Merge branch 'stable-3.8'

* stable-3.8:
  Set version to 3.8.0
  Revert "Fix GetRelated if multiple changes exist for the same commit"
  Set version to 3.8.0-SNAPSHOT
  Set version to 3.8.0-rc5
  Fix glitches when removing account from accountlist
  Fix diffs with comments on start line 0
  Add basePatchNum to SHOW_CHANGE plugin event
  Allow indexing of change numbers imported from other servers
  Fix comment context not showing up 2

Release-Notes: skip
Change-Id: I3b147195f7812486a7aaaf4bd3723753ca1dd857
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index 09ce63b..fae2a87 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -1,4 +1,5 @@
 eclipse.preferences.version=1
+org.eclipse.jdt.core.builder.annotationPath.allLocations=disabled
 org.eclipse.jdt.core.compiler.annotation.inheritNullAnnotations=disabled
 org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore
 org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jdt.annotation.NonNull
@@ -102,7 +103,7 @@
 org.eclipse.jdt.core.compiler.problem.uncheckedTypeOperation=warning
 org.eclipse.jdt.core.compiler.problem.unclosedCloseable=warning
 org.eclipse.jdt.core.compiler.problem.undocumentedEmptyBlock=ignore
-org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=warning
+org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=ignore
 org.eclipse.jdt.core.compiler.problem.unlikelyCollectionMethodArgumentType=warning
 org.eclipse.jdt.core.compiler.problem.unlikelyCollectionMethodArgumentTypeStrict=disabled
 org.eclipse.jdt.core.compiler.problem.unlikelyEqualsArgumentType=warning
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index cf89982..23bb0ef 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -951,6 +951,20 @@
 can always edit or remove hashtags (even without having the `Edit Hashtags`
 access right assigned).
 
+
+[[category_edit_custom_keyed_values]]
+=== Edit Custom Keyed Values
+
+This category permits users to add or remove
+custom keyed values on a change that is uploaded for review. Custom Keyed Values
+are used by plugins to store extra data. They are not surfaced in the UI, unless
+a plugin explicitly does so.
+
+The change owner and site administrators can always edit or remove custom
+keyed values (even without having the `Edit Custom Keyed Values` access right
+assigned).
+
+
 [[example_roles]]
 == Examples of typical roles in a project
 
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 3726e544..3cc5a5a 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1722,6 +1722,54 @@
 link:#schedule-configuration-examples[Schedule examples] can be found
 in the link:#schedule-configuration[Schedule Configuration] section.
 
+[[attentionSet]]
+=== Section attentionSet
+
+This section allows to configure readding of change owners and schedules them to
+run periodically.
+
+[[attentionSet.readdAfter]]attentionSet.readdAfter::
++
+Period of inactivity after which open no-WIP/private changes should have change owner
+added to attention-set automatically (if they are not already).
++
+By default `0`, never readd change owner.
++
+[WARNING] Auto-readding change owners may confuse/annoy users. When
+enabling this, make sure to choose a reasonably large grace period and
+inform users in advance.
++
+The following suffixes are supported to define the time unit:
++
+* `d, day, days`
+* `w, week, weeks` (`1 week` is treated as `7 days`)
+* `mon, month, months` (`1 month` is treated as `30 days`)
+* `y, year, years` (`1 year` is treated as `365 days`)
+
+[[attentionSet.readdMessage]]attentionSet.readdMessage::
++
+Attention-set message that should be shown as reason when an change owner is readded.
++
+'${URL}' can be used as a placeholder for the Gerrit web URL.
++
+By default "Owner readded to attention-set due to inactivity, see
+${URL}Documentation/user-attention-set.html#auto-readd-owner\n\n
+If you do not want to be readded to the attention-set when the timer has counted down.
+Set this change as WIP or private.".
+
+[[attentionSet.startTime]]attentionSet.startTime::
++
+The link:#schedule-configuration-startTime[start time] for running
+readd owner to attention-set.
+
+[[attentionSet.interval]]attentionSet.interval::
++
+The link:#schedule-configuration-interval[interval] for running
+readd owner to attention-set.
+
+link:#schedule-configuration-examples[Schedule examples] can be found
+in the link:#schedule-configuration[Schedule Configuration] section.
+
 [[commentlink]]
 === Section commentlink
 
@@ -1743,7 +1791,7 @@
 ----
 [commentlink "changeid"]
   match = (I[0-9a-f]{8,40})
-  link = "#/q/$1"
+  link = "/q/$1"
 
 [commentlink "bugzilla"]
   match = "(^|\\s)(bug\\s+#?)(\\d+)($|\\s)"
@@ -3950,6 +3998,9 @@
 All users must be a member of this group to allow account creation or
 authentication.
 +
+For example, setting to `ldap/gerritaccess` limits account creation or
+authentication to members of the ldap group `gerritaccess`.
++
 Setting mandatoryGroup implies enabling of `ldap.fetchMemberOfEagerly`
 +
 By default, unset.
@@ -5494,18 +5545,6 @@
 +
 By default, false.
 
-[[tracing.exportPerformanceMetrics]]tracing.exportPerformanceMetrics::
-+
-Whether to export performance metrics.
-+
-Performace logged when link:#tracing.performanceLogging[`performanceLogging`] is
-enabled, can be exported as metrics.
-+
-NOTE: Since the payload returned could be of tens of thousands metrics,
-assess the latency of the metrics endpoint before enabling this option.
-+
-By default, false.
-
 [[tracing.traceid]]
 ==== Subsection tracing.<trace-id>
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index f577000..7961d81 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2213,15 +2213,18 @@
 @Listen
 public class MyWeblinkPlugin implements PatchSetWebLink {
   private String name = "MyLink";
-  private String placeHolderUrlProjectCommit = "http://my.tool.com/project=%s/commit=%s";
+  private String placeHolderUrlProjectCommit =
+  "http://my.tool.com/project=%s/commit=%s/changeKey=%s/numericChangeId=%s";
   private String imageUrl = "http://placehold.it/16x16.gif";
 
   @Override
   public WebLinkInfo getPatchSetWebLink(String projectName, String commit,
-   String commitMessage, String branchName) {
+   String commitMessage, String branchName, String changeKey, int
+   numericChangeId) {
     return new WebLinkInfo(name,
         imageUrl,
-        String.format(placeHolderUrlProjectCommit, project, commit));
+        String.format(placeHolderUrlProjectCommit, project, commit, changeKey,
+	numericChangeId));
   }
 }
 ----
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 6c9dfef..2f43538 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -73,30 +73,6 @@
 ** `cancellation_type`:
    The cancellation type (graceful or forceful).
 
-[[performance]]
-=== Performance
-
-* `performance/operations`: Latency of performing operations
-** `operation_name`:
-   The operation that was performed.
-** `request`:
-   The request for which the operation was performed (format = '<request-type>
-   <redacted-request-uri>').
-** `plugin`:
-   The name of the plugin that performed the operation.
-* `performance/operations_count`: Number of performed operations
-** `operation_name`:
-   The operation that was performed.
-** `request`:
-   The request for which the operation was performed (format = '<request-type>
-   <redacted-request-uri>').
-** `plugin`:
-   The name of the plugin that performed the operation.
-
-Performance metrics can be enabled via the
-link:config.gerrit.html#tracing.exportPerformanceMetrics[`tracing.exportPerformanceMetrics`]
-setting.
-
 === Pushes
 
 * `receivecommits/changes`: histogram of number of changes processed in a single
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index bf51252..d5d68b3f 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -379,6 +379,18 @@
   as link:#tracking-id-info[TrackingIdInfo].
 --
 
+[[custom-keyed-values]]
+--
+* `CUSTOM_KEYED_VALUES`: include the custom key-value map
+--
+
+[[star]]
+--
+* `STAR`: include the `starred` field in
+   link:#change-info[ChangeInfo], which indicates if the change is starred
+   by the current user or not.
+--
+
 .Request
 ----
   GET /changes/?q=97&o=CURRENT_REVISION&o=CURRENT_COMMIT&o=CURRENT_FILES&o=DOWNLOAD_COMMANDS HTTP/1.0
@@ -2832,6 +2844,76 @@
   ]
 ----
 
+[[get-custom-keyed-values]]
+=== Get Custom Keyed Values
+--
+'GET /changes/link:#change-id[\{change-id\}]/custom-keyed-values'
+--
+
+Gets the custom keyed values associated with a change.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom-keyed-values HTTP/1.0
+----
+
+As response the change's custom keyed values are returned as a map of strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "key1": "value1",
+    "key2": "value2"
+  }
+----
+
+[[set-custom-keyed-values]]
+=== Set Custom Keyed Values
+--
+'POST /changes/link:#change-id[\{change-id\}]/custom-keyed-values'
+--
+
+Adds and/or removes custom keyed values from a change.
+
+The custom keyed values to add or remove must be provided in the request body
+inside a link:#custom-keyed-values-input[CustomKeyedValuesInput] entity.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/custom-keyed-values HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "add" : {
+      "key1": "value1"
+    },
+    "remove" : [
+      "key2"
+    ]
+  }
+----
+
+As response the change's custom keyed values are returned as a map of strings to strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "key1": "value1",
+    "key3": "value3"
+  }
+----
+
+
 [[list-change-messages]]
 === List Change Messages
 --
@@ -6919,6 +7001,11 @@
 |==================================
 |Field Name           ||Description
 |`id`                 ||
+The ID of the change. Subject to a 'GerritBackendFeature__return_new_change_info_id'
+ experiment, the format is either "'<project>\~<_number>'" (new format),
+or `triplet_id`. The experiment is needed to allow callers to migrate.
+'project' and '_number' are URL encoded. The callers must not rely on the format.
+|`triplet_id`         ||
 The ID of the change in the format "'<project>\~<branch>~<Change-Id>'",
 where 'project', 'branch' and 'Change-Id' are URL encoded. For 'branch' the
 `refs/heads/` prefix is omitted.
@@ -6939,6 +7026,10 @@
  of the account from the attention set.
 |`hashtags`           |optional|
 List of hashtags that are set on the change.
+|`custom_keyed_values`       |optional|
+A map that maps custom keys to custom values that are tied to a specific change,
+both in the form of strings. Only set if link:#custom-keyed-values[custom keyed
+values] are requested.
 |`change_id`          ||The Change-Id of the change.
 |`subject`            ||
 The subject of the change (header line of the commit message).
@@ -6958,6 +7049,7 @@
 link:rest-api-accounts.html#account-info[ AccountInfo] entity.
 |`starred`            |not set if `false`|
 Whether the calling user has starred this change with the default label.
+Only set if link:#star[requested].
 |`stars`              |optional|
 A list of star labels that are applied by the calling user to this
 change. The labels are lexicographically sorted.
@@ -7160,6 +7252,8 @@
 listeners that are implemented in plugins may. Please refer to the
 documentation of the installed plugins to learn whether they support validation
 options. Unknown validation options are silently ignored.
+|`custom_keyed_values`|optional|Custom keyed values as a
+map from custom keys to values.
 |`merge`              |optional|
 The detail of a merge commit as a link:#merge-input[MergeInput] entity.
 If set, the target branch (see  `branch` field) must exist (it is not
@@ -7680,6 +7774,19 @@
 === ApplyProvidedFixInput
 The `ApplyProvidedFixInput` entity contains the fixes to be applied on a review.
 
+[[custom-keyed-values-input]]
+=== CustomKeyedValuesInput
+
+The `CustomKeyedValuesInput` entity contains information about custom keyed values
+to add to, and/or remove from, a change.
+
+[options="header",cols="1,^1,5"]
+|=======================
+|Field Name||Description
+|`add`     |optional|The map of custom keyed values to be added to the change.
+|`remove`  |optional|The list of custom keys to be removed from the change.
+|=======================
+
 [options="header",cols="1,6"]
 |=======================
 |Field Name              |Description
@@ -8153,6 +8260,9 @@
 patch set is inferred. +
 Empty string is used for rebasing directly on top of the target branch,
 which effectively breaks dependency towards a parent change.
+|`strategy`       |optional|
+The strategy of the merge, can be `recursive`, `resolve`,
+`simple-two-way-in-core`, `ours` or `theirs`, default will use project settings.
 |`allow_conflicts`      |optional, defaults to false|
 If `true`, the rebase also succeeds if there are conflicts. +
 If there are conflicts the file contents of the rebased patch set contain
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 738205a..93b1c7d 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -191,3 +191,18 @@
 
 SEARCHBOX
 ---------
+
+=== Auto readd owner [[auto-readd-owner]]
+
+This job automatically readds the change owner to the attention-set for open non-WIP/private
+changes that have been inactive for a defined time. Gerrit administrators may configure
+link:config-gerrit.html#auto-readd[this]
+
+Readding the owner to the attention-set of an inactive change has the advantages:
+
+* It signals the change owner that the review is not progressing and that the owner
+may need to adjust the attention-set or indicate a need for a priority review.
+* It may prevent changes where no one is in the attention-set from getting forgotten.
+* It makes people set changes in WIP or private for changes that should not
+be actively reviewed.
+
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 1b87f32..91a4da6 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -43,6 +43,7 @@
     PLUGIN_DELETE_PROJECT,
     PLUGIN_HIGH_AVAILABILITY,
     PLUGIN_MULTI_SITE,
+    PLUGIN_PULL_REPLICATION,
     PLUGIN_SERVICEUSER,
     PLUGIN_WEBSESSION_FLATFILE,
     MODULE_GIT_REFS_FILTER
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 56fb748..77437b3 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ChangeStatus;
@@ -492,6 +493,9 @@
   /** References the source change and patchset that this change was cherry-picked from. */
   @Nullable private PatchSet.Id cherryPickOf;
 
+  /** Custom keyed values that were provided during change creation. */
+  @Nullable private ImmutableMap<String, String> customKeyedValues;
+
   Change() {}
 
   public Change(
@@ -523,6 +527,7 @@
     reviewStarted = other.reviewStarted;
     revertOf = other.revertOf;
     cherryPickOf = other.cherryPickOf;
+    customKeyedValues = other.customKeyedValues;
   }
 
   /** 32 bit integer identity for a change. */
@@ -713,6 +718,14 @@
     this.cherryPickOf = cherryPickOf;
   }
 
+  public void setCustomKeyedValues(ImmutableMap<String, String> customKeyedValues) {
+    this.customKeyedValues = customKeyedValues;
+  }
+
+  public ImmutableMap<String, String> getCustomKeyedValues() {
+    return customKeyedValues;
+  }
+
   @Override
   public String toString() {
     return new StringBuilder(getClass().getSimpleName())
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 2a34579..0e959e7 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -36,6 +36,7 @@
   public static final String DELETE = "delete";
   public static final String DELETE_CHANGES = "deleteChanges";
   public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
+  public static final String EDIT_CUSTOM_KEYED_VALUES = "editCustomKeyedValues";
   public static final String EDIT_HASHTAGS = "editHashtags";
   public static final String EDIT_TOPIC_NAME = "editTopicName";
   public static final String FORGE_AUTHOR = "forgeAuthor";
@@ -73,6 +74,7 @@
     NAMES_LC.add(DELETE.toLowerCase(Locale.US));
     NAMES_LC.add(DELETE_CHANGES.toLowerCase(Locale.US));
     NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase(Locale.US));
+    NAMES_LC.add(EDIT_CUSTOM_KEYED_VALUES.toLowerCase(Locale.US));
     NAMES_LC.add(EDIT_HASHTAGS.toLowerCase(Locale.US));
     NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase(Locale.US));
     NAMES_LC.add(FORGE_AUTHOR.toLowerCase(Locale.US));
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index ef61b68..d8fd727 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -337,6 +338,16 @@
    */
   Set<String> getHashtags() throws RestApiException;
 
+  /** Set custom keyed values on a change */
+  void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException;
+
+  /**
+   * Gets the custom keyed values on a change.
+   *
+   * @return customKeyedValues
+   */
+  ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException;
+
   /**
    * Manage the attention set.
    *
@@ -720,6 +731,16 @@
     }
 
     @Override
+    public void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public AttentionSetApi attention(String id) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java b/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java
new file mode 100644
index 0000000..a603328
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/CustomKeyedValuesInput.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class CustomKeyedValuesInput {
+  @DefaultInput public ImmutableMap<String, String> add;
+  public ImmutableSet<String> remove;
+
+  public CustomKeyedValuesInput() {}
+
+  public CustomKeyedValuesInput(ImmutableMap<String, String> add) {
+    this.add = add;
+  }
+
+  public CustomKeyedValuesInput(ImmutableMap<String, String> add, ImmutableSet<String> remove) {
+    this(add);
+    this.remove = remove;
+  }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index a85bc73..07e65d0 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -20,6 +20,13 @@
   public String base;
 
   /**
+   * {@code strategy} name of the merge strategy.
+   *
+   * @see org.eclipse.jgit.merge.MergeStrategy
+   */
+  public String strategy;
+
+  /**
    * Whether the rebase should succeed if there are conflicts.
    *
    * <p>If there are conflicts the file contents of the rebased change contain git conflict markers
diff --git a/java/com/google/gerrit/extensions/client/ListChangesOption.java b/java/com/google/gerrit/extensions/client/ListChangesOption.java
index f1f7831..e2a7c1e 100644
--- a/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -88,7 +88,13 @@
   SKIP_DIFFSTAT(23),
 
   /** Include the evaluated submit requirements for the caller. */
-  SUBMIT_REQUIREMENTS(24);
+  SUBMIT_REQUIREMENTS(24),
+
+  /** Include custom keyed values. */
+  CUSTOM_KEYED_VALUES(25),
+
+  /** Include the 'starred' field, that is if the change is starred by the current user . */
+  STAR(26);
 
   private final int value;
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index dc9bc32..a2e2e8f 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -37,6 +37,8 @@
   // protected by any ListChangesOption.
 
   public String id;
+  public String tripletId;
+
   public String project;
   public String branch;
   public String topic;
@@ -49,6 +51,8 @@
 
   public Map<Integer, AttentionSetInfo> removedFromAttentionSet;
 
+  public Map<String, String> customKeyedValues;
+
   public Collection<String> hashtags;
   public String changeId;
   public String subject;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index 6f9cff7..2e2b9ca 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -38,6 +38,7 @@
   public String baseCommit;
   public Boolean newBranch;
   public Map<String, String> validationOptions;
+  public Map<String, String> customKeyedValues;
   public MergeInput merge;
   public ApplyPatchInput patch;
 
diff --git a/java/com/google/gerrit/extensions/events/CommentAddedListener.java b/java/com/google/gerrit/extensions/events/CommentAddedListener.java
index 071dac1..7e0b623 100644
--- a/java/com/google/gerrit/extensions/events/CommentAddedListener.java
+++ b/java/com/google/gerrit/extensions/events/CommentAddedListener.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.events;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import java.util.Map;
@@ -22,6 +23,7 @@
 @ExtensionPoint
 public interface CommentAddedListener {
   interface Event extends RevisionEvent {
+    @Nullable
     String getComment();
 
     Map<String, ApprovalInfo> getApprovals();
diff --git a/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java b/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java
new file mode 100644
index 0000000..d008675
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/CustomKeyedValuesEditedListener.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Notified whenever a Change's Custom Keyed Values are edited. */
+@ExtensionPoint
+public interface CustomKeyedValuesEditedListener {
+  interface Event extends ChangeEvent {
+    ImmutableMap<String, String> getCustomKeyedValues();
+
+    ImmutableMap<String, String> getAddedCustomKeyedValues();
+
+    ImmutableSet<String> getRemovedCustomKeys();
+  }
+
+  void onCustomKeyedValuesEdited(Event event);
+}
diff --git a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
index 74bccbd..4f8fa10 100644
--- a/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
+++ b/java/com/google/gerrit/extensions/webui/PatchSetWebLink.java
@@ -59,6 +59,7 @@
    * @return WebLinkInfo that links to patch set in external service, null if there should be no
    *     link.
    */
+  @Deprecated
   default WebLinkInfo getPatchSetWebLink(
       String projectName,
       String commit,
@@ -67,4 +68,33 @@
       String changeKey) {
     return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
   }
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a patch set to
+   * an external service.
+   *
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * <p>
+   *
+   * @param projectName name of the project
+   * @param commit commit of the patch set
+   * @param commitMessage the commit message of the change
+   * @param branchName target branch of the change
+   * @param changeKey the changeID for this change
+   * @param numericChangeId the numeric changeID for this change
+   * @return WebLinkInfo that links to patch set in external service, null if there should be no
+   *     link.
+   */
+  default WebLinkInfo getPatchSetWebLink(
+      String projectName,
+      String commit,
+      String commitMessage,
+      String branchName,
+      String changeKey,
+      int numericChangeId) {
+    return getPatchSetWebLink(projectName, commit, commitMessage, branchName, changeKey);
+  }
 }
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index b2173c4..fcf4f0f 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -11,7 +11,6 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/api",
         "//lib:guava",
         "//lib:jgit",
         "//lib/auto:auto-factory",
diff --git a/java/com/google/gerrit/gpg/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 5347398..946fee3 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -237,7 +237,6 @@
       List<PGPSignature> revocations,
       Map<Long, RevocationKey> revokers)
       throws PGPException {
-    @SuppressWarnings("unchecked")
     Iterator<PGPSignature> allSigs = key.getSignatures();
     while (allSigs.hasNext()) {
       PGPSignature sig = allSigs.next();
diff --git a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
new file mode 100644
index 0000000..2c1cae6
--- /dev/null
+++ b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.gpg;
+
+import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.io.BaseEncoding;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.util.NB;
+
+public class PublicKeyStoreUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ExternalIds externalIds;
+  private final Provider<PublicKeyStore> storeProvider;
+
+  @Inject
+  public PublicKeyStoreUtil(ExternalIds externalIds, Provider<PublicKeyStore> storeProvider) {
+    this.externalIds = externalIds;
+    this.storeProvider = storeProvider;
+  }
+
+  public static byte[] parseFingerprint(ExternalId gpgKeyExtId) {
+    return BaseEncoding.base16().decode(gpgKeyExtId.key().id());
+  }
+
+  public static long keyIdFromFingerprint(byte[] fp) {
+    return NB.decodeInt64(fp, fp.length - 8);
+  }
+
+  public List<PGPPublicKey> listGpgKeysForUser(Account.Id id) throws PGPException, IOException {
+    List<PGPPublicKey> keys = new ArrayList<>();
+    try (PublicKeyStore store = storeProvider.get()) {
+      for (ExternalId extId : getGpgExtIds(id)) {
+        byte[] fp = parseFingerprint(extId);
+        boolean found = false;
+        for (PGPPublicKeyRing keyRing : store.get(keyIdFromFingerprint(fp))) {
+          if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
+            found = true;
+            keys.add(keyRing.getPublicKey());
+            break;
+          }
+        }
+        if (!found) {
+          logger.atWarning().log(
+              "No public key stored for fingerprint %s", Fingerprint.toString(fp));
+        }
+      }
+    }
+    return keys;
+  }
+
+  public Iterable<ExternalId> getGpgExtIds(Account.Id id) throws IOException {
+    return externalIds.byAccount(id, SCHEME_GPGKEY);
+  }
+
+  public RefUpdate.Result deletePgpKey(PGPPublicKey key, PersonIdent committer, PersonIdent author)
+      throws PGPException, IOException {
+    return deletePgpKeys(ImmutableList.of(key), committer, author).get(0);
+  }
+
+  public List<RefUpdate.Result> deletePgpKeys(
+      List<PGPPublicKey> keys, PersonIdent committer, PersonIdent author)
+      throws IOException, PGPException {
+    List<RefUpdate.Result> res = new ArrayList<>();
+    try (PublicKeyStore store = storeProvider.get()) {
+      for (PGPPublicKey key : keys) {
+        store.remove(key.getFingerprint());
+
+        CommitBuilder cb = new CommitBuilder();
+        cb.setAuthor(author);
+        cb.setCommitter(committer);
+        cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
+
+        RefUpdate.Result saveResult = store.save(cb);
+        res.add(saveResult);
+      }
+    }
+    return res;
+  }
+}
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 6ae0334..57fda5b 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.gpg.api;
 
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.api.accounts.GpgKeysInput;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -70,8 +68,10 @@
       return gpgKeys.get().list().apply(account).value();
     } catch (PGPException | IOException e) {
       throw new GpgException(e);
+    } catch (RestApiException e) {
+      throw e;
     } catch (Exception e) {
-      throw asRestApiException("Cannot list GPG keys", e);
+      throw RestApiException.wrap("Cannot list GPG keys", e);
     }
   }
 
@@ -86,8 +86,10 @@
       return postGpgKeys.get().apply(account, in).value();
     } catch (PGPException | IOException | ConfigInvalidException e) {
       throw new GpgException(e);
+    } catch (RestApiException e) {
+      throw e;
     } catch (Exception e) {
-      throw asRestApiException("Cannot put GPG keys", e);
+      throw RestApiException.wrap("Cannot put GPG keys", e);
     }
   }
 
diff --git a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 0ff12e8..2a05f35 100644
--- a/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.gpg.api;
 
-import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
-
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.Input;
@@ -50,7 +48,7 @@
     try {
       return get.apply(rsrc).value();
     } catch (Exception e) {
-      throw asRestApiException("Cannot get GPG key", e);
+      throw RestApiException.wrap("Cannot get GPG key", e);
     }
   }
 
@@ -58,8 +56,10 @@
   public void delete() throws RestApiException {
     try {
       delete.apply(rsrc, new Input());
+    } catch (RestApiException e) {
+      throw e;
     } catch (PGPException | IOException | ConfigInvalidException e) {
-      throw asRestApiException("Cannot delete GPG key", e);
+      throw RestApiException.wrap("Cannot delete GPG key", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index bcc8631..6381ada 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.gpg.PublicKeyStore.keyIdToString;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.collect.ImmutableList;
@@ -28,13 +27,14 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailModule.DeleteKeyEmailFactories;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -42,7 +42,6 @@
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 
@@ -50,25 +49,25 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Provider<PersonIdent> serverIdent;
-  private final Provider<PublicKeyStore> storeProvider;
+  private final PublicKeyStoreUtil publicKeyStoreUtil;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final ExternalIds externalIds;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final DeleteKeyEmailFactories deleteKeyEmailFactories;
   private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
   DeleteGpgKey(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<PublicKeyStore> storeProvider,
+      PublicKeyStoreUtil publicKeyStoreUtil,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       ExternalIds externalIds,
-      DeleteKeySender.Factory deleteKeySenderFactory,
+      DeleteKeyEmailFactories deleteKeyEmailFactories,
       ExternalIdKeyFactory externalIdKeyFactory) {
     this.serverIdent = serverIdent;
-    this.storeProvider = storeProvider;
+    this.publicKeyStoreUtil = publicKeyStoreUtil;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.externalIds = externalIds;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.deleteKeyEmailFactories = deleteKeyEmailFactories;
     this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
@@ -90,42 +89,35 @@
             rsrc.getUser().getAccountId(),
             u -> u.deleteExternalId(extId.get()));
 
-    try (PublicKeyStore store = storeProvider.get()) {
-      store.remove(rsrc.getKeyRing().getPublicKey().getFingerprint());
+    PersonIdent committer = serverIdent.get();
+    PersonIdent author = rsrc.getUser().newCommitterIdent(committer);
 
-      CommitBuilder cb = new CommitBuilder();
-      PersonIdent committer = serverIdent.get();
-      cb.setAuthor(rsrc.getUser().newCommitterIdent(committer));
-      cb.setCommitter(committer);
-      cb.setMessage("Delete public key " + keyIdToString(key.getKeyID()));
-
-      RefUpdate.Result saveResult = store.save(cb);
-      switch (saveResult) {
-        case NO_CHANGE:
-        case FAST_FORWARD:
-          try {
-            deleteKeySenderFactory
-                .create(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
-                .send();
-          } catch (EmailException e) {
-            logger.atSevere().withCause(e).log(
-                "Cannot send GPG key deletion message to %s",
-                rsrc.getUser().getAccount().preferredEmail());
-          }
-          break;
-        case LOCK_FAILURE:
-        case FORCED:
-        case IO_FAILURE:
-        case NEW:
-        case NOT_ATTEMPTED:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        case REJECTED_MISSING_OBJECT:
-        case REJECTED_OTHER_REASON:
-        default:
-          throw new StorageException(String.format("Failed to delete public key: %s", saveResult));
-      }
+    RefUpdate.Result saveResult = publicKeyStoreUtil.deletePgpKey(key, committer, author);
+    switch (saveResult) {
+      case NO_CHANGE:
+      case FAST_FORWARD:
+        try {
+          deleteKeyEmailFactories
+              .createEmail(rsrc.getUser(), ImmutableList.of(PublicKeyStore.keyToString(key)))
+              .send();
+        } catch (EmailException e) {
+          logger.atSevere().withCause(e).log(
+              "Cannot send GPG key deletion message to %s",
+              rsrc.getUser().getAccount().preferredEmail());
+        }
+        break;
+      case LOCK_FAILURE:
+      case FORCED:
+      case IO_FAILURE:
+      case NEW:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      default:
+        throw new StorageException(String.format("Failed to delete public key: %s", saveResult));
     }
     return Response.none();
   }
diff --git a/java/com/google/gerrit/gpg/server/GpgKeys.java b/java/com/google/gerrit/gpg/server/GpgKeys.java
index 00a0f57..9fb8286 100644
--- a/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -14,13 +14,10 @@
 
 package com.google.gerrit.gpg.server;
 
-import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableList;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.io.BaseEncoding;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -36,10 +33,10 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.externalids.ExternalId;
-import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -48,36 +45,35 @@
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Iterator;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
 import org.bouncycastle.openpgp.PGPException;
 import org.bouncycastle.openpgp.PGPPublicKey;
 import org.bouncycastle.openpgp.PGPPublicKeyRing;
-import org.eclipse.jgit.util.NB;
 
 @Singleton
 public class GpgKeys implements ChildCollection<AccountResource, GpgKey> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final DynamicMap<RestView<GpgKey>> views;
   private final Provider<CurrentUser> self;
+  private final PublicKeyStoreUtil publicKeyStoreUtil;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final ExternalIds externalIds;
 
   @Inject
   GpgKeys(
       DynamicMap<RestView<GpgKey>> views,
       Provider<CurrentUser> self,
+      PublicKeyStoreUtil publicKeyStoreUtil,
       Provider<PublicKeyStore> storeProvider,
-      GerritPublicKeyChecker.Factory checkerFactory,
-      ExternalIds externalIds) {
+      GerritPublicKeyChecker.Factory checkerFactory) {
     this.views = views;
     this.self = self;
+    this.publicKeyStoreUtil = publicKeyStoreUtil;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
-    this.externalIds = externalIds;
   }
 
   @Override
@@ -90,10 +86,11 @@
       throws ResourceNotFoundException, PGPException, IOException {
     checkVisible(self, parent);
 
-    ExternalId gpgKeyExtId = findGpgKey(id.get(), getGpgExtIds(parent));
-    byte[] fp = parseFingerprint(gpgKeyExtId);
+    ExternalId gpgKeyExtId =
+        findGpgKey(id.get(), publicKeyStoreUtil.getGpgExtIds(parent.getUser().getAccountId()));
+    byte[] fp = PublicKeyStoreUtil.parseFingerprint(gpgKeyExtId);
     try (PublicKeyStore store = storeProvider.get()) {
-      long keyId = keyId(fp);
+      long keyId = PublicKeyStoreUtil.keyIdFromFingerprint(fp);
       for (PGPPublicKeyRing keyRing : store.get(keyId)) {
         PGPPublicKey key = keyRing.getPublicKey();
         if (Arrays.equals(key.getFingerprint(), fp)) {
@@ -131,10 +128,6 @@
     return gpgKeyExtId;
   }
 
-  static byte[] parseFingerprint(ExternalId gpgKeyExtId) {
-    return BaseEncoding.base16().decode(gpgKeyExtId.key().id());
-  }
-
   @Override
   public DynamicMap<RestView<GpgKey>> views() {
     return views;
@@ -145,29 +138,17 @@
     public Response<Map<String, GpgKeyInfo>> apply(AccountResource rsrc)
         throws PGPException, IOException, ResourceNotFoundException {
       checkVisible(self, rsrc);
-      Map<String, GpgKeyInfo> keys = new HashMap<>();
+      List<PGPPublicKey> keys =
+          publicKeyStoreUtil.listGpgKeysForUser(rsrc.getUser().getAccountId());
+      Map<String, GpgKeyInfo> res = new HashMap<>();
       try (PublicKeyStore store = storeProvider.get()) {
-        for (ExternalId extId : getGpgExtIds(rsrc)) {
-          byte[] fp = parseFingerprint(extId);
-          boolean found = false;
-          for (PGPPublicKeyRing keyRing : store.get(keyId(fp))) {
-            if (Arrays.equals(keyRing.getPublicKey().getFingerprint(), fp)) {
-              found = true;
-              GpgKeyInfo info =
-                  toJson(
-                      keyRing.getPublicKey(), checkerFactory.create(rsrc.getUser(), store), store);
-              keys.put(info.id, info);
-              info.id = null;
-              break;
-            }
-          }
-          if (!found) {
-            logger.atWarning().log(
-                "No public key stored for fingerprint %s", Fingerprint.toString(fp));
-          }
+        for (PGPPublicKey key : keys) {
+          GpgKeyInfo info = toJson(key, checkerFactory.create(rsrc.getUser(), store), store);
+          res.put(info.id, info);
+          info.id = null;
         }
       }
-      return Response.ok(keys);
+      return Response.ok(res);
     }
   }
 
@@ -194,14 +175,6 @@
     }
   }
 
-  private Iterable<ExternalId> getGpgExtIds(AccountResource rsrc) throws IOException {
-    return externalIds.byAccount(rsrc.getUser().getAccountId(), SCHEME_GPGKEY);
-  }
-
-  private static long keyId(byte[] fp) {
-    return NB.decodeInt64(fp, fp.length - 8);
-  }
-
   static void checkVisible(Provider<CurrentUser> self, AccountResource rsrc)
       throws ResourceNotFoundException {
     if (!BouncyCastleUtil.havePGP()) {
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index d51ee6a..edd5a58 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -57,8 +58,8 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailModule.AddKeyEmailFactories;
+import com.google.gerrit.server.mail.EmailModule.DeleteKeyEmailFactories;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
@@ -90,8 +91,8 @@
   private final Provider<CurrentUser> self;
   private final Provider<PublicKeyStore> storeProvider;
   private final GerritPublicKeyChecker.Factory checkerFactory;
-  private final AddKeySender.Factory addKeySenderFactory;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final AddKeyEmailFactories addKeyEmailFactories;
+  private final DeleteKeyEmailFactories deleteKeyEmailFactories;
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
@@ -105,8 +106,8 @@
       Provider<CurrentUser> self,
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory,
-      AddKeySender.Factory addKeySenderFactory,
-      DeleteKeySender.Factory deleteKeySenderFactory,
+      AddKeyEmailFactories addKeyEmailFactories,
+      DeleteKeyEmailFactories deleteKeyEmailFactories,
       Provider<InternalAccountQuery> accountQueryProvider,
       ExternalIds externalIds,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
@@ -117,8 +118,8 @@
     this.self = self;
     this.storeProvider = storeProvider;
     this.checkerFactory = checkerFactory;
-    this.addKeySenderFactory = addKeySenderFactory;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.addKeyEmailFactories = addKeyEmailFactories;
+    this.deleteKeyEmailFactories = deleteKeyEmailFactories;
     this.accountQueryProvider = accountQueryProvider;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
@@ -175,7 +176,8 @@
     for (String id : input.delete) {
       try {
         ExternalId gpgKeyExtId = GpgKeys.findGpgKey(id, existingExtIds);
-        fingerprints.put(gpgKeyExtId, new Fingerprint(GpgKeys.parseFingerprint(gpgKeyExtId)));
+        fingerprints.put(
+            gpgKeyExtId, new Fingerprint(PublicKeyStoreUtil.parseFingerprint(gpgKeyExtId)));
       } catch (ResourceNotFoundException e) {
         // Skip removal.
       }
@@ -261,7 +263,7 @@
         case FORCED:
           if (!addedKeys.isEmpty()) {
             try {
-              addKeySenderFactory.create(user, addedKeys).send();
+              addKeyEmailFactories.createEmail(user, addedKeys).send();
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
                   "Cannot send GPG key added message to %s",
@@ -270,8 +272,8 @@
           }
           if (!toRemove.isEmpty()) {
             try {
-              deleteKeySenderFactory
-                  .create(user, toRemove.stream().map(Fingerprint::toString).collect(toList()))
+              deleteKeyEmailFactories
+                  .createEmail(user, toRemove.stream().map(Fingerprint::toString).collect(toList()))
                   .send();
             } catch (EmailException e) {
               logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 7293f35..392b35d 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -60,6 +60,7 @@
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.AttentionSetOwnerAdder.AttentionSetOwnerAdderModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
@@ -360,6 +361,7 @@
           }
         });
     modules.add(new GarbageCollectionModule());
+    modules.add(new AttentionSetOwnerAdderModule());
     modules.add(new ChangeCleanupRunnerModule());
     modules.add(new AccountDeactivatorModule());
     modules.add(new DefaultProjectNameLockManagerModule());
diff --git a/java/com/google/gerrit/httpd/restapi/CorsResponder.java b/java/com/google/gerrit/httpd/restapi/CorsResponder.java
new file mode 100644
index 0000000..60dce61
--- /dev/null
+++ b/java/com/google/gerrit/httpd/restapi/CorsResponder.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.restapi;
+
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
+import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
+import static com.google.common.net.HttpHeaders.AUTHORIZATION;
+import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
+import static com.google.common.net.HttpHeaders.ORIGIN;
+import static com.google.common.net.HttpHeaders.VARY;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.util.http.CacheHeaders;
+import java.util.Locale;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
+
+/** Provides methods for processing CORS requests. */
+public class CorsResponder {
+  private static final String PLAIN_TEXT = "text/plain";
+  private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
+  private static final String X_REQUESTED_WITH = "X-Requested-With";
+
+  static final ImmutableSet<String> ALLOWED_CORS_METHODS =
+      ImmutableSet.of("GET", "HEAD", "POST", "PUT", "DELETE");
+  private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
+      Stream.of(AUTHORIZATION, CONTENT_TYPE, X_GERRIT_AUTH, X_REQUESTED_WITH)
+          .map(s -> s.toLowerCase(Locale.US))
+          .collect(ImmutableSet.toImmutableSet());
+
+  private static boolean isCorsPreflight(HttpServletRequest req) {
+    return "OPTIONS".equals(req.getMethod())
+        && !Strings.isNullOrEmpty(req.getHeader(ORIGIN))
+        && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD));
+  }
+
+  @Nullable
+  public static Pattern makeAllowOrigin(Config cfg) {
+    String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
+    if (allow.length > 0) {
+      return Pattern.compile(Joiner.on('|').join(allow));
+    }
+    return null;
+  }
+
+  @Nullable private final Pattern allowOrigin;
+
+  public CorsResponder(@Nullable Pattern allowOrigin) {
+    this.allowOrigin = allowOrigin;
+  }
+
+  /**
+   * Responses to a CORS preflight request.
+   *
+   * <p>If the request is a CORS preflight request, the method writes a correct preflight response
+   * and returns true. A further processing of the request is not required. Otherwise, the method
+   * returns false without adding anything to the response.
+   */
+  public boolean filterCorsPreflight(HttpServletRequest req, HttpServletResponse res)
+      throws BadRequestException {
+    if (!isCorsPreflight(req)) {
+      return false;
+    }
+    doCorsPreflight(req, res);
+    return true;
+  }
+
+  /**
+   * Processes CORS request and add required headers to the response.
+   *
+   * <p>The method checks if the incoming request is a CORS request and if so validates the
+   * request's origin.
+   */
+  public void checkCors(HttpServletRequest req, HttpServletResponse res, boolean isXd)
+      throws BadRequestException {
+    String origin = req.getHeader(ORIGIN);
+    if (isXd) {
+      // Cross-domain, non-preflighted requests must come from an approved origin.
+      if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+        throw new BadRequestException("origin not allowed");
+      }
+      res.addHeader(VARY, ORIGIN);
+      res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+      res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    } else if (!Strings.isNullOrEmpty(origin)) {
+      // All other requests must be processed, but conditionally set CORS headers.
+      if (allowOrigin != null) {
+        res.addHeader(VARY, ORIGIN);
+      }
+      if (isOriginAllowed(origin)) {
+        setCorsHeaders(res, origin);
+      }
+    }
+  }
+
+  private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res)
+      throws BadRequestException {
+    CacheHeaders.setNotCacheable(res);
+    setHeaderList(
+        res,
+        VARY,
+        ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS));
+
+    String origin = req.getHeader(ORIGIN);
+    if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
+      throw new BadRequestException("CORS not allowed");
+    }
+
+    String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
+    if (!ALLOWED_CORS_METHODS.contains(method)) {
+      throw new BadRequestException(method + " not allowed in CORS");
+    }
+
+    String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
+    if (headers != null) {
+      for (String reqHdr : Splitter.on(',').trimResults().split(headers)) {
+        if (!ALLOWED_CORS_REQUEST_HEADERS.contains(reqHdr.toLowerCase(Locale.US))) {
+          throw new BadRequestException(reqHdr + " not allowed in CORS");
+        }
+      }
+    }
+
+    res.setStatus(SC_OK);
+    setCorsHeaders(res, origin);
+    res.setContentType(PLAIN_TEXT);
+    res.setContentLength(0);
+  }
+
+  private static void setCorsHeaders(HttpServletResponse res, String origin) {
+    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
+    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
+    res.setHeader(ACCESS_CONTROL_MAX_AGE, "600");
+    setHeaderList(
+        res,
+        ACCESS_CONTROL_ALLOW_METHODS,
+        Iterables.concat(ALLOWED_CORS_METHODS, ImmutableList.of("OPTIONS")));
+    setHeaderList(res, ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_REQUEST_HEADERS);
+  }
+
+  private static void setHeaderList(HttpServletResponse res, String name, Iterable<String> values) {
+    res.setHeader(name, Joiner.on(", ").join(values));
+  }
+
+  private boolean isOriginAllowed(String origin) {
+    return allowOrigin != null && allowOrigin.matcher(origin).matches();
+  }
+}
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 315c9c8..de53e64 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.httpd.restapi;
 
-import static com.google.gerrit.httpd.restapi.RestApiServlet.ALLOWED_CORS_METHODS;
+import static com.google.gerrit.httpd.restapi.CorsResponder.ALLOWED_CORS_METHODS;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_AUTHORIZATION;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_CONTENT_TYPE;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_METHOD;
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 44e7854..4e11346 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -19,17 +19,7 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.flogger.LazyArgs.lazy;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_MAX_AGE;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS;
-import static com.google.common.net.HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD;
-import static com.google.common.net.HttpHeaders.AUTHORIZATION;
 import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
-import static com.google.common.net.HttpHeaders.ORIGIN;
-import static com.google.common.net.HttpHeaders.VARY;
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -48,13 +38,11 @@
 import static javax.servlet.http.HttpServletResponse.SC_REQUEST_TIMEOUT;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -179,7 +167,6 @@
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Locale;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
@@ -187,7 +174,6 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.regex.Pattern;
-import java.util.stream.Stream;
 import java.util.zip.GZIPOutputStream;
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServlet;
@@ -216,15 +202,6 @@
   @VisibleForTesting
   public static final String X_GERRIT_UPDATED_REF_ENABLED = "X-Gerrit-UpdatedRef-Enabled";
 
-  private static final String X_REQUESTED_WITH = "X-Requested-With";
-  private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
-  static final ImmutableSet<String> ALLOWED_CORS_METHODS =
-      ImmutableSet.of("GET", "HEAD", "POST", "PUT", "DELETE");
-  private static final ImmutableSet<String> ALLOWED_CORS_REQUEST_HEADERS =
-      Stream.of(AUTHORIZATION, CONTENT_TYPE, X_GERRIT_AUTH, X_REQUESTED_WITH)
-          .map(s -> s.toLowerCase(Locale.US))
-          .collect(ImmutableSet.toImmutableSet());
-
   public static final String XD_AUTHORIZATION = "access_token";
   public static final String XD_CONTENT_TYPE = "$ct";
   public static final String XD_METHOD = "$m";
@@ -302,25 +279,17 @@
       this.changeFinder = changeFinder;
       this.retryHelper = retryHelper;
       this.exceptionHooks = exceptionHooks;
-      allowOrigin = makeAllowOrigin(config);
+      allowOrigin = CorsResponder.makeAllowOrigin(config);
       this.injector = injector;
       this.dynamicBeans = dynamicBeans;
       this.deadlineCheckerFactory = deadlineCheckerFactory;
       this.cancellationMetrics = cancellationMetrics;
     }
-
-    @Nullable
-    private static Pattern makeAllowOrigin(Config cfg) {
-      String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
-      if (allow.length > 0) {
-        return Pattern.compile(Joiner.on('|').join(allow));
-      }
-      return null;
-    }
   }
 
   private final Globals globals;
   private final Provider<RestCollection<RestResource, RestResource>> members;
+  private final CorsResponder corsResponder;
 
   public RestApiServlet(
       Globals globals, RestCollection<? extends RestResource, ? extends RestResource> members) {
@@ -335,6 +304,7 @@
         (Provider<RestCollection<RestResource, RestResource>>) requireNonNull((Object) members);
     this.globals = globals;
     this.members = n;
+    this.corsResponder = new CorsResponder(globals.allowOrigin);
   }
 
   @Override
@@ -375,13 +345,12 @@
                 new PerformanceLogContext(globals.config, globals.performanceLoggers)) {
           traceRequestData(req);
 
-          if (isCorsPreflight(req)) {
-            doCorsPreflight(req, res);
+          if (corsResponder.filterCorsPreflight(req, res)) {
             return;
           }
 
           qp = ParameterParser.getQueryParams(req);
-          checkCors(req, res, qp.hasXdOverride());
+          corsResponder.checkCors(req, res, qp.hasXdOverride());
           if (qp.hasXdOverride()) {
             req = applyXdOverrides(req, qp);
           }
@@ -550,7 +519,6 @@
                       (RestReadView<RestResource>) viewData.view,
                       rsrc);
             } else if (viewData.view instanceof RestModifyView<?, ?>) {
-              @SuppressWarnings("unchecked")
               RestModifyView<RestResource, Object> m =
                   (RestModifyView<RestResource, Object>) viewData.view;
 
@@ -566,7 +534,6 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
-              @SuppressWarnings("unchecked")
               RestCollectionCreateView<RestResource, RestResource, Object> m =
                   (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
 
@@ -581,7 +548,6 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
-              @SuppressWarnings("unchecked")
               RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
                   (RestCollectionDeleteMissingView<RestResource, RestResource, Object>)
                       viewData.view;
@@ -597,7 +563,6 @@
                 }
               }
             } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
-              @SuppressWarnings("unchecked")
               RestCollectionModifyView<RestResource, RestResource, Object> m =
                   (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
 
@@ -1032,86 +997,6 @@
     };
   }
 
-  private void checkCors(HttpServletRequest req, HttpServletResponse res, boolean isXd)
-      throws BadRequestException {
-    String origin = req.getHeader(ORIGIN);
-    if (isXd) {
-      // Cross-domain, non-preflighted requests must come from an approved origin.
-      if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
-        throw new BadRequestException("origin not allowed");
-      }
-      res.addHeader(VARY, ORIGIN);
-      res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
-      res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
-    } else if (!Strings.isNullOrEmpty(origin)) {
-      // All other requests must be processed, but conditionally set CORS headers.
-      if (globals.allowOrigin != null) {
-        res.addHeader(VARY, ORIGIN);
-      }
-      if (isOriginAllowed(origin)) {
-        setCorsHeaders(res, origin);
-      }
-    }
-  }
-
-  private static boolean isCorsPreflight(HttpServletRequest req) {
-    return "OPTIONS".equals(req.getMethod())
-        && !Strings.isNullOrEmpty(req.getHeader(ORIGIN))
-        && !Strings.isNullOrEmpty(req.getHeader(ACCESS_CONTROL_REQUEST_METHOD));
-  }
-
-  private void doCorsPreflight(HttpServletRequest req, HttpServletResponse res)
-      throws BadRequestException {
-    CacheHeaders.setNotCacheable(res);
-    setHeaderList(
-        res,
-        VARY,
-        ImmutableList.of(ORIGIN, ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS));
-
-    String origin = req.getHeader(ORIGIN);
-    if (Strings.isNullOrEmpty(origin) || !isOriginAllowed(origin)) {
-      throw new BadRequestException("CORS not allowed");
-    }
-
-    String method = req.getHeader(ACCESS_CONTROL_REQUEST_METHOD);
-    if (!ALLOWED_CORS_METHODS.contains(method)) {
-      throw new BadRequestException(method + " not allowed in CORS");
-    }
-
-    String headers = req.getHeader(ACCESS_CONTROL_REQUEST_HEADERS);
-    if (headers != null) {
-      for (String reqHdr : Splitter.on(',').trimResults().split(headers)) {
-        if (!ALLOWED_CORS_REQUEST_HEADERS.contains(reqHdr.toLowerCase(Locale.US))) {
-          throw new BadRequestException(reqHdr + " not allowed in CORS");
-        }
-      }
-    }
-
-    res.setStatus(SC_OK);
-    setCorsHeaders(res, origin);
-    res.setContentType(PLAIN_TEXT);
-    res.setContentLength(0);
-  }
-
-  private static void setCorsHeaders(HttpServletResponse res, String origin) {
-    res.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN, origin);
-    res.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
-    res.setHeader(ACCESS_CONTROL_MAX_AGE, "600");
-    setHeaderList(
-        res,
-        ACCESS_CONTROL_ALLOW_METHODS,
-        Iterables.concat(ALLOWED_CORS_METHODS, ImmutableList.of("OPTIONS")));
-    setHeaderList(res, ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_REQUEST_HEADERS);
-  }
-
-  private static void setHeaderList(HttpServletResponse res, String name, Iterable<String> values) {
-    res.setHeader(name, Joiner.on(", ").join(values));
-  }
-
-  private boolean isOriginAllowed(String origin) {
-    return globals.allowOrigin != null && globals.allowOrigin.matcher(origin).matches();
-  }
-
   private static String messageOr(Throwable t, String defaultMessage) {
     if (!Strings.isNullOrEmpty(t.getMessage())) {
       return t.getMessage();
@@ -1726,7 +1611,9 @@
   private void checkUserSession(HttpServletRequest req) throws AuthException {
     CurrentUser user = globals.currentUser.get();
     if (isRead(req)) {
-      user.setAccessPath(AccessPath.REST_API);
+      if (user.getAccessPath().equals(AccessPath.UNKNOWN)) {
+        user.setAccessPath(AccessPath.REST_API);
+      }
     } else if (user instanceof AnonymousUser) {
       throw new AuthException("Authentication required");
     } else if (!globals.webSession.get().isAccessPathOk(AccessPath.REST_API)) {
@@ -1978,8 +1865,9 @@
       case CLIENT_CLOSED_REQUEST:
         return SC_CLIENT_CLOSED_REQUEST;
       case CLIENT_PROVIDED_DEADLINE_EXCEEDED:
-      case SERVER_DEADLINE_EXCEEDED:
         return SC_REQUEST_TIMEOUT;
+      case SERVER_DEADLINE_EXCEEDED:
+        return SC_INTERNAL_SERVER_ERROR;
     }
     logger.atSevere().log("Unexpected cancellation reason: %s", cancellationReason);
     return SC_INTERNAL_SERVER_ERROR;
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 3ac594e..dac1012 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -62,7 +62,10 @@
   @Deprecated static final Schema<ProjectData> V4 = schema(V3);
 
   // Upgrade Lucene to 7.x requires reindexing.
-  static final Schema<ProjectData> V5 = schema(V4);
+  @Deprecated static final Schema<ProjectData> V5 = schema(V4);
+
+  // Upgrade Lucene to 8.x requires reindexing.
+  static final Schema<ProjectData> V6 = schema(V5);
 
   /**
    * Name of the project index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index f9dc31a..78ee128 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -544,8 +544,7 @@
         int realLimit = opts.start() + opts.pageSize();
         TopFieldDocs docs =
             opts.searchAfter() != null
-                ? searcher.searchAfter(
-                    (ScoreDoc) opts.searchAfter(), query, realLimit, sort, false, false)
+                ? searcher.searchAfter((ScoreDoc) opts.searchAfter(), query, realLimit, sort, false)
                 : searcher.search(query, realLimit, sort);
         ImmutableList.Builder<T> b = ImmutableList.builderWithExpectedSize(docs.scoreDocs.length);
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index bc614d8..b94e840 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -415,12 +415,7 @@
             if (maxRemainingHits > 0) {
               TopFieldDocs subIndexHits =
                   searchers[i].searchAfter(
-                      searchAfter,
-                      query,
-                      maxRemainingHits,
-                      sort,
-                      /* doDocScores= */ false,
-                      /* doMaxScore= */ false);
+                      searchAfter, query, maxRemainingHits, sort, /* doDocScores= */ false);
               searchAfterHitsCount += subIndexHits.scoreDocs.length;
               hits.add(subIndexHits);
               searchAfterBySubIndex.put(
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 744f91b..d0b0031 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -69,6 +69,7 @@
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.AttentionSetOwnerAdder.AttentionSetOwnerAdderModule;
 import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
@@ -531,6 +532,7 @@
       modules.add(new PeriodicGroupIndexerModule());
     } else {
       modules.add(new AccountDeactivatorModule());
+      modules.add(new AttentionSetOwnerAdderModule());
       modules.add(new ChangeCleanupRunnerModule());
     }
     modules.add(new LocalMergeSuperSetComputationModule());
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index a057e66..d0d03b5 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -122,6 +122,8 @@
     extractMailExample("DeleteReviewerHtml.soy");
     extractMailExample("DeleteVote.soy");
     extractMailExample("DeleteVoteHtml.soy");
+    extractMailExample("Email.soy");
+    extractMailExample("EmailHtml.soy");
     extractMailExample("Footer.soy");
     extractMailExample("FooterHtml.soy");
     extractMailExample("ChangeHeader.soy");
diff --git a/java/com/google/gerrit/pgm/util/BUILD b/java/com/google/gerrit/pgm/util/BUILD
index f7c2b75..5b01c9c 100644
--- a/java/com/google/gerrit/pgm/util/BUILD
+++ b/java/com/google/gerrit/pgm/util/BUILD
@@ -15,6 +15,7 @@
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/cache/mem",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/rules/prolog",
         "//java/com/google/gerrit/server/schema",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/logging",
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index cae7ca6..7aebb46 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -92,8 +92,8 @@
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
-import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.rules.prolog.PrologModule;
 import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
 import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 2be3383..d68f809 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -14,6 +14,8 @@
     "account/externalids/testing/ExternalIdTestUtil.java",
 ]
 
+PROLOG_SRC = ["rules/prolog/*.java"]
+
 java_library(
     name = "constants",
     srcs = CONSTANTS_SRC,
@@ -30,7 +32,8 @@
     name = "server",
     srcs = glob(
         ["**/*.java"],
-        exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + TESTING_SRC,
+        exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + TESTING_SRC +
+                  PROLOG_SRC,
     ),
     resource_strip_prefix = "resources",
     resources = ["//resources/com/google/gerrit/server"],
@@ -151,6 +154,7 @@
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
+        "//java/com/google/gerrit/server/rules/prolog",
         "//lib:blame-cache",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/server/PerformanceMetrics.java b/java/com/google/gerrit/server/PerformanceMetrics.java
deleted file mode 100644
index 845ed80..0000000
--- a/java/com/google/gerrit/server/PerformanceMetrics.java
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.metrics.Counter3;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer3;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.PerformanceLogger;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.concurrent.TimeUnit;
-
-/** Performance logger that records the execution times as a metric. */
-@Singleton
-public class PerformanceMetrics implements PerformanceLogger {
-  private static final String OPERATION_LATENCY_METRIC_NAME = "performance/operations";
-  private static final String OPERATION_COUNT_METRIC_NAME = "performance/operations_count";
-
-  public final Timer3<String, String, String> operationsLatency;
-  public final Counter3<String, String, String> operationsCounter;
-
-  @Inject
-  PerformanceMetrics(MetricMaker metricMaker) {
-    Field<String> operationNameField =
-        Field.ofString(
-                "operation_name",
-                (metadataBuilder, fieldValue) -> metadataBuilder.operationName(fieldValue))
-            .description("The operation that was performed.")
-            .build();
-    Field<String> requestField =
-        Field.ofString("request", (metadataBuilder, fieldValue) -> {})
-            .description(
-                "The request for which the operation was performed"
-                    + " (format = '<request-type> <redacted-request-uri>').")
-            .build();
-    Field<String> pluginField =
-        Field.ofString(
-                "plugin", (metadataBuilder, fieldValue) -> metadataBuilder.pluginName(fieldValue))
-            .description("The name of the plugin that performed the operation.")
-            .build();
-
-    this.operationsLatency =
-        metricMaker
-            .newTimer(
-                OPERATION_LATENCY_METRIC_NAME,
-                new Description("Latency of performing operations")
-                    .setCumulative()
-                    .setUnit(Description.Units.MILLISECONDS),
-                operationNameField,
-                requestField,
-                pluginField)
-            .suppressLogging();
-    this.operationsCounter =
-        metricMaker.newCounter(
-            OPERATION_COUNT_METRIC_NAME,
-            new Description("Number of performed operations").setRate(),
-            operationNameField,
-            requestField,
-            pluginField);
-  }
-
-  @Override
-  public void log(String operation, long durationMs) {
-    log(operation, durationMs, /* metadata= */ null);
-  }
-
-  @Override
-  public void log(String operation, long durationMs, @Nullable Metadata metadata) {
-    String requestTag = TraceContext.getTag(TraceRequestListener.TAG_REQUEST).orElse("");
-    String pluginTag = TraceContext.getPluginTag().orElse("");
-    operationsLatency.record(operation, requestTag, pluginTag, durationMs, TimeUnit.MILLISECONDS);
-    operationsCounter.increment(operation, requestTag, pluginTag);
-  }
-}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 2d18054..cf04029 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -49,10 +49,12 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.List;
 import java.util.NavigableSet;
 import java.util.Objects;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.NullProgressMonitor;
@@ -217,6 +219,29 @@
   }
 
   /**
+   * Returns a subset of change IDs among the input {@code changeIds} list that are starred by the
+   * {@code caller} user.
+   */
+  public Set<Change.Id> areStarred(
+      Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller) {
+    List<String> starRefs =
+        changeIds.stream()
+            .map(c -> RefNames.refsStarredChanges(c, caller))
+            .collect(Collectors.toList());
+    try {
+      return allUsersRepo.getRefDatabase().exactRef(starRefs.toArray(new String[0])).keySet()
+          .stream()
+          .map(r -> Change.Id.fromAllUsersRef(r))
+          .collect(Collectors.toSet());
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log(
+          "Failed getting starred changes for account %d within changes: %s",
+          caller.get(), Joiner.on(", ").join(changeIds));
+      return ImmutableSet.of();
+    }
+  }
+
+  /**
    * Unstar the given change for all users.
    *
    * <p>Intended for use only when we're about to delete a change. For that reason, the change is
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 58396f5..4676be3 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -87,18 +87,20 @@
    * @param commitMessage the commit message of the commit.
    * @param branchName branch of the commit.
    * @param changeKey change Identifier for this change
+   * @param changeId the numeric changeID for this change
    */
   public ImmutableList<WebLinkInfo> getPatchSetLinks(
       Project.NameKey project,
       String commit,
       String commitMessage,
       String branchName,
-      String changeKey) {
+      String changeKey,
+      int changeId) {
     return filterLinks(
         patchSetLinks,
         webLink ->
             webLink.getPatchSetWebLink(
-                project.get(), commit, commitMessage, branchName, changeKey));
+                project.get(), commit, commitMessage, branchName, changeKey, changeId));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 66a36f6..9fec1fa 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -122,13 +122,6 @@
   public Map<Account.Id, AccountState> get(Set<Account.Id> accountIds) {
     try {
       try (Repository allUsers = repoManager.openRepository(allUsersName)) {
-        // Get the default preferences for this Gerrit host
-        Ref ref = allUsers.exactRef(RefNames.REFS_USERS_DEFAULT);
-        CachedPreferences defaultPreferences =
-            ref != null
-                ? defaultPreferenceCache.get(ref.getObjectId())
-                : DefaultPreferencesCache.EMPTY;
-
         Set<CachedAccountDetails.Key> keys =
             Sets.newLinkedHashSetWithExpectedSize(accountIds.size());
         for (Account.Id id : accountIds) {
@@ -138,6 +131,7 @@
           }
           keys.add(CachedAccountDetails.Key.create(id, userRef.getObjectId()));
         }
+        CachedPreferences defaultPreferences = defaultPreferenceCache.get();
         ImmutableMap.Builder<Account.Id, AccountState> result = ImmutableMap.builder();
         for (Map.Entry<CachedAccountDetails.Key, CachedAccountDetails> account :
             accountDetailsCache.getAll(keys).entrySet()) {
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 4fba660..9ad1744 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
@@ -27,6 +28,7 @@
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.ChangeMessageApi;
 import com.google.gerrit.extensions.api.changes.Changes;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
@@ -80,6 +82,7 @@
 import com.google.gerrit.server.restapi.change.DeleteChange;
 import com.google.gerrit.server.restapi.change.DeletePrivate;
 import com.google.gerrit.server.restapi.change.GetChange;
+import com.google.gerrit.server.restapi.change.GetCustomKeyedValues;
 import com.google.gerrit.server.restapi.change.GetHashtags;
 import com.google.gerrit.server.restapi.change.GetMetaDiff;
 import com.google.gerrit.server.restapi.change.GetPureRevert;
@@ -90,6 +93,7 @@
 import com.google.gerrit.server.restapi.change.ListChangeRobotComments;
 import com.google.gerrit.server.restapi.change.ListReviewers;
 import com.google.gerrit.server.restapi.change.Move;
+import com.google.gerrit.server.restapi.change.PostCustomKeyedValues;
 import com.google.gerrit.server.restapi.change.PostHashtags;
 import com.google.gerrit.server.restapi.change.PostPrivate;
 import com.google.gerrit.server.restapi.change.PostReviewers;
@@ -151,6 +155,8 @@
   private final Provider<GetMetaDiff> getMetaDiffProvider;
   private final PostHashtags postHashtags;
   private final GetHashtags getHashtags;
+  private final PostCustomKeyedValues postCustomKeyedValues;
+  private final GetCustomKeyedValues getCustomKeyedValues;
   private final AttentionSet attentionSet;
   private final AttentionSetApiImpl.Factory attentionSetApi;
   private final AddToAttentionSet addToAttentionSet;
@@ -201,6 +207,8 @@
       Provider<GetMetaDiff> getMetaDiffProvider,
       PostHashtags postHashtags,
       GetHashtags getHashtags,
+      PostCustomKeyedValues postCustomKeyedValues,
+      GetCustomKeyedValues getCustomKeyedValues,
       AttentionSet attentionSet,
       AttentionSetApiImpl.Factory attentionSetApi,
       AddToAttentionSet addToAttentionSet,
@@ -249,6 +257,8 @@
     this.getMetaDiffProvider = getMetaDiffProvider;
     this.postHashtags = postHashtags;
     this.getHashtags = getHashtags;
+    this.postCustomKeyedValues = postCustomKeyedValues;
+    this.getCustomKeyedValues = getCustomKeyedValues;
     this.attentionSet = attentionSet;
     this.attentionSetApi = attentionSetApi;
     this.addToAttentionSet = addToAttentionSet;
@@ -568,6 +578,24 @@
   }
 
   @Override
+  public void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException {
+    try {
+      var unused = postCustomKeyedValues.apply(change, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot post custom keyed values", e);
+    }
+  }
+
+  @Override
+  public ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException {
+    try {
+      return getCustomKeyedValues.apply(change).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get custom keyed values", e);
+    }
+  }
+
+  @Override
   public AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException {
     try {
       return addToAttentionSet.apply(change, input).value();
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index 10e1f92..621994d 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -25,9 +25,10 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
-import com.google.gerrit.server.mail.send.AbandonedSender;
+import com.google.gerrit.server.mail.EmailModule.AbandonedChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -38,7 +39,7 @@
 public class AbandonOp implements BatchUpdateOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final AbandonedSender.Factory abandonedSenderFactory;
+  private final AbandonedChangeEmailFactories abandonedChangeEmailFactories;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
   private final ChangeAbandoned changeAbandoned;
@@ -58,14 +59,14 @@
 
   @Inject
   AbandonOp(
-      AbandonedSender.Factory abandonedSenderFactory,
+      AbandonedChangeEmailFactories abandonedChangeEmailFactories,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
       ChangeAbandoned changeAbandoned,
       MessageIdGenerator messageIdGenerator,
       @Assisted @Nullable AccountState accountState,
       @Assisted @Nullable String msgTxt) {
-    this.abandonedSenderFactory = abandonedSenderFactory;
+    this.abandonedChangeEmailFactories = abandonedChangeEmailFactories;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
     this.changeAbandoned = changeAbandoned;
@@ -111,16 +112,16 @@
   public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
-      ReplyToChangeSender emailSender =
-          abandonedSenderFactory.create(ctx.getProject(), change.getId());
+      ChangeEmail changeEmail =
+          abandonedChangeEmailFactories.createChangeEmail(ctx.getProject(), change.getId());
+      changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+      OutgoingEmail email = abandonedChangeEmailFactories.createEmail(changeEmail);
       if (accountState != null) {
-        emailSender.setFrom(accountState.account().id());
+        email.setFrom(accountState.account().id());
       }
-      emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-      emailSender.send();
+      email.setNotify(notify);
+      email.setMessageId(messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
+      email.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index e5a9534..00673d5 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -123,6 +123,10 @@
         changeInfo.removedFromAttentionSet == null
             ? null
             : ImmutableMap.copyOf(changeInfo.removedFromAttentionSet);
+    copy.customKeyedValues =
+        changeInfo.customKeyedValues == null
+            ? null
+            : ImmutableMap.copyOf(changeInfo.customKeyedValues);
     copy.hashtags = changeInfo.hashtags;
     copy.changeId = changeInfo.changeId;
     copy.submitType = changeInfo.submitType;
@@ -147,6 +151,7 @@
     copy.unresolvedCommentCount = changeInfo.unresolvedCommentCount;
     copy.workInProgress = changeInfo.workInProgress;
     copy.id = changeInfo.id;
+    copy.tripletId = changeInfo.tripletId;
     copy.cherryPickOfChange = changeInfo.cherryPickOfChange;
     copy.cherryPickOfPatchSet = changeInfo.cherryPickOfPatchSet;
     return copy;
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index ec90bec..f0a70bb 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange.USER_ADDED;
 import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -37,7 +37,6 @@
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final AddToAttentionSetSender.Factory addToAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
   private final Account.Id attentionUserId;
@@ -56,15 +55,12 @@
   @Inject
   AddToAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      AddToAttentionSetSender.Factory addToAttentionSetSender,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.addToAttentionSetSender = addToAttentionSetSender;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
-
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
     this.notify = notify;
@@ -97,13 +93,6 @@
     if (!notify) {
       return;
     }
-    attentionSetEmailFactory
-        .create(
-            addToAttentionSetSender.create(ctx.getProject(), change.getId()),
-            ctx,
-            change,
-            reason,
-            attentionUserId)
-        .sendAsync();
+    attentionSetEmailFactory.create(USER_ADDED, ctx, change, reason, attentionUserId).sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/AttentionSetOwnerAdder.java b/java/com/google/gerrit/server/change/AttentionSetOwnerAdder.java
new file mode 100644
index 0000000..2e2769b
--- /dev/null
+++ b/java/com/google/gerrit/server/change/AttentionSetOwnerAdder.java
@@ -0,0 +1,87 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.AttentionSetConfig;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.inject.Inject;
+
+/** Runnable to enable scheduling change cleanups to run periodically */
+public class AttentionSetOwnerAdder implements Runnable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static class AttentionSetOwnerAdderModule extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(Lifecycle.class);
+    }
+  }
+
+  static class Lifecycle implements LifecycleListener {
+    private final WorkQueue queue;
+    private final AttentionSetOwnerAdder runner;
+    private final AttentionSetConfig cfg;
+
+    @Inject
+    Lifecycle(WorkQueue queue, AttentionSetOwnerAdder runner, AttentionSetConfig cfg) {
+      this.queue = queue;
+      this.runner = runner;
+      this.cfg = cfg;
+    }
+
+    @Override
+    public void start() {
+      cfg.getSchedule().ifPresent(s -> queue.scheduleAtFixedRate(runner, s));
+    }
+
+    @Override
+    public void stop() {
+      // handled by WorkQueue.stop() already
+    }
+  }
+
+  private final OneOffRequestContext oneOffRequestContext;
+  private final BatchUpdate.Factory updateFactory;
+  private final ReaddOwnerUtil readdOwnerUtil;
+
+  @Inject
+  AttentionSetOwnerAdder(
+      OneOffRequestContext oneOffRequestContext,
+      BatchUpdate.Factory updateFactory,
+      ReaddOwnerUtil readdOwnerUtil) {
+    this.oneOffRequestContext = oneOffRequestContext;
+    this.updateFactory = updateFactory;
+    this.readdOwnerUtil = readdOwnerUtil;
+  }
+
+  @Override
+  public void run() {
+    logger.atInfo().log("Running attention-set owner adder.");
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      readdOwnerUtil.readdOwnerForInactiveOpenChanges(updateFactory);
+    }
+  }
+
+  @Override
+  public String toString() {
+    return "attention-set adder";
+  }
+}
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 8773bb7..83b7565 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -19,6 +19,7 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Change.INITIAL_PATCH_SET_ID;
 import static com.google.gerrit.server.change.ReviewerModifier.newReviewerInputFromCommitIdentity;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUES;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
@@ -26,6 +27,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
@@ -63,8 +65,11 @@
 import com.google.gerrit.server.git.GroupCollector;
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.mail.send.CreateChangeSender;
+import com.google.gerrit.server.mail.EmailModule.StartReviewChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
@@ -110,7 +115,7 @@
   private final PatchSetUtil psUtil;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final CreateChangeSender.Factory createChangeSenderFactory;
+  private final StartReviewChangeEmailFactories startReviewChangeEmailFactories;
   private final ExecutorService sendEmailExecutor;
   private final CommitValidators.Factory commitValidatorsFactory;
   private final RevisionCreated revisionCreated;
@@ -135,6 +140,7 @@
   private boolean workInProgress;
   private List<String> groups = Collections.emptyList();
   private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
+  private ImmutableMap<String, String> customKeyedValues = ImmutableMap.of();
   private boolean validate = true;
   private Map<String, Short> approvals;
   private RequestScopePropagator requestScopePropagator;
@@ -162,7 +168,7 @@
       PatchSetUtil psUtil,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
-      CreateChangeSender.Factory createChangeSenderFactory,
+      StartReviewChangeEmailFactories startReviewChangeEmailFactories,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       CommitValidators.Factory commitValidatorsFactory,
       CommentAdded commentAdded,
@@ -180,7 +186,7 @@
     this.psUtil = psUtil;
     this.approvalsUtil = approvalsUtil;
     this.cmUtil = cmUtil;
-    this.createChangeSenderFactory = createChangeSenderFactory;
+    this.startReviewChangeEmailFactories = startReviewChangeEmailFactories;
     this.sendEmailExecutor = sendEmailExecutor;
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.revisionCreated = revisionCreated;
@@ -217,6 +223,7 @@
     change.setWorkInProgress(workInProgress);
     change.setReviewStarted(!workInProgress);
     change.setRevertOf(revertOf);
+    change.setCustomKeyedValues(customKeyedValues);
     return change;
   }
 
@@ -343,6 +350,13 @@
   }
 
   @CanIgnoreReturnValue
+  public ChangeInserter setCustomKeyedValues(ImmutableMap<String, String> customKeyedValues) {
+    requireNonNull(customKeyedValues, "customKeyedValues may not be null");
+    this.customKeyedValues = customKeyedValues;
+    return this;
+  }
+
+  @CanIgnoreReturnValue
   public ChangeInserter setValidationOptions(
       ImmutableListMultimap<String, String> validationOptions) {
     requireNonNull(validationOptions, "validationOptions may not be null");
@@ -461,6 +475,18 @@
     } catch (ValidationException ex) {
       throw new BadRequestException(ex.getMessage());
     }
+    if (change.getCustomKeyedValues() != null) {
+      try {
+        if (change.getCustomKeyedValues().entrySet().size() > MAX_CUSTOM_KEYED_VALUES) {
+          throw new ValidationException("Too many custom keyed values");
+        }
+        for (Map.Entry<String, String> entry : change.getCustomKeyedValues().entrySet()) {
+          update.addCustomKeyedValue(entry.getKey(), entry.getValue());
+        }
+      } catch (ValidationException ex) {
+        throw new BadRequestException(ex.getMessage());
+      }
+    }
     update.setPsDescription(patchSetDescription);
     update.setPrivate(isPrivate);
     update.setWorkInProgress(workInProgress);
@@ -531,24 +557,30 @@
             @Override
             public void run() {
               try {
-                CreateChangeSender emailSender =
-                    createChangeSenderFactory.create(change.getProject(), change.getId());
-                emailSender.setFrom(change.getOwner());
-                emailSender.setPatchSet(patchSet, patchSetInfo);
-                emailSender.setNotify(notify);
-                emailSender.addReviewers(
+                StartReviewChangeEmailDecorator startReviewEmail =
+                    startReviewChangeEmailFactories.createStartReviewChangeEmail();
+                startReviewEmail.markAsCreateChange();
+                startReviewEmail.addReviewers(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
                         .map(PatchSetApproval::accountId)
                         .collect(toImmutableSet()));
-                emailSender.addReviewersByEmail(
+                startReviewEmail.addReviewersByEmail(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewersByEmail));
-                emailSender.addExtraCC(
+                startReviewEmail.addExtraCC(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCs));
-                emailSender.addExtraCCByEmail(
+                startReviewEmail.addExtraCCByEmail(
                     reviewerAdditions.flattenResults(ReviewerOp.Result::addedCCsByEmail));
-                emailSender.setMessageId(
+                ChangeEmail changeEmail =
+                    startReviewChangeEmailFactories.createChangeEmail(
+                        change.getProject(), change.getId(), startReviewEmail);
+                changeEmail.setPatchSet(patchSet, patchSetInfo);
+                OutgoingEmail outgoingEmail =
+                    startReviewChangeEmailFactories.createEmail(changeEmail);
+                outgoingEmail.setFrom(change.getOwner());
+                outgoingEmail.setNotify(notify);
+                outgoingEmail.setMessageId(
                     messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-                emailSender.send();
+                outgoingEmail.send();
               } catch (Exception e) {
                 logger.atSevere().withCause(e).log(
                     "Cannot send email for new change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index f733a7b..e6fc4e7 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -23,6 +23,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.extensions.client.ListChangesOption.CUSTOM_KEYED_VALUES;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
@@ -30,6 +31,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_DIFFSTAT;
+import static com.google.gerrit.extensions.client.ListChangesOption.STAR;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMIT_REQUIREMENTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
@@ -99,8 +101,12 @@
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -130,6 +136,7 @@
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 
 /**
  * Produces {@link ChangeInfo} (which is serialized to JSON afterwards) from {@link ChangeData}.
@@ -219,12 +226,15 @@
     }
   }
 
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
   private final Provider<CurrentUser> userProvider;
   private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
   private final AccountLoader.Factory accountLoaderFactory;
   private final ImmutableSet<ListChangesOption> options;
   private final ChangeMessagesUtil cmUtil;
+  private final StarredChangesUtil starredChangesUtil;
   private final Provider<ConsistencyChecker> checkerProvider;
   private final ActionJson actionJson;
   private final ChangeNotes.Factory notesFactory;
@@ -240,14 +250,19 @@
 
   private AccountLoader accountLoader;
   private FixInput fix;
+  private ExperimentFeatures experimentFeatures;
 
   @Inject
   ChangeJson(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      ExperimentFeatures experimentFeatures,
       Provider<CurrentUser> user,
       PermissionBackend permissionBackend,
       ChangeData.Factory cdf,
       AccountLoader.Factory ailf,
       ChangeMessagesUtil cmUtil,
+      StarredChangesUtil starredChangesUtil,
       Provider<ConsistencyChecker> checkerProvider,
       ActionJson actionJson,
       ChangeNotes.Factory notesFactory,
@@ -259,11 +274,15 @@
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.experimentFeatures = experimentFeatures;
     this.userProvider = user;
     this.changeDataFactory = cdf;
     this.permissionBackend = permissionBackend;
     this.accountLoaderFactory = ailf;
     this.cmUtil = cmUtil;
+    this.starredChangesUtil = starredChangesUtil;
     this.checkerProvider = checkerProvider;
     this.actionJson = actionJson;
     this.notesFactory = notesFactory;
@@ -422,10 +441,18 @@
     return info;
   }
 
-  private static void finish(ChangeInfo info) {
-    info.id =
+  private static void finish(ChangeInfo info, ExperimentFeatures experimentFeatures) {
+    info.tripletId =
         Joiner.on('~')
             .join(Url.encode(info.project), Url.encode(info.branch), Url.encode(info.changeId));
+    if (experimentFeatures.isFeatureEnabled(
+        ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_RETURN_NEW_CHANGE_INFO_ID,
+        Project.nameKey(info.project))) {
+      info.id =
+          Joiner.on('~').join(Url.encode(info.project), Url.encode(String.valueOf(info._number)));
+    } else {
+      info.id = info.tripletId;
+    }
   }
 
   private static boolean containsAnyOf(
@@ -526,6 +553,9 @@
               "Omitting corrupt change %s from results", cd.getId());
         }
       }
+      if (has(STAR)) {
+        populateStarField(changeInfos);
+      }
       return changeInfos;
     }
   }
@@ -563,7 +593,7 @@
       info.isPrivate = c.isPrivate() ? true : null;
       info.workInProgress = c.isWorkInProgress() ? true : null;
       info.hasReviewStarted = c.hasReviewStarted();
-      finish(info);
+      finish(info, experimentFeatures);
     } else {
       info._number = result.id().get();
       info.problems = result.problems();
@@ -617,6 +647,9 @@
                       a -> a.account().get(),
                       a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
     }
+    if (has(CUSTOM_KEYED_VALUES)) {
+      out.customKeyedValues = cd.customKeyedValues();
+    }
     out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
     if (in.isNew()) {
@@ -727,7 +760,7 @@
     if (needMessages) {
       out.messages = messages(cd);
     }
-    finish(out);
+    finish(out, experimentFeatures);
 
     // This block must come after the ChangeInfo is mostly populated, since
     // it will be passed to ActionVisitors as-is.
@@ -940,6 +973,25 @@
     return map.build();
   }
 
+  /** Populate the 'starred' field. */
+  private void populateStarField(List<ChangeInfo> changeInfos) {
+    // We populate the 'starred' field for all change infos together so that we open the All-Users
+    // repository only once
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      List<Change.Id> changeIds =
+          changeInfos.stream().map(c -> Change.id(c._number)).collect(Collectors.toList());
+      Set<Change.Id> starredChanges =
+          starredChangesUtil.areStarred(
+              allUsersRepo, changeIds, userProvider.get().asIdentifiedUser().getAccountId());
+      if (starredChanges.isEmpty()) {
+        return;
+      }
+      changeInfos.stream().forEach(c -> c.starred = starredChanges.contains(Change.id(c._number)));
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Failed to open All-Users repo.");
+    }
+  }
+
   private List<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
     return getPluginInfos(Collections.singleton(cd)).get(cd.getId());
   }
diff --git a/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java b/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java
new file mode 100644
index 0000000..04bc6e4
--- /dev/null
+++ b/java/com/google/gerrit/server/change/CustomKeyedValuesUtil.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.util.Map;
+
+public class CustomKeyedValuesUtil {
+  public static class InvalidCustomKeyedValueException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    static InvalidCustomKeyedValueException customKeyedValuesMayNotContainEquals() {
+      return new InvalidCustomKeyedValueException("custom keys may not contain equals sign");
+    }
+
+    static InvalidCustomKeyedValueException customKeyedValuesMayNotContainNewLine() {
+      return new InvalidCustomKeyedValueException("custom values may not contain newline");
+    }
+
+    InvalidCustomKeyedValueException(String message) {
+      super(message);
+    }
+  }
+
+  static ImmutableMap<String, String> extractCustomKeyedValues(ImmutableMap<String, String> input)
+      throws InvalidCustomKeyedValueException {
+    if (input == null) {
+      return ImmutableMap.of();
+    }
+    ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
+    for (Map.Entry<String, String> customKeyedValue : input.entrySet()) {
+      if (customKeyedValue.getKey().contains("=")) {
+        throw InvalidCustomKeyedValueException.customKeyedValuesMayNotContainEquals();
+      }
+      if (customKeyedValue.getValue().contains("\n")) {
+        throw InvalidCustomKeyedValueException.customKeyedValuesMayNotContainNewLine();
+      }
+      String key = customKeyedValue.getKey().trim();
+      if (key.isEmpty()) {
+        continue;
+      }
+      builder.put(key, customKeyedValue.getValue());
+    }
+    return builder.build();
+  }
+
+  static ImmutableSet<String> extractCustomKeys(ImmutableSet<String> input)
+      throws InvalidCustomKeyedValueException {
+    if (input == null) {
+      return ImmutableSet.of();
+    }
+    ImmutableSet.Builder<String> builder = ImmutableSet.builder();
+    for (String customKey : input) {
+      if (customKey.contains("=")) {
+        throw InvalidCustomKeyedValueException.customKeyedValuesMayNotContainEquals();
+      }
+      String key = customKey.trim();
+      if (key.isEmpty()) {
+        continue;
+      }
+      builder.add(key);
+    }
+    return builder.build();
+  }
+
+  private CustomKeyedValuesUtil() {}
+}
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index f3fd68e..294049f 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -19,8 +19,11 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.EmailModule.DeleteReviewerChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.PostUpdateContext;
@@ -35,7 +38,7 @@
     DeleteReviewerByEmailOp create(Address reviewer);
   }
 
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final DeleteReviewerChangeEmailFactories deleteReviewerChangeEmailFactories;
   private final MessageIdGenerator messageIdGenerator;
   private final ChangeMessagesUtil changeMessagesUtil;
 
@@ -45,11 +48,11 @@
 
   @Inject
   DeleteReviewerByEmailOp(
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      DeleteReviewerChangeEmailFactories deleteReviewerChangeEmailFactories,
       MessageIdGenerator messageIdGenerator,
       ChangeMessagesUtil changeMessagesUtil,
       @Assisted Address reviewer) {
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.deleteReviewerChangeEmailFactories = deleteReviewerChangeEmailFactories;
     this.messageIdGenerator = messageIdGenerator;
     this.changeMessagesUtil = changeMessagesUtil;
     this.reviewer = reviewer;
@@ -76,15 +79,19 @@
     if (sendEmail) {
       try {
         NotifyResolver.Result notify = ctx.getNotify(change.getId());
-        DeleteReviewerSender emailSender =
-            deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.addReviewersByEmail(Collections.singleton(reviewer));
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setNotify(notify);
-        emailSender.setMessageId(
+        DeleteReviewerChangeEmailDecorator deleteReviewerEmail =
+            deleteReviewerChangeEmailFactories.createDeleteReviewerChangeEmail();
+        deleteReviewerEmail.addReviewersByEmail(Collections.singleton(reviewer));
+        ChangeEmail changeEmail =
+            deleteReviewerChangeEmailFactories.createChangeEmail(
+                ctx.getProject(), change.getId(), deleteReviewerEmail);
+        changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+        OutgoingEmail outgoingEmail = deleteReviewerChangeEmailFactories.createEmail(changeEmail);
+        outgoingEmail.setFrom(ctx.getAccountId());
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-        emailSender.send();
+        outgoingEmail.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
       }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index fc07592..f961d61 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -36,8 +36,11 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.mail.EmailModule.DeleteReviewerChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -68,7 +71,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final ReviewerDeleted reviewerDeleted;
   private final Provider<IdentifiedUser> user;
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final DeleteReviewerChangeEmailFactories deleteReviewerChangeEmailFactories;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
   private final MessageIdGenerator messageIdGenerator;
@@ -89,7 +92,7 @@
       ChangeMessagesUtil cmUtil,
       ReviewerDeleted reviewerDeleted,
       Provider<IdentifiedUser> user,
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      DeleteReviewerChangeEmailFactories deleteReviewerChangeEmailFactories,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
       MessageIdGenerator messageIdGenerator,
@@ -101,7 +104,7 @@
     this.cmUtil = cmUtil;
     this.reviewerDeleted = reviewerDeleted;
     this.user = user;
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.deleteReviewerChangeEmailFactories = deleteReviewerChangeEmailFactories;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
@@ -250,14 +253,18 @@
       // The user knows they removed themselves, don't bother emailing them.
       return;
     }
-    DeleteReviewerSender emailSender =
-        deleteReviewerSenderFactory.create(projectName, change.getId());
-    emailSender.setFrom(userId);
-    emailSender.addReviewers(Collections.singleton(reviewer.id()));
-    emailSender.setChangeMessage(mailMessage, timestamp.toInstant());
-    emailSender.setNotify(notify);
-    emailSender.setMessageId(
+    DeleteReviewerChangeEmailDecorator deleteReviewerEmail =
+        deleteReviewerChangeEmailFactories.createDeleteReviewerChangeEmail();
+    deleteReviewerEmail.addReviewers(Collections.singleton(reviewer.id()));
+    ChangeEmail changeEmail =
+        deleteReviewerChangeEmailFactories.createChangeEmail(
+            projectName, change.getId(), deleteReviewerEmail);
+    changeEmail.setChangeMessage(mailMessage, timestamp.toInstant());
+    OutgoingEmail outgoingEmail = deleteReviewerChangeEmailFactories.createEmail(changeEmail);
+    outgoingEmail.setFrom(userId);
+    outgoingEmail.setNotify(notify);
+    outgoingEmail.setMessageId(
         messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
-    emailSender.send();
+    outgoingEmail.send();
   }
 }
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
index f67ce4a..f695a56 100644
--- a/java/com/google/gerrit/server/change/EmailNewPatchSet.java
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -28,9 +28,12 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.EmailModule.ReplacePatchSetChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecorator;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.RequestContext;
@@ -71,7 +74,7 @@
   EmailNewPatchSet(
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ThreadLocalRequestContext threadLocalRequestContext,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      ReplacePatchSetChangeEmailFactories replacePatchSetChangeEmailFactories,
       PatchSetInfoFactory patchSetInfoFactory,
       MessageIdGenerator messageIdGenerator,
       @Assisted PostUpdateContext postUpdateContext,
@@ -107,7 +110,7 @@
     this.asyncSender =
         new AsyncSender(
             postUpdateContext.getIdentifiedUser(),
-            replacePatchSetFactory,
+            replacePatchSetChangeEmailFactories,
             patchSetInfoFactory,
             messageId,
             postUpdateContext.getNotify(changeId),
@@ -153,7 +156,7 @@
    */
   private static class AsyncSender implements Runnable, RequestContext {
     private final IdentifiedUser user;
-    private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+    private final ReplacePatchSetChangeEmailFactories replacePatchSetChangeEmailFactories;
     private final PatchSetInfoFactory patchSetInfoFactory;
     private final MessageId messageId;
     private final NotifyResolver.Result notify;
@@ -172,7 +175,7 @@
 
     AsyncSender(
         IdentifiedUser user,
-        ReplacePatchSetSender.Factory replacePatchSetFactory,
+        ReplacePatchSetChangeEmailFactories replacePatchSetChangeEmailFactories,
         PatchSetInfoFactory patchSetInfoFactory,
         MessageId messageId,
         NotifyResolver.Result notify,
@@ -188,7 +191,7 @@
         ObjectId preUpdateMetaId,
         Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
       this.user = user;
-      this.replacePatchSetFactory = replacePatchSetFactory;
+      this.replacePatchSetChangeEmailFactories = replacePatchSetChangeEmailFactories;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.messageId = messageId;
       this.notify = notify;
@@ -208,22 +211,26 @@
     @Override
     public void run() {
       try {
-        ReplacePatchSetSender emailSender =
-            replacePatchSetFactory.create(
+        ReplacePatchSetChangeEmailDecorator replacePatchSetEmail =
+            replacePatchSetChangeEmailFactories.createReplacePatchSetChangeEmail(
                 projectName,
                 changeId,
                 changeKind,
                 preUpdateMetaId,
                 postUpdateSubmitRequirementResults);
-        emailSender.setFrom(user.getAccountId());
-        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
-        emailSender.setChangeMessage(message, timestamp);
-        emailSender.setNotify(notify);
-        emailSender.addReviewers(reviewers);
-        emailSender.addExtraCC(extraCcs);
-        emailSender.addOutdatedApproval(outdatedApprovals);
-        emailSender.setMessageId(messageId);
-        emailSender.send();
+        replacePatchSetEmail.addReviewers(reviewers);
+        replacePatchSetEmail.addExtraCC(extraCcs);
+        replacePatchSetEmail.addOutdatedApproval(outdatedApprovals);
+        ChangeEmail changeEmail =
+            replacePatchSetChangeEmailFactories.createChangeEmail(
+                projectName, changeId, replacePatchSetEmail);
+        changeEmail.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        changeEmail.setChangeMessage(message, timestamp);
+        OutgoingEmail outgoingEmail = replacePatchSetChangeEmailFactories.createEmail(changeEmail);
+        outgoingEmail.setFrom(user.getAccountId());
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(messageId);
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot send email for new patch set %s", patchSet.id());
       }
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index a9886c7..67e09b0 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -28,9 +28,12 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
-import com.google.gerrit.server.mail.send.CommentSender;
+import com.google.gerrit.server.mail.EmailModule.CommentChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.CommentChangeEmailDecorator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.LabelVote;
@@ -84,7 +87,7 @@
   EmailReviewComments(
       @SendEmailExecutor ExecutorService executor,
       PatchSetInfoFactory patchSetInfoFactory,
-      CommentSender.Factory commentSenderFactory,
+      CommentChangeEmailFactories commentChangeEmailFactories,
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
       @Assisted PostUpdateContext postUpdateContext,
@@ -118,7 +121,7 @@
     this.asyncSender =
         new AsyncSender(
             requestContext,
-            commentSenderFactory,
+            commentChangeEmailFactories,
             patchSetInfoFactory,
             postUpdateContext.getUser().asIdentifiedUser(),
             messageId,
@@ -149,7 +152,7 @@
   // TODO: The passed in Comment class is not thread-safe, replace it with an AutoValue type.
   private static class AsyncSender implements Runnable, RequestContext {
     private final ThreadLocalRequestContext requestContext;
-    private final CommentSender.Factory commentSenderFactory;
+    private final CommentChangeEmailFactories commentChangeEmailFactories;
     private final PatchSetInfoFactory patchSetInfoFactory;
     private final IdentifiedUser user;
     private final MessageId messageId;
@@ -168,7 +171,7 @@
 
     AsyncSender(
         ThreadLocalRequestContext requestContext,
-        CommentSender.Factory commentSenderFactory,
+        CommentChangeEmailFactories commentChangeEmailFactories,
         PatchSetInfoFactory patchSetInfoFactory,
         IdentifiedUser user,
         MessageId messageId,
@@ -184,7 +187,7 @@
         ImmutableList<LabelVote> labels,
         Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
       this.requestContext = requestContext;
-      this.commentSenderFactory = commentSenderFactory;
+      this.commentChangeEmailFactories = commentChangeEmailFactories;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.user = user;
       this.messageId = messageId;
@@ -205,18 +208,22 @@
     public void run() {
       RequestContext old = requestContext.setContext(this);
       try {
-        CommentSender emailSender =
-            commentSenderFactory.create(
+        CommentChangeEmailDecorator commentChangeEmail =
+            commentChangeEmailFactories.createCommentChangeEmail(
                 projectName, changeId, preUpdateMetaId, postUpdateSubmitRequirementResults);
-        emailSender.setFrom(user.getAccountId());
-        emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
-        emailSender.setChangeMessage(message, timestamp);
-        emailSender.setComments(comments);
-        emailSender.setPatchSetComment(patchSetComment);
-        emailSender.setLabels(labels);
-        emailSender.setNotify(notify);
-        emailSender.setMessageId(messageId);
-        emailSender.send();
+        commentChangeEmail.setComments(comments);
+        commentChangeEmail.setPatchSetComment(patchSetComment);
+        commentChangeEmail.setLabels(labels);
+        ChangeEmail changeEmail =
+            commentChangeEmailFactories.createChangeEmail(
+                projectName, changeId, commentChangeEmail);
+        changeEmail.setPatchSet(patchSet, patchSetInfoFactory.get(projectName, patchSet));
+        changeEmail.setChangeMessage(message, timestamp);
+        OutgoingEmail outgoingEmail = commentChangeEmailFactories.createEmail(changeEmail);
+        outgoingEmail.setFrom(user.getAccountId());
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(messageId);
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
       } finally {
diff --git a/java/com/google/gerrit/server/change/ModifyReviewersEmail.java b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
index cb747f6..5f2a5fd 100644
--- a/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
@@ -24,8 +24,11 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.EmailModule.StartReviewChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ModifyReviewerSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -36,16 +39,16 @@
 public class ModifyReviewersEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final ModifyReviewerSender.Factory addReviewerSenderFactory;
+  private final StartReviewChangeEmailFactories startReviewChangeEmailFactories;
   private final ExecutorService sendEmailsExecutor;
   private final MessageIdGenerator messageIdGenerator;
 
   @Inject
   ModifyReviewersEmail(
-      ModifyReviewerSender.Factory addReviewerSenderFactory,
+      StartReviewChangeEmailFactories startReviewChangeEmailFactories,
       @SendEmailExecutor ExecutorService sendEmailsExecutor,
       MessageIdGenerator messageIdGenerator) {
-    this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.startReviewChangeEmailFactories = startReviewChangeEmailFactories;
     this.sendEmailsExecutor = sendEmailsExecutor;
     this.messageIdGenerator = messageIdGenerator;
   }
@@ -90,20 +93,25 @@
         sendEmailsExecutor.submit(
             () -> {
               try {
-                ModifyReviewerSender emailSender =
-                    addReviewerSenderFactory.create(projectNameKey, cId);
-                emailSender.setNotify(notify);
-                emailSender.setFrom(userId);
-                emailSender.addReviewers(immutableToMail);
-                emailSender.addReviewersByEmail(immutableAddedByEmail);
-                emailSender.addExtraCC(immutableToCopy);
-                emailSender.addExtraCCByEmail(immutableCopiedByEmail);
-                emailSender.addRemovedReviewers(immutableToRemove);
-                emailSender.addRemovedByEmailReviewers(immutableRemovedByEmail);
-                emailSender.setMessageId(
+                StartReviewChangeEmailDecorator startReviewEmail =
+                    startReviewChangeEmailFactories.createStartReviewChangeEmail();
+                startReviewEmail.addReviewers(immutableToMail);
+                startReviewEmail.addReviewersByEmail(immutableAddedByEmail);
+                startReviewEmail.addExtraCC(immutableToCopy);
+                startReviewEmail.addExtraCCByEmail(immutableCopiedByEmail);
+                startReviewEmail.addRemovedReviewers(immutableToRemove);
+                startReviewEmail.addRemovedByEmailReviewers(immutableRemovedByEmail);
+                ChangeEmail changeEmail =
+                    startReviewChangeEmailFactories.createChangeEmail(
+                        projectNameKey, cId, startReviewEmail);
+                OutgoingEmail outgoingEmail =
+                    startReviewChangeEmailFactories.createEmail(changeEmail);
+                outgoingEmail.setNotify(notify);
+                outgoingEmail.setFrom(userId);
+                outgoingEmail.setMessageId(
                     messageIdGenerator.fromChangeUpdate(
                         change.getProject(), change.currentPatchSetId()));
-                emailSender.send();
+                outgoingEmail.send();
               } catch (Exception err) {
                 logger.atSevere().withCause(err).log(
                     "Cannot send email to new reviewers of change %s", change.getId());
diff --git a/java/com/google/gerrit/server/change/ReaddOwnerUtil.java b/java/com/google/gerrit/server/change/ReaddOwnerUtil.java
new file mode 100644
index 0000000..afbe30b
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ReaddOwnerUtil.java
@@ -0,0 +1,127 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.config.AttentionSetConfig;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.AttentionSetUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+@Singleton
+public class ReaddOwnerUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final AttentionSetConfig cfg;
+  private final Provider<ChangeQueryProcessor> queryProvider;
+  private final ChangeQueryBuilder queryBuilder;
+  private final AddToAttentionSetOp.Factory opFactory;
+  private final ServiceUserClassifier serviceUserClassifier;
+  private final InternalUser internalUser;
+
+  @Inject
+  ReaddOwnerUtil(
+      AttentionSetConfig cfg,
+      Provider<ChangeQueryProcessor> queryProvider,
+      ChangeQueryBuilder queryBuilder,
+      AddToAttentionSetOp.Factory opFactory,
+      ServiceUserClassifier serviceUserClassifier,
+      InternalUser.Factory internalUserFactory) {
+    this.cfg = cfg;
+    this.queryProvider = queryProvider;
+    this.queryBuilder = queryBuilder;
+    this.opFactory = opFactory;
+    this.serviceUserClassifier = serviceUserClassifier;
+    internalUser = internalUserFactory.create();
+  }
+
+  public void readdOwnerForInactiveOpenChanges(BatchUpdate.Factory updateFactory) {
+    if (cfg.getReaddAfter() <= 0) {
+      logger.atWarning().log("readdOwnerAfter needs to be set to a positive value");
+      return;
+    }
+
+    try {
+      String query =
+          "status:new -is:wip -is:private age:"
+              + TimeUnit.MILLISECONDS.toMinutes(cfg.getReaddAfter())
+              + "m";
+
+      List<ChangeData> changesToAddOwner =
+          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
+
+      ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
+          ImmutableListMultimap.builder();
+      for (ChangeData cd : changesToAddOwner) {
+        builder.put(cd.project(), cd);
+      }
+
+      ListMultimap<Project.NameKey, ChangeData> ownerAdds = builder.build();
+      int ownersAdded = 0;
+      for (Project.NameKey project : ownerAdds.keySet()) {
+        try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+          try (BatchUpdate bu = updateFactory.create(project, internalUser, TimeUtil.now())) {
+            for (ChangeData changeData : ownerAdds.get(project)) {
+              Account.Id ownerId = changeData.change().getOwner();
+              if (!inAttentionSet(changeData, ownerId)
+                  && !serviceUserClassifier.isServiceUser(ownerId)) {
+                logger.atFine().log(
+                    "Batch owner for add to AS of change %s in project %s",
+                    changeData.getId(), project.get());
+                bu.addOp(
+                    changeData.getId(), opFactory.create(ownerId, cfg.getReaddMessage(), true));
+                ownersAdded++;
+              }
+            }
+            bu.execute();
+          } catch (RestApiException | UpdateException e) {
+            logger.atSevere().withCause(e).log(
+                "Failed to readd owners for changes in project %s", project.get());
+          }
+        }
+      }
+      logger.atInfo().log("Auto-Added %d owners to changes", ownersAdded);
+    } catch (QueryParseException | StorageException e) {
+      logger.atSevere().withCause(e).log(
+          "Failed to query inactive open changes for readding owners.");
+    }
+  }
+
+  private static boolean inAttentionSet(ChangeData changeData, Account.Id accountId) {
+    return AttentionSetUtil.additionsOnly(changeData.attentionSet()).stream()
+        .anyMatch(u -> u.account().equals(accountId));
+  }
+}
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index ed87c76..540e438 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
@@ -62,6 +64,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.merge.MergeResult;
+import org.eclipse.jgit.merge.Merger;
 import org.eclipse.jgit.merge.ResolveMerger;
 import org.eclipse.jgit.merge.ThreeWayMerger;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -108,6 +111,7 @@
   private boolean storeCopiedVotes = true;
   private boolean matchAuthorToCommitterDate = false;
   private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
+  private String mergeStrategy;
 
   private CodeReviewCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
@@ -264,6 +268,11 @@
     return this;
   }
 
+  public RebaseChangeOp setMergeStrategy(String strategy) {
+    this.mergeStrategy = strategy;
+    return this;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx)
       throws InvalidChangeOperationException, RestApiException, IOException, NoSuchChangeException,
@@ -430,9 +439,14 @@
       throw new ResourceConflictException("Change is already up to date.");
     }
 
-    ThreeWayMerger merger =
-        newMergeUtil().newThreeWayMerger(ctx.getInserter(), ctx.getRepoView().getConfig());
-    merger.setBase(parentCommit);
+    MergeUtil mergeUtil = newMergeUtil();
+    String strategy =
+        firstNonNull(Strings.emptyToNull(mergeStrategy), mergeUtil.mergeStrategyName());
+
+    Merger merger = MergeUtil.newMerger(ctx.getInserter(), ctx.getRepoView().getConfig(), strategy);
+    if (merger instanceof ThreeWayMerger) {
+      ((ThreeWayMerger) merger).setBase(parentCommit);
+    }
 
     DirCache dc = DirCache.newInCore();
     if (allowConflicts && merger instanceof ResolveMerger) {
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 56ab936..48b052f 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -552,6 +552,7 @@
   private RebaseChangeOp applyRebaseInputToOp(RebaseChangeOp op, RebaseInput input) {
     return op.setForceContentMerge(true)
         .setAllowConflicts(input.allowConflicts)
+        .setMergeStrategy(input.strategy)
         .setValidationOptions(
             ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions))
         .setFireRevisionCreated(true);
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 1d92521..5930f7a 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange.USER_REMOVED;
 import static java.util.Objects.requireNonNull;
 
 import com.google.gerrit.entities.Account;
@@ -21,7 +22,6 @@
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -39,7 +39,6 @@
   }
 
   private final ChangeData.Factory changeDataFactory;
-  private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
   private final AttentionSetEmail.Factory attentionSetEmailFactory;
 
   private final Account.Id attentionUserId;
@@ -58,13 +57,11 @@
   @Inject
   RemoveFromAttentionSetOp(
       ChangeData.Factory changeDataFactory,
-      RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
       AttentionSetEmail.Factory attentionSetEmailFactory,
       @Assisted Account.Id attentionUserId,
       @Assisted String reason,
       @Assisted boolean notify) {
     this.changeDataFactory = changeDataFactory;
-    this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
     this.attentionSetEmailFactory = attentionSetEmailFactory;
     this.attentionUserId = requireNonNull(attentionUserId, "user");
     this.reason = requireNonNull(reason, "reason");
@@ -97,13 +94,6 @@
     if (!notify) {
       return;
     }
-    attentionSetEmailFactory
-        .create(
-            removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
-            ctx,
-            change,
-            reason,
-            attentionUserId)
-        .sendAsync();
+    attentionSetEmailFactory.create(USER_REMOVED, ctx, change, reason, attentionUserId).sendAsync();
   }
 }
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index c4fd5be..9810c81 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -169,7 +169,8 @@
       boolean addLinks,
       boolean fillCommit,
       String branchName,
-      String changeKey)
+      String changeKey,
+      int numericChangeId)
       throws IOException {
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
@@ -184,7 +185,12 @@
     if (addLinks) {
       ImmutableList<WebLinkInfo> patchSetLinks =
           webLinks.getPatchSetLinks(
-              project, commit.name(), commit.getFullMessage(), branchName, changeKey);
+              project,
+              commit.name(),
+              commit.getFullMessage(),
+              branchName,
+              changeKey,
+              numericChangeId);
       info.webLinks = patchSetLinks.isEmpty() ? null : patchSetLinks;
       ImmutableList<WebLinkInfo> resolveConflictsLinks =
           webLinks.getResolveConflictsLinks(
@@ -309,7 +315,14 @@
       if (setCommit) {
         out.commit =
             getCommitInfo(
-                project, rw, commit, has(WEB_LINKS), fillCommit, branchName, c.getKey().get());
+                project,
+                rw,
+                commit,
+                has(WEB_LINKS),
+                fillCommit,
+                branchName,
+                c.getKey().get(),
+                c.getId().get());
       }
       if (addFooters) {
         Ref ref = repo.exactRef(branchName);
diff --git a/java/com/google/gerrit/server/change/SetCustomKeyedValuesOp.java b/java/com/google/gerrit/server/change/SetCustomKeyedValuesOp.java
new file mode 100644
index 0000000..0810c447
--- /dev/null
+++ b/java/com/google/gerrit/server/change/SetCustomKeyedValuesOp.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.change.CustomKeyedValuesUtil.extractCustomKeyedValues;
+import static com.google.gerrit.server.change.CustomKeyedValuesUtil.extractCustomKeys;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUES;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.server.change.CustomKeyedValuesUtil.InvalidCustomKeyedValueException;
+import com.google.gerrit.server.extensions.events.CustomKeyedValuesEdited;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.validators.CustomKeyedValueValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+public class SetCustomKeyedValuesOp implements BatchUpdateOp {
+  public interface Factory {
+    SetCustomKeyedValuesOp create(CustomKeyedValuesInput input);
+  }
+
+  private final PluginSetContext<CustomKeyedValueValidationListener> validationListeners;
+  private final CustomKeyedValuesEdited customKeyedValuesEdited;
+  private final CustomKeyedValuesInput input;
+
+  private boolean fireEvent = true;
+
+  private Change change;
+  private ImmutableMap<String, String> toAdd;
+  private ImmutableSet<String> toRemove;
+  private ImmutableMap<String, String> updatedCustomKeyedValues;
+
+  @Inject
+  SetCustomKeyedValuesOp(
+      PluginSetContext<CustomKeyedValueValidationListener> validationListeners,
+      CustomKeyedValuesEdited customKeyedValuesEdited,
+      @Assisted @Nullable CustomKeyedValuesInput input) {
+    this.validationListeners = validationListeners;
+    this.customKeyedValuesEdited = customKeyedValuesEdited;
+    this.input = input;
+  }
+
+  public SetCustomKeyedValuesOp setFireEvent(boolean fireEvent) {
+    this.fireEvent = fireEvent;
+    return this;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, BadRequestException, MethodNotAllowedException, IOException {
+    if (input == null || (input.add == null && input.remove == null)) {
+      updatedCustomKeyedValues = ImmutableMap.of();
+      return false;
+    }
+
+    change = ctx.getChange();
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    ChangeNotes notes = update.getNotes().load();
+
+    try {
+      ImmutableMap<String, String> existingCustomKeyedValues = notes.getCustomKeyedValues();
+      ImmutableMap<String, String> tryingToAdd = extractCustomKeyedValues(input.add);
+      ImmutableSet<String> tryingToRemove = extractCustomKeys(input.remove);
+
+      validationListeners.runEach(
+          l -> l.validateCustomKeyedValues(update.getChange(), tryingToAdd, tryingToRemove),
+          ValidationException.class);
+      Map<String, String> newValues = new HashMap<>(existingCustomKeyedValues);
+      Map<String, String> added = new HashMap<>();
+      // Do the removes before the additions so that adding a key with a value while
+      // removing the key consists of adding the key with that new value.
+      for (String key : tryingToRemove) {
+        if (!newValues.containsKey(key)) {
+          continue;
+        }
+        update.deleteCustomKeyedValue(key);
+        newValues.remove(key);
+      }
+      for (Map.Entry<String, String> add : tryingToAdd.entrySet()) {
+        if (newValues.containsKey(add.getKey())
+            && newValues.get(add.getKey()).equals(add.getValue())) {
+          continue;
+        }
+        update.addCustomKeyedValue(add.getKey(), add.getValue());
+        newValues.put(add.getKey(), add.getValue());
+        added.put(add.getKey(), add.getValue());
+      }
+      if (newValues.size() > MAX_CUSTOM_KEYED_VALUES) {
+        throw new ValidationException("Too many custom keyed values.");
+      }
+      toAdd = ImmutableMap.copyOf(added);
+      toRemove =
+          ImmutableSet.copyOf(
+              Sets.filter(tryingToRemove, k -> existingCustomKeyedValues.containsKey(k)));
+      updatedCustomKeyedValues = ImmutableMap.copyOf(newValues);
+      return true;
+    } catch (ValidationException | InvalidCustomKeyedValueException e) {
+      throw new BadRequestException(e.getMessage(), e);
+    }
+  }
+
+  @Override
+  public void postUpdate(PostUpdateContext ctx) {
+    if (updated() && fireEvent) {
+      customKeyedValuesEdited.fire(
+          ctx.getChangeData(change),
+          ctx.getAccount(),
+          updatedCustomKeyedValues,
+          toAdd,
+          toRemove,
+          ctx.getWhen());
+    }
+  }
+
+  public ImmutableMap<String, String> getUpdatedCustomKeyedValues() {
+    checkState(
+        updatedCustomKeyedValues != null,
+        "getUpdatedCustomKeyedValues() only valid after executing op");
+    return updatedCustomKeyedValues;
+  }
+
+  private boolean updated() {
+    return (toAdd != null && !toAdd.isEmpty()) || (toRemove != null && !toRemove.isEmpty());
+  }
+}
diff --git a/java/com/google/gerrit/server/config/AttentionSetConfig.java b/java/com/google/gerrit/server/config/AttentionSetConfig.java
new file mode 100644
index 0000000..19698ae
--- /dev/null
+++ b/java/com/google/gerrit/server/config/AttentionSetConfig.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.config.ScheduleConfig.Schedule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class AttentionSetConfig {
+  private static final String SECTION = "attentionSet";
+  private static final String KEY_READD_AFTER = "readdOwnerAfter";
+  private static final String KEY_READD_MESSAGE = "readdOwnerMessage";
+  private static final String DEFAULT_READD_MESSAGE =
+      "Owner readded to attention-set due to inactivity, see "
+          + "${URL}\n"
+          + "\n"
+          + "If you do not want to be readded to the attention-set when the timer has counted down,"
+          + " set this change as WIP or private.";
+
+  private final DynamicItem<UrlFormatter> urlFormatter;
+  private final Optional<Schedule> schedule;
+  private final long readdAfter;
+  private final String readdMessage;
+
+  @Inject
+  AttentionSetConfig(@GerritServerConfig Config cfg, DynamicItem<UrlFormatter> urlFormatter) {
+    this.urlFormatter = urlFormatter;
+    schedule = ScheduleConfig.createSchedule(cfg, SECTION);
+    readdAfter = readReaddAfter(cfg);
+    readdMessage = readReaddMessage(cfg);
+  }
+
+  private long readReaddAfter(Config cfg) {
+    long readdAfter =
+        ConfigUtil.getTimeUnit(cfg, SECTION, null, KEY_READD_AFTER, 0, TimeUnit.MILLISECONDS);
+    return readdAfter >= 0 ? readdAfter : 0;
+  }
+
+  private String readReaddMessage(Config cfg) {
+    String readdMessage = cfg.getString(SECTION, null, KEY_READD_MESSAGE);
+    return Strings.isNullOrEmpty(readdMessage) ? DEFAULT_READD_MESSAGE : readdMessage;
+  }
+
+  public Optional<Schedule> getSchedule() {
+    return schedule;
+  }
+
+  public long getReaddAfter() {
+    return readdAfter;
+  }
+
+  public String getReaddMessage() {
+    String docUrl =
+        urlFormatter.get().getDocUrl("user-attention-set.html", "auto-readd-owner").orElse("");
+    return docUrl.isEmpty() ? readdMessage : readdMessage.replace("${URL}", docUrl);
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index eee1c83..e9912f5 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.CustomKeyedValuesEditedListener;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
 import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
@@ -88,7 +89,6 @@
 import com.google.gerrit.server.ExceptionHookImpl;
 import com.google.gerrit.server.ExternalUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PerformanceMetrics;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.TraceRequestListener;
 import com.google.gerrit.server.account.AccountCacheImpl;
@@ -169,7 +169,6 @@
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.mail.send.FromAddressGenerator;
 import com.google.gerrit.server.mail.send.FromAddressGeneratorProvider;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
 import com.google.gerrit.server.mail.send.MailSoySauceModule;
 import com.google.gerrit.server.mail.send.MailSoyTemplateProvider;
 import com.google.gerrit.server.mime.FileTypeRegistry;
@@ -205,9 +204,8 @@
 import com.google.gerrit.server.restapi.group.GroupModule;
 import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
 import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
-import com.google.gerrit.server.rules.PrologModule;
-import com.google.gerrit.server.rules.RulesCache;
 import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.rules.prolog.PrologModule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.submit.ConfiguredSubscriptionGraphFactory;
 import com.google.gerrit.server.submit.GitModules;
@@ -252,7 +250,6 @@
     bind(EmailExpander.class).toProvider(EmailExpanderProvider.class).in(SINGLETON);
 
     bind(IdGenerator.class);
-    bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
     install(AccountCacheImpl.module());
     install(BatchUpdate.module());
@@ -307,7 +304,6 @@
     factory(PatchScriptFactoryForAutoFix.Factory.class);
     factory(ProjectState.Factory.class);
     factory(RevisionJson.Factory.class);
-    factory(InboundEmailRejectionSender.Factory.class);
     factory(ExternalUser.Factory.class);
     bind(PermissionCollection.Factory.class);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
@@ -333,6 +329,7 @@
     DynamicSet.setOf(binder(), GerritConfigListener.class);
 
     bind(ChangeCleanupConfig.class);
+    bind(AttentionSetConfig.class);
     bind(AccountDeactivator.class);
 
     bind(ApprovalsUtil.class);
@@ -363,6 +360,7 @@
     DynamicSet.setOf(binder(), ChangeDeletedListener.class);
     DynamicSet.setOf(binder(), CommentAddedListener.class);
     DynamicSet.setOf(binder(), HashtagsEditedListener.class);
+    DynamicSet.setOf(binder(), CustomKeyedValuesEditedListener.class);
     DynamicSet.setOf(binder(), ChangeMergedListener.class);
     bind(ChangeMergedListener.class)
         .annotatedWith(Exports.named("CreateGroupPermissionSyncer"))
@@ -447,9 +445,6 @@
     DynamicSet.setOf(binder(), SubmitRequirement.class);
     DynamicSet.setOf(binder(), QuotaEnforcer.class);
     DynamicSet.setOf(binder(), PerformanceLogger.class);
-    if (cfg.getBoolean("tracing", "exportPerformanceMetrics", false)) {
-      DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
-    }
     DynamicSet.setOf(binder(), RequestListener.class);
     DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
     DynamicSet.setOf(binder(), ChangeETagComputation.class);
diff --git a/java/com/google/gerrit/server/config/GerritServerConfig.java b/java/com/google/gerrit/server/config/GerritServerConfig.java
index ead0d63..484c1e9 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfig.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfig.java
@@ -16,8 +16,8 @@
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
-import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
+import javax.inject.Qualifier;
 
 /**
  * Marker on {@link org.eclipse.jgit.lib.Config} holding {@code gerrit.config} .
@@ -26,5 +26,5 @@
  * Gerrit Code Review server.
  */
 @Retention(RUNTIME)
-@BindingAnnotation
+@Qualifier
 public @interface GerritServerConfig {}
diff --git a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index cd49ea6..093b87c 100644
--- a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -34,6 +34,7 @@
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreDoc;
 import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TotalHits;
 import org.apache.lucene.store.ByteBuffersDirectory;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.IndexOutput;
@@ -84,10 +85,10 @@
       // We don't have much documentation, so we just use MAX_VALUE here and skip paging.
       TopDocs results = searcher.search(query, Integer.MAX_VALUE);
       ScoreDoc[] hits = results.scoreDocs;
-      long totalHits = results.totalHits;
+      TotalHits totalHits = results.totalHits;
 
       List<DocResult> out = new ArrayList<>();
-      for (int i = 0; i < totalHits; i++) {
+      for (int i = 0; i < totalHits.value; i++) {
         DocResult result = new DocResult();
         Document doc = searcher.doc(hits[i].doc);
         result.url = doc.get(Constants.URL_FIELD);
diff --git a/java/com/google/gerrit/server/events/CommentAddedEvent.java b/java/com/google/gerrit/server/events/CommentAddedEvent.java
index dbbebe8..d59ab08d2 100644
--- a/java/com/google/gerrit/server/events/CommentAddedEvent.java
+++ b/java/com/google/gerrit/server/events/CommentAddedEvent.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.events;
 
 import com.google.common.base.Supplier;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.data.AccountAttribute;
 import com.google.gerrit.server.data.ApprovalAttribute;
@@ -23,7 +24,7 @@
   static final String TYPE = "comment-added";
   public Supplier<AccountAttribute> author;
   public Supplier<ApprovalAttribute[]> approvals;
-  public String comment;
+  @Nullable public String comment;
 
   public CommentAddedEvent(Change change) {
     super(TYPE, change);
diff --git a/java/com/google/gerrit/server/events/CustomKeyedValuesChangedEvent.java b/java/com/google/gerrit/server/events/CustomKeyedValuesChangedEvent.java
new file mode 100644
index 0000000..353c830
--- /dev/null
+++ b/java/com/google/gerrit/server/events/CustomKeyedValuesChangedEvent.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+import java.util.Map;
+
+public class CustomKeyedValuesChangedEvent extends ChangeEvent {
+  static final String TYPE = "custom-keyed-values-changed";
+  public Supplier<AccountAttribute> editor;
+  public Map<String, String> added;
+  public String[] removed;
+  public Map<String, String> customKeyedValues;
+
+  public CustomKeyedValuesChangedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/java/com/google/gerrit/server/events/EventTypes.java b/java/com/google/gerrit/server/events/EventTypes.java
index e24bbd2..2b35ee3 100644
--- a/java/com/google/gerrit/server/events/EventTypes.java
+++ b/java/com/google/gerrit/server/events/EventTypes.java
@@ -29,6 +29,7 @@
     register(ChangeRestoredEvent.TYPE, ChangeRestoredEvent.class);
     register(CommentAddedEvent.TYPE, CommentAddedEvent.class);
     register(CommitReceivedEvent.TYPE, CommitReceivedEvent.class);
+    register(CustomKeyedValuesChangedEvent.TYPE, CustomKeyedValuesChangedEvent.class);
     register(HashtagsChangedEvent.TYPE, HashtagsChangedEvent.class);
     register(PatchSetCreatedEvent.TYPE, PatchSetCreatedEvent.class);
     register(PrivateStateChangedEvent.TYPE, PrivateStateChangedEvent.class);
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 50c15b7..2d90d9b 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.CustomKeyedValuesEditedListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
@@ -81,6 +82,7 @@
         CommentAddedListener,
         GitReferenceUpdatedListener,
         HashtagsEditedListener,
+        CustomKeyedValuesEditedListener,
         NewProjectCreatedListener,
         ReviewerAddedListener,
         ReviewerDeletedListener,
@@ -101,6 +103,8 @@
       DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
           .to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), HashtagsEditedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), CustomKeyedValuesEditedListener.class)
+          .to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), NewProjectCreatedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), PrivateStateChangedListener.class)
           .to(StreamEventsApiListener.class);
@@ -233,9 +237,9 @@
   }
 
   @Nullable
-  String[] hashtagArray(Collection<String> hashtags) {
-    if (hashtags != null && !hashtags.isEmpty()) {
-      return Sets.newHashSet(hashtags).toArray(new String[hashtags.size()]);
+  String[] hashArray(Collection<String> collection) {
+    if (collection != null && !collection.isEmpty()) {
+      return Sets.newHashSet(collection).toArray(new String[collection.size()]);
     }
     return null;
   }
@@ -342,9 +346,28 @@
 
       event.change = changeAttributeSupplier(change, notes);
       event.editor = accountAttributeSupplier(ev.getWho());
-      event.hashtags = hashtagArray(ev.getHashtags());
-      event.added = hashtagArray(ev.getAddedHashtags());
-      event.removed = hashtagArray(ev.getRemovedHashtags());
+      event.hashtags = hashArray(ev.getHashtags());
+      event.added = hashArray(ev.getAddedHashtags());
+      event.removed = hashArray(ev.getRemovedHashtags());
+
+      dispatcher.run(d -> d.postEvent(change, event));
+    } catch (StorageException e) {
+      logger.atSevere().withCause(e).log("Failed to dispatch event");
+    }
+  }
+
+  @Override
+  public void onCustomKeyedValuesEdited(CustomKeyedValuesEditedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      CustomKeyedValuesChangedEvent event = new CustomKeyedValuesChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change, notes);
+      event.editor = accountAttributeSupplier(ev.getWho());
+      event.customKeyedValues = ev.getCustomKeyedValues();
+      event.added = ev.getAddedCustomKeyedValues();
+      event.removed = hashArray(ev.getRemovedCustomKeys());
 
       dispatcher.run(d -> d.postEvent(change, event));
     } catch (StorageException e) {
diff --git a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
index 227deb5..8de5db3 100644
--- a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
+++ b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.experiments;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
@@ -57,6 +58,11 @@
   }
 
   @Override
+  public boolean isFeatureEnabled(String featureFlag, Project.NameKey project) {
+    return isFeatureEnabled(featureFlag);
+  }
+
+  @Override
   public ImmutableSet<String> getEnabledExperimentFeatures() {
     return enabledExperimentFeatures;
   }
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
index dc9148a..fd885ed 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeatures.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.experiments;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Project;
 
 /**
  * Features that can be enabled/disabled on Gerrit (e. g. experiments to research new behavior in
@@ -37,6 +38,12 @@
   boolean isFeatureEnabled(String featureFlag);
 
   /**
+   * Same {@link #isFeatureEnabled}, but takes into account {@code project}, when evaluating the
+   * experiment.
+   */
+  boolean isFeatureEnabled(String featureFlag, Project.NameKey project);
+
+  /**
    * Returns the names of the features that are enabled on Gerrit instance (either by default or via
    * gerrit.config).
    */
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index e294d55..6052ddc 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -27,6 +27,13 @@
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of();
 
   /** On BatchUpdate, do not await index completion before returning to the user */
-  public static String GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING =
-      "GerritBackendFeature__do_not_await_change_indexing";
+  public static String GERRIT_BACKEND_REQUEST_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING =
+      "GerritBackendRequestFeature__do_not_await_change_indexing";
+
+  /**
+   * Sets ChangeInfo.id to "'<project>~<_number>'", instead of "'<project>~<branch>~<Change-Id>'",
+   * spearing an index lookup if the id is used in the follow-up API calls.
+   */
+  public static String GERRIT_BACKEND_FEATURE_RETURN_NEW_CHANGE_INFO_ID =
+      "GerritBackendFeature__return_new_change_info_id";
 }
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 79544f2..c6661bd 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -54,7 +55,7 @@
       ChangeData changeData,
       PatchSet ps,
       AccountState author,
-      String comment,
+      @Nullable String comment,
       Map<String, Short> approvals,
       Map<String, Short> oldApprovals,
       Instant when) {
@@ -86,7 +87,7 @@
   /** Event to be fired when a comment or vote has been added to a change. */
   private static class Event extends AbstractRevisionEvent implements CommentAddedListener.Event {
 
-    private final String comment;
+    @Nullable private final String comment;
     private final Map<String, ApprovalInfo> approvals;
     private final Map<String, ApprovalInfo> oldApprovals;
 
@@ -94,7 +95,7 @@
         ChangeInfo change,
         RevisionInfo revision,
         AccountInfo author,
-        String comment,
+        @Nullable String comment,
         Map<String, ApprovalInfo> approvals,
         Map<String, ApprovalInfo> oldApprovals,
         Instant when) {
@@ -105,6 +106,7 @@
     }
 
     @Override
+    @Nullable
     public String getComment() {
       return comment;
     }
diff --git a/java/com/google/gerrit/server/extensions/events/CustomKeyedValuesEdited.java b/java/com/google/gerrit/server/extensions/events/CustomKeyedValuesEdited.java
new file mode 100644
index 0000000..949840a
--- /dev/null
+++ b/java/com/google/gerrit/server/extensions/events/CustomKeyedValuesEdited.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.CustomKeyedValuesEditedListener;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Instant;
+
+/** Helper class to fire an event when the hashtags of a change has been edited. */
+@Singleton
+public class CustomKeyedValuesEdited {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final PluginSetContext<CustomKeyedValuesEditedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  public CustomKeyedValuesEdited(
+      PluginSetContext<CustomKeyedValuesEditedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(
+      ChangeData changeData,
+      AccountState editor,
+      ImmutableMap<String, String> customKeyedValues,
+      ImmutableMap<String, String> added,
+      ImmutableSet<String> removed,
+      Instant when) {
+    if (listeners.isEmpty()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(changeData),
+              util.accountInfo(editor),
+              customKeyedValues,
+              added,
+              removed,
+              when);
+      listeners.runEach(l -> l.onCustomKeyedValuesEdited(event));
+    } catch (StorageException e) {
+      logger.atSevere().withCause(e).log("Couldn't fire event");
+    }
+  }
+
+  /** Event to be fired when the custom keyed values of a change has been edited. */
+  private static class Event extends AbstractChangeEvent
+      implements CustomKeyedValuesEditedListener.Event {
+
+    private ImmutableMap<String, String> updated;
+    private ImmutableMap<String, String> added;
+    private ImmutableSet<String> removed;
+
+    Event(
+        ChangeInfo change,
+        AccountInfo editor,
+        ImmutableMap<String, String> updated,
+        ImmutableMap<String, String> added,
+        ImmutableSet<String> removed,
+        Instant when) {
+      super(change, editor, when, NotifyHandling.ALL);
+      this.updated = updated;
+      this.added = added;
+      this.removed = removed;
+    }
+
+    @Override
+    public ImmutableMap<String, String> getCustomKeyedValues() {
+      return updated;
+    }
+
+    @Override
+    public ImmutableMap<String, String> getAddedCustomKeyedValues() {
+      return added;
+    }
+
+    @Override
+    public ImmutableSet<String> getRemovedCustomKeys() {
+      return removed;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index ffb6c66..40d714d 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -43,8 +43,10 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ValidationOptionsUtil;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
+import com.google.gerrit.server.mail.EmailModule.RevertedChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.RevertedSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.notedb.Sequences;
@@ -93,7 +95,7 @@
   private final ApprovalsUtil approvalsUtil;
   private final ChangeInserter.Factory changeInserterFactory;
   private final NotifyResolver notifyResolver;
-  private final RevertedSender.Factory revertedSenderFactory;
+  private final RevertedChangeEmailFactories revertedChangeEmailFactories;
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeReverted changeReverted;
@@ -108,7 +110,7 @@
       ApprovalsUtil approvalsUtil,
       ChangeInserter.Factory changeInserterFactory,
       NotifyResolver notifyResolver,
-      RevertedSender.Factory revertedSenderFactory,
+      RevertedChangeEmailFactories revertedChangeEmailFactories,
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory changeNotesFactory,
       ChangeReverted changeReverted,
@@ -120,7 +122,7 @@
     this.approvalsUtil = approvalsUtil;
     this.changeInserterFactory = changeInserterFactory;
     this.notifyResolver = notifyResolver;
-    this.revertedSenderFactory = revertedSenderFactory;
+    this.revertedChangeEmailFactories = revertedChangeEmailFactories;
     this.cmUtil = cmUtil;
     this.changeNotesFactory = changeNotesFactory;
     this.changeReverted = changeReverted;
@@ -381,14 +383,16 @@
           ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertingChangeId));
       changeReverted.fire(revertedChange, revertingChange, ctx.getWhen());
       try {
-        RevertedSender emailSender =
-            revertedSenderFactory.create(ctx.getProject(), revertedChange.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setNotify(ctx.getNotify(revertedChangeId));
-        emailSender.setMessageId(
+        ChangeEmail changeEmail =
+            revertedChangeEmailFactories.createChangeEmail(
+                ctx.getProject(), revertedChange.getId());
+        OutgoingEmail outgoingEmail = revertedChangeEmailFactories.createEmail(changeEmail);
+        outgoingEmail.setFrom(ctx.getAccountId());
+        outgoingEmail.setNotify(ctx.getNotify(revertedChangeId));
+        outgoingEmail.setMessageId(
             messageIdGenerator.fromChangeUpdate(
                 ctx.getRepoView(), revertedChange.currentPatchSet().id()));
-        emailSender.send();
+        outgoingEmail.send();
       } catch (Exception err) {
         logger.atSevere().withCause(err).log(
             "Cannot send email for revert change %s", revertedChangeId);
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 426f8db..8844c1e 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -26,8 +26,10 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
-import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.EmailModule.MergedChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -67,7 +69,7 @@
   private final RequestScopePropagator requestScopePropagator;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final ChangeMessagesUtil cmUtil;
-  private final MergedSender.Factory mergedSenderFactory;
+  private final MergedChangeEmailFactories mergedChangeEmailFactories;
   private final PatchSetUtil psUtil;
   private final ExecutorService sendEmailExecutor;
   private final ChangeMerged changeMerged;
@@ -88,7 +90,7 @@
   MergedByPushOp(
       PatchSetInfoFactory patchSetInfoFactory,
       ChangeMessagesUtil cmUtil,
-      MergedSender.Factory mergedSenderFactory,
+      MergedChangeEmailFactories mergedChangeEmailFactories,
       PatchSetUtil psUtil,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
       ChangeMerged changeMerged,
@@ -100,7 +102,7 @@
       @Assisted("mergeResultRevId") String mergeResultRevId) {
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.cmUtil = cmUtil;
-    this.mergedSenderFactory = mergedSenderFactory;
+    this.mergedChangeEmailFactories = mergedChangeEmailFactories;
     this.psUtil = psUtil;
     this.sendEmailExecutor = sendEmailExecutor;
     this.changeMerged = changeMerged;
@@ -188,16 +190,18 @@
                     try {
                       // The stickyApprovalDiff is always empty here since this is not supported
                       // for direct pushes.
-                      MergedSender emailSender =
-                          mergedSenderFactory.create(
+                      ChangeEmail changeEmail =
+                          mergedChangeEmailFactories.createChangeEmail(
                               ctx.getProject(),
                               psId.changeId(),
                               /* stickyApprovalDiff= */ Optional.empty());
-                      emailSender.setFrom(ctx.getAccountId());
-                      emailSender.setPatchSet(patchSet, info);
-                      emailSender.setMessageId(
+                      changeEmail.setPatchSet(patchSet, info);
+                      OutgoingEmail outgoingEmail =
+                          mergedChangeEmailFactories.createEmail(changeEmail);
+                      outgoingEmail.setFrom(ctx.getAccountId());
+                      outgoingEmail.setMessageId(
                           messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
-                      emailSender.send();
+                      outgoingEmail.send();
                     } catch (Exception e) {
                       logger.atSevere().withCause(e).log(
                           "Cannot send email for submitted patch set %s", psId);
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 2baca53..3b08d92 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -260,9 +260,9 @@
  *
  * <p>Conceptually, most use of Gerrit is a push of some commits to refs/for/BRANCH. However, the
  * receive-pack protocol that this is based on allows multiple ref updates to be processed at once.
- * So we have to be prepared to also handle normal pushes (refs/heads/BRANCH), and legacy pushes
- * (refs/changes/CHANGE). It is hard to split this class up further, because normal pushes can also
- * result in updates to reviews, through the autoclose mechanism.
+ * So we have to be prepared to also handle normal pushes (refs/heads/BRANCH). It is hard to split
+ * this class up further, because normal pushes can also result in updates to reviews, through the
+ * autoclose mechanism.
  */
 class ReceiveCommits {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 8e7d964..fd264a1 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -85,7 +85,10 @@
           .build();
 
   // Upgrade Lucene to 7.x requires reindexing.
-  static final Schema<AccountState> V12 = schema(V11);
+  @Deprecated static final Schema<AccountState> V12 = schema(V11);
+
+  // Upgrade Lucene to 8.x requires reindexing.
+  static final Schema<AccountState> V13 = schema(V12);
 
   /**
    * Name of the account index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index e74ce8f..6f2bfdd 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -239,7 +239,7 @@
           .build();
 
   /** Remove assignee field. */
-  @SuppressWarnings("deprecation")
+  @Deprecated
   static final Schema<ChangeData> V82 =
       new Schema.Builder<ChangeData>()
           .add(V81)
@@ -247,6 +247,9 @@
           .remove(ChangeField.ASSIGNEE_FIELD)
           .build();
 
+  /** Upgrade Lucene to 8.x requires reindexing. */
+  static final Schema<ChangeData> V83 = schema(V82);
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index f0f3510..1b87d27 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -69,7 +69,10 @@
   @Deprecated static final Schema<InternalGroup> V8 = schema(V7);
 
   // Upgrade Lucene to 7.x requires reindexing.
-  static final Schema<InternalGroup> V9 = schema(V8);
+  @Deprecated static final Schema<InternalGroup> V9 = schema(V8);
+
+  // Upgrade Lucene to 8.x requires reindexing.
+  static final Schema<InternalGroup> V10 = schema(V9);
 
   /** Singleton instance of the schema definitions. This is one per JVM. */
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index 50f26bb..92777e4 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -14,42 +14,467 @@
 
 package com.google.gerrit.server.mail;
 
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.mail.send.AbandonedSender;
-import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
-import com.google.gerrit.server.mail.send.CommentSender;
-import com.google.gerrit.server.mail.send.CreateChangeSender;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.mail.send.DeleteVoteSender;
-import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
-import com.google.gerrit.server.mail.send.MergedSender;
-import com.google.gerrit.server.mail.send.ModifyReviewerSender;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
-import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
-import com.google.gerrit.server.mail.send.RestoredSender;
-import com.google.gerrit.server.mail.send.RevertedSender;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.send.AbandonedChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.AddKeyEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange;
+import com.google.gerrit.server.mail.send.ChangeEmail;
+import com.google.gerrit.server.mail.send.ChangeEmailFactory;
+import com.google.gerrit.server.mail.send.CommentChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.CommentChangeEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.DeleteKeyEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.DeleteVoteChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.EmailArguments;
+import com.google.gerrit.server.mail.send.HttpPasswordUpdateEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
+import com.google.gerrit.server.mail.send.MergedChangeEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
+import com.google.gerrit.server.mail.send.OutgoingEmailFactory;
+import com.google.gerrit.server.mail.send.RegisterNewEmailDecorator;
+import com.google.gerrit.server.mail.send.RegisterNewEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.ReplacePatchSetChangeEmailDecoratorFactory;
+import com.google.gerrit.server.mail.send.RestoredChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.RevertedChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.StartReviewChangeEmailDecorator;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 public class EmailModule extends FactoryModule {
-  @Override
-  protected void configure() {
-    factory(AbandonedSender.Factory.class);
-    factory(AddKeySender.Factory.class);
-    factory(ModifyReviewerSender.Factory.class);
-    factory(CommentSender.Factory.class);
-    factory(CreateChangeSender.Factory.class);
-    factory(DeleteKeySender.Factory.class);
-    factory(DeleteReviewerSender.Factory.class);
-    factory(DeleteVoteSender.Factory.class);
-    factory(HttpPasswordUpdateSender.Factory.class);
-    factory(MergedSender.Factory.class);
-    factory(RegisterNewEmailSender.Factory.class);
-    factory(ReplacePatchSetSender.Factory.class);
-    factory(RestoredSender.Factory.class);
-    factory(RevertedSender.Factory.class);
-    factory(AddToAttentionSetSender.Factory.class);
-    factory(RemoveFromAttentionSetSender.Factory.class);
+  public static class AbandonedChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+    private final AbandonedChangeEmailDecorator abandonedChangeEmailDecorator;
+
+    @Inject
+    AbandonedChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
+        AbandonedChangeEmailDecorator abandonedChangeEmailDecorator) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+      this.abandonedChangeEmailDecorator = abandonedChangeEmailDecorator;
+    }
+
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), abandonedChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("abandon", changeEmail);
+    }
+  }
+
+  public static class AttentionSetChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    AttentionSetChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public AttentionSetChangeEmailDecorator createAttentionSetChangeEmail() {
+      return new AttentionSetChangeEmailDecorator();
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        AttentionSetChangeEmailDecorator attentionSetChangeEmailDecorator) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), attentionSetChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(
+        AttentionSetChange attentionSetChange, ChangeEmail changeEmail) {
+      if (attentionSetChange.equals(AttentionSetChange.USER_ADDED)) {
+        return outgoingEmailFactory.create("addToAttentionSet", changeEmail);
+      }
+      return outgoingEmailFactory.create("removeFromAttentionSet", changeEmail);
+    }
+  }
+
+  public static class CommentChangeEmailFactories {
+    private final EmailArguments args;
+    private final CommentChangeEmailDecoratorFactory commentChangeEmailFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    CommentChangeEmailFactories(
+        EmailArguments args,
+        CommentChangeEmailDecoratorFactory commentChangeEmailFactory,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.commentChangeEmailFactory = commentChangeEmailFactory;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public CommentChangeEmailDecorator createCommentChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      return commentChangeEmailFactory.create(
+          project, changeId, preUpdateMetaId, postUpdateSubmitRequirementResults);
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        CommentChangeEmailDecorator commentChangeEmailDecorator) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), commentChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("comment", changeEmail);
+    }
+  }
+
+  public static class DeleteReviewerChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    DeleteReviewerChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public DeleteReviewerChangeEmailDecorator createDeleteReviewerChangeEmail() {
+      return new DeleteReviewerChangeEmailDecorator();
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        DeleteReviewerChangeEmailDecorator deleteReviewerChangeEmailDecorator) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), deleteReviewerChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("deleteReviewer", changeEmail);
+    }
+  }
+
+  public static class DeleteVoteChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+    private final DeleteVoteChangeEmailDecorator deleteVoteChangeEmailDecorator;
+
+    @Inject
+    DeleteVoteChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
+        DeleteVoteChangeEmailDecorator deleteVoteChangeEmailDecorator) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+      this.deleteVoteChangeEmailDecorator = deleteVoteChangeEmailDecorator;
+    }
+
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), deleteVoteChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("deleteVote", changeEmail);
+    }
+  }
+
+  public static class MergedChangeEmailFactories {
+    private final EmailArguments args;
+    private final MergedChangeEmailDecoratorFactory mergedChangeEmailDecoratorFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    MergedChangeEmailFactories(
+        EmailArguments args,
+        MergedChangeEmailDecoratorFactory mergedChangeEmailDecoratorFactory,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.mergedChangeEmailDecoratorFactory = mergedChangeEmailDecoratorFactory;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId),
+          mergedChangeEmailDecoratorFactory.create(stickyApprovalDiff));
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("merged", changeEmail);
+    }
+  }
+
+  public static class ReplacePatchSetChangeEmailFactories {
+    private final EmailArguments args;
+    private final ReplacePatchSetChangeEmailDecoratorFactory
+        replacePatchSetChangeEmailDecoratorFactory;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    ReplacePatchSetChangeEmailFactories(
+        EmailArguments args,
+        ReplacePatchSetChangeEmailDecoratorFactory replacePatchSetChangeEmailDecoratorFactory,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.replacePatchSetChangeEmailDecoratorFactory = replacePatchSetChangeEmailDecoratorFactory;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public ReplacePatchSetChangeEmailDecorator createReplacePatchSetChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        ChangeKind changeKind,
+        ObjectId preUpdateMetaId,
+        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+      return replacePatchSetChangeEmailDecoratorFactory.create(
+          project, changeId, changeKind, preUpdateMetaId, postUpdateSubmitRequirementResults);
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        ReplacePatchSetChangeEmailDecorator replacePatchSetChangeEmailDecoratorFactory) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), replacePatchSetChangeEmailDecoratorFactory);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("newpatchset", changeEmail);
+    }
+  }
+
+  public static class RestoredChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+    private final RestoredChangeEmailDecorator restoredChangeEmailDecorator;
+
+    @Inject
+    RestoredChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
+        RestoredChangeEmailDecorator restoredChangeEmailDecorator) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+      this.restoredChangeEmailDecorator = restoredChangeEmailDecorator;
+    }
+
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), restoredChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("restore", changeEmail);
+    }
+  }
+
+  public static class RevertedChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+    private final RevertedChangeEmailDecorator revertedChangeEmailDecorator;
+
+    @Inject
+    RevertedChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory,
+        RevertedChangeEmailDecorator revertedChangeEmailDecorator) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+      this.revertedChangeEmailDecorator = revertedChangeEmailDecorator;
+    }
+
+    public ChangeEmail createChangeEmail(Project.NameKey project, Change.Id changeId) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), revertedChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("revert", changeEmail);
+    }
+  }
+
+  public static class StartReviewChangeEmailFactories {
+    private final EmailArguments args;
+    private final ChangeEmailFactory changeEmailFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    StartReviewChangeEmailFactories(
+        EmailArguments args,
+        ChangeEmailFactory changeEmailFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.args = args;
+      this.changeEmailFactory = changeEmailFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public StartReviewChangeEmailDecorator createStartReviewChangeEmail() {
+      return new StartReviewChangeEmailDecorator();
+    }
+
+    public ChangeEmail createChangeEmail(
+        Project.NameKey project,
+        Change.Id changeId,
+        StartReviewChangeEmailDecorator startReviewChangeEmailDecorator) {
+      return changeEmailFactory.create(
+          args.newChangeData(project, changeId), startReviewChangeEmailDecorator);
+    }
+
+    public OutgoingEmail createEmail(ChangeEmail changeEmail) {
+      return outgoingEmailFactory.create("newchange", changeEmail);
+    }
+  }
+
+  public static class AddKeyEmailFactories {
+    private final AddKeyEmailDecoratorFactory addKeyEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    AddKeyEmailFactories(
+        AddKeyEmailDecoratorFactory addKeyEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.addKeyEmailDecoratorFactory = addKeyEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, AccountSshKey sshKey) {
+      return outgoingEmailFactory.create(
+          "addkey", addKeyEmailDecoratorFactory.create(user, sshKey));
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, List<String> gpgKeys) {
+      return outgoingEmailFactory.create(
+          "addkey", addKeyEmailDecoratorFactory.create(user, gpgKeys));
+    }
+  }
+
+  public static class DeleteKeyEmailFactories {
+    private final DeleteKeyEmailDecoratorFactory deleteKeyEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    DeleteKeyEmailFactories(
+        DeleteKeyEmailDecoratorFactory deleteKeyEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.deleteKeyEmailDecoratorFactory = deleteKeyEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, AccountSshKey sshKey) {
+      return outgoingEmailFactory.create(
+          "deletekey", deleteKeyEmailDecoratorFactory.create(user, sshKey));
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, List<String> gpgKeyFingerprints) {
+      return outgoingEmailFactory.create(
+          "deletekey", deleteKeyEmailDecoratorFactory.create(user, gpgKeyFingerprints));
+    }
+  }
+
+  public static class HttpPasswordUpdateEmailFactory {
+    private final HttpPasswordUpdateEmailDecoratorFactory httpPasswordUpdateEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    HttpPasswordUpdateEmailFactory(
+        HttpPasswordUpdateEmailDecoratorFactory httpPasswordUpdateEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.httpPasswordUpdateEmailDecoratorFactory = httpPasswordUpdateEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(IdentifiedUser user, String operation) {
+      return outgoingEmailFactory.create(
+          "HttpPasswordUpdate", httpPasswordUpdateEmailDecoratorFactory.create(user, operation));
+    }
+  }
+
+  public static class InboundEmailRejectionEmailFactory {
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    InboundEmailRejectionEmailFactory(OutgoingEmailFactory outgoingEmailFactory) {
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public OutgoingEmail createEmail(Address to, String threadId, InboundEmailError reason) {
+      return outgoingEmailFactory.create(
+          "error", new InboundEmailRejectionEmailDecorator(to, threadId, reason));
+    }
+  }
+
+  public static class RegisterNewEmailFactories {
+    private final RegisterNewEmailDecoratorFactory registerEmailDecoratorFactory;
+    private final OutgoingEmailFactory outgoingEmailFactory;
+
+    @Inject
+    RegisterNewEmailFactories(
+        RegisterNewEmailDecoratorFactory registerEmailDecoratorFactory,
+        OutgoingEmailFactory outgoingEmailFactory) {
+      this.registerEmailDecoratorFactory = registerEmailDecoratorFactory;
+      this.outgoingEmailFactory = outgoingEmailFactory;
+    }
+
+    public RegisterNewEmailDecorator createRegisterNewEmail(String address) {
+      return registerEmailDecoratorFactory.create(address);
+    }
+
+    public OutgoingEmail createEmail(RegisterNewEmailDecorator registerEmail) {
+      return outgoingEmailFactory.create("registernewemail", registerEmail);
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
index ead4c06..ea55a24 100644
--- a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
@@ -16,9 +16,8 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 
-/** Verifies the token sent by {@link RegisterNewEmailSender}. */
+/** Verifies the token used by new email address verification process. */
 public interface EmailTokenVerifier {
   /**
    * Construct a token to verify an email address for a user.
diff --git a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
index 36e801b..82ffda2 100644
--- a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
@@ -21,14 +21,13 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AuthConfig;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
-/** Verifies the token sent by {@link RegisterNewEmailSender}. */
+/** Verifies the token used by new email address verification process. */
 @Singleton
 public class SignedTokenEmailTokenVerifier implements EmailTokenVerifier {
   private final SignedToken emailRegistrationToken;
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 93da997..7fe0515 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -56,10 +56,11 @@
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.mail.EmailModule.InboundEmailRejectionEmailFactory;
 import com.google.gerrit.server.mail.MailFilter;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
-import com.google.gerrit.server.mail.send.InboundEmailRejectionSender.InboundEmailError;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionEmailDecorator.InboundEmailError;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -108,7 +109,7 @@
                   CommentForValidation.CommentType.INLINE_COMMENT);
 
   private final Emails emails;
-  private final InboundEmailRejectionSender.Factory emailRejectionSender;
+  private final InboundEmailRejectionEmailFactory inboundEmailRejectionEmailFactory;
   private final RetryHelper retryHelper;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final CommentsUtil commentsUtil;
@@ -127,7 +128,7 @@
   @Inject
   public MailProcessor(
       Emails emails,
-      InboundEmailRejectionSender.Factory emailRejectionSender,
+      InboundEmailRejectionEmailFactory inboundEmailRejectionEmailFactory,
       RetryHelper retryHelper,
       ChangeMessagesUtil changeMessagesUtil,
       CommentsUtil commentsUtil,
@@ -143,7 +144,7 @@
       PluginSetContext<CommentValidator> commentValidators,
       MessageIdGenerator messageIdGenerator) {
     this.emails = emails;
-    this.emailRejectionSender = emailRejectionSender;
+    this.inboundEmailRejectionEmailFactory = inboundEmailRejectionEmailFactory;
     this.retryHelper = retryHelper;
     this.changeMessagesUtil = changeMessagesUtil;
     this.commentsUtil = commentsUtil;
@@ -228,10 +229,10 @@
 
   private void sendRejectionEmail(MailMessage message, InboundEmailError reason) {
     try {
-      InboundEmailRejectionSender emailSender =
-          emailRejectionSender.create(message.from(), message.id(), reason);
-      emailSender.setMessageId(messageIdGenerator.fromMailMessage(message));
-      emailSender.send();
+      OutgoingEmail email =
+          inboundEmailRejectionEmailFactory.createEmail(message.from(), message.id(), reason);
+      email.setMessageId(messageIdGenerator.fromMailMessage(message));
+      email.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot send email to warn for an error");
     }
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
new file mode 100644
index 0000000..3ec2b35
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AbandonedChangeEmailDecorator.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+
+/** Send notice about a change being abandoned by its owner. */
+public class AbandonedChangeEmailDecorator implements ChangeEmail.ChangeEmailDecorator {
+  private ChangeEmail changeEmail;
+  private OutgoingEmail email;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ABANDONED_CHANGES);
+
+    email.appendText(email.textTemplate("Abandoned"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("AbandonedHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AbandonedSender.java b/java/com/google/gerrit/server/mail/send/AbandonedSender.java
deleted file mode 100644
index d8b20ba..0000000
--- a/java/com/google/gerrit/server/mail/send/AbandonedSender.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being abandoned by its owner. */
-public class AbandonedSender extends ReplyToChangeSender {
-  public interface Factory extends ReplyToChangeSender.Factory<AbandonedSender> {
-    @Override
-    AbandonedSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public AbandonedSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "abandon", ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ABANDONED_CHANGES);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Abandoned"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("AbandonedHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java
new file mode 100644
index 0000000..61e73e3
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AddKeyEmailDecorator.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import java.util.List;
+
+/** Informs a user by email about the addition of an SSH or GPG key to their account. */
+@AutoFactory
+public class AddKeyEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final AccountSshKey sshKey;
+  private final List<String> gpgKeys;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public AddKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, AccountSshKey sshKey) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.sshKey = sshKey;
+    this.gpgKeys = null;
+  }
+
+  public AddKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, List<String> gpgKeys) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.sshKey = null;
+    this.gpgKeys = gpgKeys;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader(
+        "Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
+    email.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public boolean shouldSendMessage() {
+    if (sshKey == null && (gpgKeys == null || gpgKeys.isEmpty())) {
+      // Don't email if no keys were added.
+      return false;
+    }
+
+    return true;
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("gpgKeys", getGpgKeys());
+    email.addSoyEmailDataParam("keyType", getKeyType());
+    email.addSoyEmailDataParam("sshKey", getSshKey());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("sshKeysSettingsUrl", email.getSettingsUrl("ssh-keys"));
+    email.addSoyEmailDataParam("gpgKeysSettingsUrl", email.getSettingsUrl("gpg-keys"));
+
+    email.appendText(email.textTemplate("AddKey"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("AddKeyHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+
+  private String getKeyType() {
+    if (sshKey != null) {
+      return "SSH";
+    } else if (gpgKeys != null) {
+      return "GPG";
+    }
+    return "Unknown";
+  }
+
+  @Nullable
+  private String getSshKey() {
+    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
+  }
+
+  @Nullable
+  private String getGpgKeys() {
+    if (gpgKeys != null) {
+      return Joiner.on("\n").join(gpgKeys);
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
deleted file mode 100644
index 73a46a4..0000000
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ /dev/null
@@ -1,127 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.common.base.Joiner;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountSshKey;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.util.List;
-
-/** Sender that informs a user by email about the addition of an SSH or GPG key to their account. */
-public class AddKeySender extends OutgoingEmail {
-  public interface Factory {
-    AddKeySender create(IdentifiedUser user, AccountSshKey sshKey);
-
-    AddKeySender create(IdentifiedUser user, List<String> gpgKey);
-  }
-
-  private final IdentifiedUser user;
-  private final AccountSshKey sshKey;
-  private final List<String> gpgKeys;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public AddKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted AccountSshKey sshKey) {
-    super(args, "addkey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.sshKey = sshKey;
-    this.gpgKeys = null;
-  }
-
-  @AssistedInject
-  public AddKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted List<String> gpgKeys) {
-    super(args, "addkey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.sshKey = null;
-    this.gpgKeys = gpgKeys;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
-    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    if (sshKey == null && (gpgKeys == null || gpgKeys.isEmpty())) {
-      // Don't email if no keys were added.
-      return false;
-    }
-
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("AddKey"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("AddKeyHtml"));
-    }
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("email", getEmail());
-    soyContextEmailData.put("gpgKeys", getGpgKeys());
-    soyContextEmailData.put("keyType", getKeyType());
-    soyContextEmailData.put("sshKey", getSshKey());
-    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-
-  private String getKeyType() {
-    if (sshKey != null) {
-      return "SSH";
-    } else if (gpgKeys != null) {
-      return "GPG";
-    }
-    return "Unknown";
-  }
-
-  @Nullable
-  private String getSshKey() {
-    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
-  }
-
-  @Nullable
-  private String getGpgKeys() {
-    if (gpgKeys != null) {
-      return Joiner.on("\n").join(gpgKeys);
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
deleted file mode 100644
index f9ef199..0000000
--- a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Let users know of a new user in the attention set. */
-public class AddToAttentionSetSender extends AttentionSetSender {
-
-  public interface Factory extends ReplyToChangeSender.Factory<AddToAttentionSetSender> {
-    @Override
-    AddToAttentionSetSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public AddToAttentionSetSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "addToAttentionSet", project, changeId);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("AddToAttentionSet"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("AddToAttentionSetHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java
new file mode 100644
index 0000000..9f1a5a8
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetChangeEmailDecorator.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Base class for Attention Set email senders */
+public final class AttentionSetChangeEmailDecorator implements ChangeEmailDecorator {
+  public enum AttentionSetChange {
+    USER_ADDED,
+    USER_REMOVED
+  }
+
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  private Account.Id attentionSetUser;
+  private String reason;
+  private AttentionSetChange attentionSetChange;
+
+  public void setAttentionSetUser(Account.Id attentionSetUser) {
+    this.attentionSetUser = attentionSetUser;
+  }
+
+  public void setReason(String reason) {
+    this.reason = reason;
+  }
+
+  public void setAttentionSetChange(AttentionSetChange attentionSetChange) {
+    this.attentionSetChange = attentionSetChange;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyParam("attentionSetUser", email.getNameFor(attentionSetUser));
+    email.addSoyParam("reason", reason);
+
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.ccExistingReviewers();
+
+    switch (attentionSetChange) {
+      case USER_ADDED:
+        email.appendText(email.textTemplate("AddToAttentionSet"));
+        if (email.useHtml()) {
+          email.appendHtml(email.soyHtmlTemplate("AddToAttentionSetHtml"));
+        }
+        break;
+      case USER_REMOVED:
+        email.appendText(email.textTemplate("RemoveFromAttentionSet"));
+        if (email.useHtml()) {
+          email.appendHtml(email.soyHtmlTemplate("RemoveFromAttentionSetHtml"));
+        }
+        break;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
deleted file mode 100644
index d1ee4ee..0000000
--- a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
+++ /dev/null
@@ -1,55 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-
-/** Base class for Attention Set email senders */
-public abstract class AttentionSetSender extends ReplyToChangeSender {
-  private Account.Id attentionSetUser;
-  private String reason;
-
-  public AttentionSetSender(
-      EmailArguments args, String messageClass, Project.NameKey project, Change.Id changeId) {
-    super(args, messageClass, ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    ccExistingReviewers();
-  }
-
-  public void setAttentionSetUser(Account.Id attentionSetUser) {
-    this.attentionSetUser = attentionSetUser;
-  }
-
-  public void setReason(String reason) {
-    this.reason = reason;
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContext.put("attentionSetUser", getNameFor(attentionSetUser));
-    soyContext.put("reason", reason);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
index acba4ea..e26f83f 100644
--- a/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
+++ b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
@@ -24,7 +24,6 @@
 
 /** Contains utils for email notification related to the events on project+branch. */
 class BranchEmailUtils {
-
   /** Set a reasonable list id so that filters can be used to sort messages. */
   static void setListIdHeader(OutgoingEmail email, BranchNameKey branch) {
     email.setHeader(
@@ -37,25 +36,22 @@
 
   /** Add branch information to soy template params. */
   static void addBranchData(OutgoingEmail email, EmailArguments args, BranchNameKey branch) {
-    Map<String, Object> soyContext = email.getSoyContext();
-    Map<String, Object> soyContextEmailData = email.getSoyContextEmailData();
-
     String projectName = branch.project().get();
-    soyContext.put("projectName", projectName);
+    email.addSoyParam("projectName", projectName);
     // shortProjectName is the project name with the path abbreviated.
-    soyContext.put("shortProjectName", getShortProjectName(projectName));
+    email.addSoyParam("shortProjectName", getShortProjectName(projectName));
 
     // instanceAndProjectName is the instance's name followed by the abbreviated project path
-    soyContext.put(
+    email.addSoyParam(
         "instanceAndProjectName",
         getInstanceAndProjectName(args.instanceNameProvider.get(), projectName));
-    soyContext.put("addInstanceNameInSubject", args.addInstanceNameInSubject);
+    email.addSoyParam("addInstanceNameInSubject", args.addInstanceNameInSubject);
 
-    soyContextEmailData.put("sshHost", getSshHost(email.getGerritHost(), args.sshAddresses));
+    email.addSoyEmailDataParam("sshHost", getSshHost(email.getGerritHost(), args.sshAddresses));
 
     Map<String, String> branchData = new HashMap<>();
     branchData.put("shortName", branch.shortName());
-    soyContext.put("branch", branchData);
+    email.addSoyParam("branch", branchData);
 
     email.addFooter(MailHeader.PROJECT.withDelimiter() + branch.project().get());
     email.addFooter(MailHeader.BRANCH.withDelimiter() + branch.shortName());
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 62471ac..00c73a0 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -17,6 +17,8 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -33,7 +35,6 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -57,8 +58,6 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
-import java.net.URI;
-import java.net.URISyntaxException;
 import java.text.MessageFormat;
 import java.time.Instant;
 import java.util.Collection;
@@ -70,7 +69,6 @@
 import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.stream.Collectors;
-import org.apache.http.client.utils.URIBuilder;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.JGitText;
@@ -79,66 +77,109 @@
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
 
-/** Sends an email to one or more interested parties. */
-public abstract class ChangeEmail extends OutgoingEmail {
+// TODO: Remove ChangeEmail and rename this class once all usages are migrated to ChangeEmailNew.
+/** Populates an email for change related notifications. */
+@AutoFactory
+public final class ChangeEmail implements OutgoingEmail.EmailDecorator {
+
+  /** Implementations of params interface populate details specific to the notification type. */
+  public interface ChangeEmailDecorator {
+    /**
+     * Stores the reference to the {@link OutgoingEmail} and {@link ChangeEmail} for the subsequent
+     * calls.
+     *
+     * <p>Both init and populateEmailContent can be called multiply times in case of retries. Init
+     * is therefore responsible for clearing up any changes which are not idempotent and
+     * initializing data for use in populateEmailContent.
+     *
+     * <p>Can be used to adjust any of the behaviour of the {@link
+     * ChangeEmail#populateEmailContent}.
+     */
+    void init(OutgoingEmail email, ChangeEmail changeEmail) throws EmailException;
+
+    /**
+     * Populate headers, recipients and body of the email.
+     *
+     * <p>Method operates on the email provided in the init method.
+     *
+     * <p>By default, all the contents and parameters of the email should be set in this method.
+     */
+    void populateEmailContent() throws EmailException;
+
+    /** If returns false email is not sent to any recipients. */
+    default boolean shouldSendMessage() {
+      return true;
+    }
+  }
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  protected static ChangeData newChangeData(
-      EmailArguments ea, Project.NameKey project, Change.Id id) {
-    return ea.changeDataFactory.create(project, id);
-  }
-
-  protected static ChangeData newChangeData(
-      EmailArguments ea, Project.NameKey project, Change.Id id, ObjectId metaId) {
-    return ea.changeDataFactory.create(ea.changeNotesFactory.createChecked(project, id, metaId));
-  }
-
+  // Available after construction
+  private final EmailArguments args;
   private final Set<Account.Id> currentAttentionSet;
-  protected final Change change;
-  protected final ChangeData changeData;
-  protected ListMultimap<Account.Id, String> stars;
-  protected PatchSet patchSet;
-  protected PatchSetInfo patchSetInfo;
-  protected String changeMessage;
-  protected Instant timestamp;
-  protected BranchNameKey branch;
+  private final Change change;
+  private final ChangeData changeData;
+  private final BranchNameKey branch;
+  private final ChangeEmailDecorator changeEmailDecorator;
 
-  protected ProjectState projectState;
+  // Available after init or after being explicitly set.
+  private OutgoingEmail email;
+  private ListMultimap<Account.Id, String> stars;
+  private PatchSet patchSet;
+  private PatchSetInfo patchSetInfo;
+  private String changeMessage;
+  private String changeMessageThreadId;
+  private Instant timestamp;
+  private ProjectState projectState;
   private Set<Account.Id> authors;
   private boolean emailOnlyAuthors;
-  protected boolean emailOnlyAttentionSetIfEnabled;
+  private boolean emailOnlyAttentionSetIfEnabled;
   // Watchers ignore attention set rules.
-  protected Set<Account.Id> watcherAccounts = new HashSet<>();
+  private Set<Account.Id> watcherAccounts = new HashSet<>();
   // Watcher can only be an email if it's specified in notify section of ProjectConfig.
-  protected Set<Address> watcherEmails = new HashSet<>();
+  private Set<Address> watcherEmails = new HashSet<>();
+  private boolean isThreadReply = false;
 
-  protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
-    super(args, messageClass);
+  public ChangeEmail(
+      @Provided EmailArguments args,
+      ChangeData changeData,
+      ChangeEmailDecorator changeEmailDecorator) {
+    this.args = args;
     this.changeData = changeData;
     change = changeData.change();
     emailOnlyAuthors = false;
     emailOnlyAttentionSetIfEnabled = true;
     currentAttentionSet = getAttentionSet();
     branch = changeData.change().getDest();
+    this.changeEmailDecorator = changeEmailDecorator;
   }
 
-  @Override
-  public void setFrom(Account.Id id) {
-    super.setFrom(id);
+  public void markAsReply() {
+    isThreadReply = true;
+  }
 
-    // Is the from user in an email squelching group?
-    try {
-      args.permissionBackend.absentUser(id).check(GlobalPermission.EMAIL_REVIEWERS);
-    } catch (AuthException | PermissionBackendException e) {
-      emailOnlyAuthors = true;
-    }
+  public Change getChange() {
+    return change;
+  }
+
+  public ChangeData getChangeData() {
+    return changeData;
+  }
+
+  @Nullable
+  public Instant getTimestamp() {
+    return timestamp;
   }
 
   public void setPatchSet(PatchSet ps) {
     patchSet = ps;
   }
 
+  @Nullable
+  public PatchSet getPatchSet() {
+    return patchSet;
+  }
+
   public void setPatchSet(PatchSet ps, PatchSetInfo psi) {
     patchSet = ps;
     patchSetInfo = psi;
@@ -149,34 +190,33 @@
     timestamp = t;
   }
 
-  /** Format the message body by calling {@link #appendText(String)}. */
-  @Override
-  protected void format() throws EmailException {
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ChangeHeaderHtml"));
-    }
-    appendText(textTemplate("ChangeHeader"));
-    formatChange();
-    appendText(textTemplate("ChangeFooter"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ChangeFooterHtml"));
-    }
-    formatFooter();
+  public void setEmailOnlyAttentionSetIfEnabled(boolean value) {
+    emailOnlyAttentionSetIfEnabled = value;
   }
 
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void formatChange() throws EmailException;
-
-  /**
-   * Format the message footer by calling {@link #appendText(String)}.
-   *
-   * @throws EmailException if an error occurred.
-   */
-  protected void formatFooter() throws EmailException {}
-
-  /** Setup the message headers and envelope (TO, CC, BCC). */
   @Override
-  protected void init() throws EmailException {
+  public boolean shouldSendMessage() {
+    return changeEmailDecorator.shouldSendMessage();
+  }
+
+  @Override
+  public void init(OutgoingEmail email) throws EmailException {
+    this.email = email;
+
+    changeMessageThreadId =
+        String.format(
+            "<gerrit.%s.%s@%s>",
+            change.getCreatedOn().toEpochMilli(), change.getKey().get(), email.getGerritHost());
+
+    if (email.getFrom() != null) {
+      // Is the from user in an email squelching group?
+      try {
+        args.permissionBackend.absentUser(email.getFrom()).check(GlobalPermission.EMAIL_REVIEWERS);
+      } catch (AuthException | PermissionBackendException e) {
+        emailOnlyAuthors = true;
+      }
+    }
+
     if (args.projectCache != null) {
       projectState = args.projectCache.get(change.getProject()).orElse(null);
     } else {
@@ -192,7 +232,7 @@
     }
 
     if (patchSet != null) {
-      setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
+      email.setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
       if (patchSetInfo == null) {
         try {
           patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.id());
@@ -208,46 +248,34 @@
       throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
     }
 
-    super.init();
-    BranchEmailUtils.setListIdHeader(this, branch);
+    BranchEmailUtils.setListIdHeader(email, branch);
     if (timestamp != null) {
-      setHeader(FieldName.DATE, timestamp);
+      email.setHeader(FieldName.DATE, timestamp);
     }
-    setChangeSubjectHeader();
-    setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
-    setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
-    setHeader(MailHeader.PROJECT.fieldName(), "" + change.getProject());
+    email.setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
+    email.setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
+    email.setHeader(MailHeader.PROJECT.fieldName(), "" + change.getProject());
     setChangeUrlHeader();
     setCommitIdHeader();
 
-    if (notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)
-        || notify.handling().equals(NotifyHandling.ALL)) {
-      try {
-        changeData.reviewersByEmail().byState(ReviewerStateInternal.CC).stream()
-            .forEach(address -> addByEmail(RecipientType.CC, address));
-        changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).stream()
-            .forEach(address -> addByEmail(RecipientType.CC, address));
-      } catch (StorageException e) {
-        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
-      }
-    }
+    changeEmailDecorator.init(email, this);
   }
 
   private void setChangeUrlHeader() {
     final String u = getChangeUrl();
     if (u != null) {
-      setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
+      email.setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
     }
   }
 
   private void setCommitIdHeader() {
     if (patchSet != null) {
-      setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
+      email.setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
     }
   }
 
   private void setChangeSubjectHeader() {
-    setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
+    email.setHeader(FieldName.SUBJECT, email.textTemplate("ChangeSubject"));
   }
 
   private int getInsertionsCount() {
@@ -271,25 +299,19 @@
    */
   @Nullable
   public String getChangeUrl() {
-    Optional<String> changeUrl =
-        args.urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId());
-    if (!changeUrl.isPresent()) return null;
-    try {
-      URI uri = new URIBuilder(changeUrl.get()).addParameter("usp", "email").build();
-      return uri.toString();
-    } catch (URISyntaxException e) {
-      return null;
-    }
+    return args.urlFormatter
+        .get()
+        .getChangeViewUrl(change.getProject(), change.getId())
+        .map(EmailArguments::addUspParam)
+        .orElse(null);
   }
 
-  public String getChangeMessageThreadId() {
-    return "<gerrit."
-        + change.getCreatedOn().toEpochMilli()
-        + "."
-        + change.getKey().get()
-        + "@"
-        + getGerritHost()
-        + ">";
+  /** Sets headers for conversation grouping */
+  private void setThreadHeaders() {
+    if (isThreadReply) {
+      email.setHeader("In-Reply-To", changeMessageThreadId);
+    }
+    email.setHeader("References", changeMessageThreadId);
   }
 
   /** Get the text of the "cover letter". */
@@ -347,7 +369,7 @@
   }
 
   /** Get the patch list corresponding to patch set patchSetId of this change. */
-  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId) {
+  public Map<String, FileDiffOutput> listModifiedFiles(int patchSetId) {
     try {
       PatchSet ps;
       if (patchSetId == patchSet.number()) {
@@ -364,7 +386,7 @@
   }
 
   /** Get the patch list corresponding to this patch set. */
-  protected Map<String, FileDiffOutput> listModifiedFiles() {
+  public Map<String, FileDiffOutput> listModifiedFiles() {
     if (patchSet != null) {
       try {
         return args.diffOperations.listModifiedFilesAgainstParent(
@@ -379,37 +401,37 @@
   }
 
   /** Get the project entity the change is in; null if its been deleted. */
-  protected ProjectState getProjectState() {
+  public ProjectState getProjectState() {
     return projectState;
   }
 
   /** TO or CC all vested parties (change owner, patch set uploader, author). */
-  protected void addAuthors(RecipientType rt) {
+  public void addAuthors(RecipientType rt) {
     for (Account.Id id : getAuthors()) {
-      addByAccountId(rt, id);
+      email.addByAccountId(rt, id);
     }
   }
 
   /** BCC any user who has starred this change. */
-  protected void bccStarredBy() {
-    if (!NotifyHandling.ALL.equals(notify.handling())) {
+  public void bccStarredBy() {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())) {
       return;
     }
 
     for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
       if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
-        super.addByAccountId(RecipientType.BCC, e.getKey());
+        email.addByAccountId(RecipientType.BCC, e.getKey());
       }
     }
   }
 
   /** Include users and groups that want notification of events. */
-  protected void includeWatchers(NotifyType type) {
+  public void includeWatchers(NotifyType type) {
     includeWatchers(type, true);
   }
 
   /** Include users and groups that want notification of events. */
-  protected void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+  public void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
     try {
       Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
       addWatchers(RecipientType.TO, matching.to);
@@ -427,17 +449,17 @@
   private void addWatchers(RecipientType type, WatcherList watcherList) {
     watcherAccounts.addAll(watcherList.accounts);
     for (Account.Id user : watcherList.accounts) {
-      addByAccountId(type, user);
+      email.addByAccountId(type, user);
     }
 
     watcherEmails.addAll(watcherList.emails);
     for (Address addr : watcherList.emails) {
-      addByEmail(type, addr);
+      email.addByEmail(type, addr);
     }
   }
 
   private final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
-    if (!NotifyHandling.ALL.equals(notify.handling())) {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())) {
       return new Watchers();
     }
 
@@ -446,15 +468,15 @@
   }
 
   /** Any user who has published comments on this change. */
-  protected void ccAllApprovals() {
-    if (!NotifyHandling.ALL.equals(notify.handling())
-        && !NotifyHandling.OWNER_REVIEWERS.equals(notify.handling())) {
+  public void ccAllApprovals() {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())
+        && !NotifyHandling.OWNER_REVIEWERS.equals(email.getNotify().handling())) {
       return;
     }
 
     try {
       for (Account.Id id : changeData.reviewers().all()) {
-        addByAccountId(RecipientType.CC, id);
+        email.addByAccountId(RecipientType.CC, id);
       }
     } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
@@ -462,15 +484,15 @@
   }
 
   /** Users who were added as reviewers to this change. */
-  protected void ccExistingReviewers() {
-    if (!NotifyHandling.ALL.equals(notify.handling())
-        && !NotifyHandling.OWNER_REVIEWERS.equals(notify.handling())) {
+  public void ccExistingReviewers() {
+    if (!NotifyHandling.ALL.equals(email.getNotify().handling())
+        && !NotifyHandling.OWNER_REVIEWERS.equals(email.getNotify().handling())) {
       return;
     }
 
     try {
       for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
-        addByAccountId(RecipientType.CC, id);
+        email.addByAccountId(RecipientType.CC, id);
       }
     } catch (StorageException err) {
       logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
@@ -478,7 +500,7 @@
   }
 
   @Override
-  protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+  public boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
     if (!projectState.statePermitsRead()) {
       return false;
     }
@@ -499,7 +521,7 @@
   }
 
   @Override
-  protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
+  public boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
     if (!projectState.statePermitsRead()) {
       return false;
     }
@@ -517,18 +539,17 @@
         return false;
       }
     }
-
     return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
   }
 
   /** Lazily finds all users who are authors of any part of this change. */
-  protected Set<Account.Id> getAuthors() {
+  private Set<Account.Id> getAuthors() {
     if (this.authors != null) {
       return this.authors;
     }
     Set<Account.Id> authors = new HashSet<>();
 
-    switch (notify.handling()) {
+    switch (email.getNotify().handling()) {
       case NONE:
         break;
       case ALL:
@@ -555,20 +576,20 @@
   }
 
   @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    BranchEmailUtils.addBranchData(this, args, branch);
+  public void populateEmailContent() throws EmailException {
+    BranchEmailUtils.addBranchData(email, args, branch);
+    setThreadHeaders();
 
-    soyContext.put("changeId", change.getKey().get());
-    soyContext.put("coverLetter", getCoverLetter());
-    soyContext.put("fromName", getNameFor(fromId));
-    soyContext.put("fromEmail", getNameEmailFor(fromId));
-    soyContext.put("diffLines", getDiffTemplateData(getUnifiedDiff()));
+    email.addSoyParam("changeId", change.getKey().get());
+    email.addSoyParam("coverLetter", getCoverLetter());
+    email.addSoyParam("fromName", email.getNameFor(email.getFrom()));
+    email.addSoyParam("fromEmail", email.getNameEmailFor(email.getFrom()));
+    email.addSoyParam("diffLines", getDiffTemplateData(getUnifiedDiff()));
 
-    soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
-    soyContextEmailData.put("changeDetail", getChangeDetail());
-    soyContextEmailData.put("changeUrl", getChangeUrl());
-    soyContextEmailData.put("includeDiff", getIncludeDiff());
+    email.addSoyEmailDataParam("unifiedDiff", getUnifiedDiff());
+    email.addSoyEmailDataParam("changeDetail", getChangeDetail());
+    email.addSoyEmailDataParam("changeUrl", getChangeUrl());
+    email.addSoyEmailDataParam("includeDiff", getIncludeDiff());
 
     Map<String, String> changeData = new HashMap<>();
 
@@ -579,42 +600,65 @@
     changeData.put("shortSubject", shortenSubject(subject));
     changeData.put("shortOriginalSubject", shortenSubject(originalSubject));
 
-    changeData.put("ownerName", getNameFor(change.getOwner()));
-    changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
+    changeData.put("ownerName", email.getNameFor(change.getOwner()));
+    changeData.put("ownerEmail", email.getNameEmailFor(change.getOwner()));
     changeData.put("changeNumber", Integer.toString(change.getChangeId()));
     changeData.put(
         "sizeBucket",
         ChangeSizeBucket.getChangeSizeBucket(getInsertionsCount() + getDeletionsCount()));
-    soyContext.put("change", changeData);
+    email.addSoyParam("change", changeData);
 
     Map<String, Object> patchSetData = new HashMap<>();
     patchSetData.put("patchSetId", patchSet.number());
     patchSetData.put("refName", patchSet.refName());
-    soyContext.put("patchSet", patchSetData);
+    email.addSoyParam("patchSet", patchSetData);
 
     Map<String, Object> patchSetInfoData = new HashMap<>();
     patchSetInfoData.put("authorName", patchSetInfo.getAuthor().getName());
     patchSetInfoData.put("authorEmail", patchSetInfo.getAuthor().getEmail());
-    soyContext.put("patchSetInfo", patchSetInfoData);
+    email.addSoyParam("patchSetInfo", patchSetInfoData);
 
-    footers.add(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
-    footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
-    footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
-    footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
+    email.addFooter(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
+    email.addFooter(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
+    email.addFooter(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
+    email.addFooter(MailHeader.OWNER.withDelimiter() + email.getNameEmailFor(change.getOwner()));
     for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
-      footers.add(MailHeader.REVIEWER.withDelimiter() + reviewer);
+      email.addFooter(MailHeader.REVIEWER.withDelimiter() + reviewer);
     }
     for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
-      footers.add(MailHeader.CC.withDelimiter() + reviewer);
+      email.addFooter(MailHeader.CC.withDelimiter() + reviewer);
     }
     for (Account.Id attentionUser : currentAttentionSet) {
-      footers.add(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
+      email.addFooter(MailHeader.ATTENTION.withDelimiter() + email.getNameEmailFor(attentionUser));
     }
     if (!currentAttentionSet.isEmpty()) {
       // We need names rather than account ids / emails to make it user readable.
-      soyContext.put(
+      email.addSoyParam(
           "attentionSet",
-          currentAttentionSet.stream().map(this::getNameFor).sorted().collect(toImmutableList()));
+          currentAttentionSet.stream().map(email::getNameFor).sorted().collect(toImmutableList()));
+    }
+
+    setChangeSubjectHeader();
+    if (email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
+        || email.getNotify().handling().equals(NotifyHandling.ALL)) {
+      try {
+        this.changeData.reviewersByEmail().byState(ReviewerStateInternal.CC).stream()
+            .forEach(address -> email.addByEmail(RecipientType.CC, address));
+        this.changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).stream()
+            .forEach(address -> email.addByEmail(RecipientType.CC, address));
+      } catch (StorageException e) {
+        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
+      }
+    }
+
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("ChangeHeaderHtml"));
+    }
+    email.appendText(email.textTemplate("ChangeHeader"));
+    changeEmailDecorator.populateEmailContent();
+    email.appendText(email.textTemplate("ChangeFooter"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("ChangeFooterHtml"));
     }
   }
 
@@ -633,7 +677,7 @@
     Set<String> reviewers = new TreeSet<>();
     try {
       for (Account.Id who : changeData.reviewers().byState(state)) {
-        reviewers.add(getNameEmailFor(who));
+        reviewers.add(email.getNameEmailFor(who));
       }
     } catch (StorageException e) {
       logger.atWarning().withCause(e).log("Cannot get change reviewers");
@@ -708,8 +752,7 @@
    * @param sourceDiff the unified diff that we're converting to the map.
    * @return map of 'type' to a line's content.
    */
-  protected static ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(
-      String sourceDiff) {
+  public static ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(String sourceDiff) {
     ImmutableList.Builder<ImmutableMap<String, String>> result = ImmutableList.builder();
     Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
     for (String diffLine : lineSplitter.split(sourceDiff)) {
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java
similarity index 81%
rename from java/com/google/gerrit/server/mail/send/CommentSender.java
rename to java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java
index 3711ca2..48b2257 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentChangeEmailDecorator.java
@@ -18,6 +18,8 @@
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static java.util.stream.Collectors.toList;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Strings;
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
@@ -35,20 +37,19 @@
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
-import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.NoSuchEntityException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.mail.MailHeader;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.receive.Protocol;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.time.ZoneId;
 import java.time.ZonedDateTime;
@@ -66,19 +67,11 @@
 import org.eclipse.jgit.lib.Repository;
 
 /** Send comments, after the author of them hit used Publish Comments in the UI. */
-public class CommentSender extends ReplyToChangeSender {
+@AutoFactory
+public class CommentChangeEmailDecorator implements ChangeEmailDecorator {
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-
-    CommentSender create(
-        Project.NameKey project,
-        Change.Id changeId,
-        ObjectId preUpdateMetaId,
-        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
-  }
-
   private class FileCommentGroup {
 
     public String filename;
@@ -89,19 +82,31 @@
     /** Returns a web link to a comment for a change. */
     @Nullable
     public String getCommentLink(String uuid) {
-      return args.urlFormatter.get().getInlineCommentView(change, uuid).orElse(null);
+      return args.urlFormatter
+          .get()
+          .getInlineCommentView(changeEmail.getChange(), uuid)
+          .map(EmailArguments::addUspParam)
+          .orElse(null);
     }
 
     /** Returns a web link to the comment tab view of a change. */
     @Nullable
     public String getCommentsTabLink() {
-      return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
+      return args.urlFormatter
+          .get()
+          .getCommentsTabView(changeEmail.getChange())
+          .map(EmailArguments::addUspParam)
+          .orElse(null);
     }
 
     /** Returns a web link to the findings tab view of a change. */
     @Nullable
     public String getFindingsTabLink() {
-      return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
+      return args.urlFormatter
+          .get()
+          .getFindingsTabView(changeEmail.getChange())
+          .map(EmailArguments::addUspParam)
+          .orElse(null);
     }
 
     /**
@@ -120,6 +125,9 @@
     }
   }
 
+  private EmailArguments args;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
   private List<? extends Comment> inlineComments = Collections.emptyList();
   @Nullable private String patchSetComment;
   private ImmutableList<LabelVote> labels = ImmutableList.of();
@@ -130,17 +138,15 @@
       preUpdateSubmitRequirementResultsSupplier;
   private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
-  @Inject
-  public CommentSender(
-      EmailArguments args,
-      CommentsUtil commentsUtil,
-      @GerritServerConfig Config cfg,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
-      @Assisted ObjectId preUpdateMetaId,
-      @Assisted
-          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
-    super(args, "comment", newChangeData(args, project, changeId));
+  public CommentChangeEmailDecorator(
+      @Provided EmailArguments args,
+      @Provided CommentsUtil commentsUtil,
+      @Provided @GerritServerConfig Config cfg,
+      Project.NameKey project,
+      Change.Id changeId,
+      ObjectId preUpdateMetaId,
+      Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+    this.args = args;
     this.commentsUtil = commentsUtil;
     this.incomingEmailEnabled =
         cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
@@ -151,7 +157,7 @@
             () ->
                 // Triggers an (expensive) evaluation of the submit requirements. This is OK since
                 // all callers sent this email asynchronously, see EmailReviewComments.
-                newChangeData(args, project, changeId, preUpdateMetaId)
+                args.newChangeData(project, changeId, preUpdateMetaId)
                     .submitRequirementsIncludingLegacy());
     this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
   }
@@ -169,54 +175,31 @@
   }
 
   @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    if (notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)
-        || notify.handling().equals(NotifyHandling.ALL)) {
-      ccAllApprovals();
-    }
-    if (notify.handling().equals(NotifyHandling.ALL)) {
-      bccStarredBy();
-      includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
-    }
-
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
     // Add header that enables identifying comments on parsed email.
     // Grouping is currently done by timestamp.
-    setHeader(MailHeader.COMMENT_DATE.fieldName(), timestamp);
+    email.setHeader(MailHeader.COMMENT_DATE.fieldName(), changeEmail.getTimestamp());
 
     if (incomingEmailEnabled) {
       if (replyToAddress == null) {
         // Remove Reply-To and use outbound SMTP (default) instead.
-        removeHeader(FieldName.REPLY_TO);
+        email.removeHeader(FieldName.REPLY_TO);
       } else {
-        setHeader(FieldName.REPLY_TO, replyToAddress);
+        email.setHeader(FieldName.REPLY_TO, replyToAddress);
       }
     }
-  }
-
-  @Override
-  public void formatChange() throws EmailException {
-    appendText(textTemplate("Comment"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("CommentHtml"));
-    }
-  }
-
-  @Override
-  public void formatFooter() throws EmailException {
-    appendText(textTemplate("CommentFooter"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("CommentFooterHtml"));
-    }
+    changeEmail.markAsReply();
   }
 
   /**
    * Returns a list of FileCommentGroup objects representing the inline comments grouped by the
    * file.
    */
-  private List<CommentSender.FileCommentGroup> getGroupedInlineComments(Repository repo) {
-    List<CommentSender.FileCommentGroup> groups = new ArrayList<>();
+  private List<CommentChangeEmailDecorator.FileCommentGroup> getGroupedInlineComments(
+      Repository repo) {
+    List<CommentChangeEmailDecorator.FileCommentGroup> groups = new ArrayList<>();
 
     // Loop over the comments and collect them into groups based on the file
     // location of the comment.
@@ -230,7 +213,7 @@
         currentGroup.filename = c.key.filename;
         currentGroup.patchSetId = c.key.patchSetId;
         // Get the modified files:
-        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles(c.key.patchSetId);
+        Map<String, FileDiffOutput> modifiedFiles = changeEmail.listModifiedFiles(c.key.patchSetId);
 
         groups.add(currentGroup);
         if (modifiedFiles != null && !modifiedFiles.isEmpty()) {
@@ -241,7 +224,7 @@
                 "Cannot load %s from %s in %s",
                 c.key.filename,
                 modifiedFiles.values().iterator().next().newCommitId().name(),
-                projectState.getName());
+                changeEmail.getProjectState().getName());
             currentGroup.fileData = null;
           }
         }
@@ -326,7 +309,7 @@
     }
     Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
     try {
-      return commentsUtil.getPublishedHumanComment(changeData.notes(), key);
+      return commentsUtil.getPublishedHumanComment(changeEmail.getChangeData().notes(), key);
     } catch (StorageException e) {
       logger.atWarning().log("Could not find the parent of this comment: %s", child);
       return Optional.empty();
@@ -361,7 +344,7 @@
    * or the first line, or following the last period within the first 100 characters, whichever is
    * shorter. If the message is shortened, an ellipsis is appended.
    */
-  protected static String getShortenedCommentMessage(String message) {
+  static String getShortenedCommentMessage(String message) {
     int threshold = 100;
     String fullMessage = message.trim();
     String msg = fullMessage;
@@ -390,7 +373,7 @@
     return msg;
   }
 
-  protected static String getShortenedCommentMessage(Comment comment) {
+  static String getShortenedCommentMessage(Comment comment) {
     return getShortenedCommentMessage(comment.message);
   }
 
@@ -401,7 +384,7 @@
   private List<Map<String, Object>> getCommentGroupsTemplateData(Repository repo) {
     List<Map<String, Object>> commentGroups = new ArrayList<>();
 
-    for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
+    for (CommentChangeEmailDecorator.FileCommentGroup group : getGroupedInlineComments(repo)) {
       Map<String, Object> groupData = new HashMap<>();
       groupData.put("title", group.getTitle());
       groupData.put("patchSetId", group.patchSetId);
@@ -512,45 +495,66 @@
   @Nullable
   private Repository getRepository() {
     try {
-      return args.server.openRepository(projectState.getNameKey());
+      return args.server.openRepository(changeEmail.getProjectState().getNameKey());
     } catch (IOException e) {
       return null;
     }
   }
 
   @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
     boolean hasComments;
     try (Repository repo = getRepository()) {
       List<Map<String, Object>> files = getCommentGroupsTemplateData(repo);
-      soyContext.put("commentFiles", files);
+      email.addSoyParam("commentFiles", files);
       hasComments = !files.isEmpty();
     }
 
-    soyContext.put(
+    email.addSoyParam(
         "patchSetCommentBlocks", commentBlocksToSoyData(CommentFormatter.parse(patchSetComment)));
-    soyContext.put("labels", getLabelVoteSoyData(labels));
-    soyContext.put("commentCount", inlineComments.size());
-    soyContext.put("commentTimestamp", getCommentTimestamp());
-    soyContext.put(
-        "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
+    email.addSoyParam("labels", getLabelVoteSoyData(labels));
+    email.addSoyParam("commentCount", inlineComments.size());
+    email.addSoyParam("commentTimestamp", getCommentTimestamp());
+    email.addSoyParam(
+        "coverLetterBlocks",
+        commentBlocksToSoyData(CommentFormatter.parse(changeEmail.getCoverLetter())));
 
     if (isChangeNoLongerSubmittable()) {
-      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
-      soyContext.put(
+      email.addSoyParam("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      email.addSoyParam(
           "oldSubmitRequirements",
           formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
-      soyContext.put(
+      email.addSoyParam(
           "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
     }
 
-    footers.add(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
-    footers.add(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
-    footers.add(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
+    email.addFooter(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
+    email.addFooter(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
+    email.addFooter(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
 
     for (Account.Id account : getReplyAccounts()) {
-      footers.add(MailHeader.COMMENT_IN_REPLY_TO.withDelimiter() + getNameEmailFor(account));
+      email.addFooter(
+          MailHeader.COMMENT_IN_REPLY_TO.withDelimiter() + email.getNameEmailFor(account));
+    }
+
+    if (email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)
+        || email.getNotify().handling().equals(NotifyHandling.ALL)) {
+      changeEmail.ccAllApprovals();
+    }
+    if (email.getNotify().handling().equals(NotifyHandling.ALL)) {
+      changeEmail.bccStarredBy();
+      changeEmail.includeWatchers(
+          NotifyType.ALL_COMMENTS,
+          !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+    }
+
+    email.appendText(email.textTemplate("Comment"));
+    email.appendText(email.textTemplate("CommentFooter"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("CommentHtml"));
+      email.appendHtml(email.soyHtmlTemplate("CommentFooterHtml"));
     }
   }
 
@@ -566,7 +570,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s before the update is %s",
-        change.getId(), isSubmittablePreUpdate);
+        changeEmail.getChange().getId(), isSubmittablePreUpdate);
     if (!isSubmittablePreUpdate) {
       return false;
     }
@@ -576,7 +580,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s after the update is %s",
-        change.getId(), isSubmittablePostUpdate);
+        changeEmail.getChange().getId(), isSubmittablePostUpdate);
     return !isSubmittablePostUpdate;
   }
 
@@ -644,6 +648,6 @@
   private String getCommentTimestamp() {
     // Grouping is currently done by timestamp.
     return MailProcessingUtil.rfcDateformatter.format(
-        ZonedDateTime.ofInstant(timestamp, ZoneId.of("UTC")));
+        ZonedDateTime.ofInstant(changeEmail.getTimestamp(), ZoneId.of("UTC")));
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
deleted file mode 100644
index e327d4d..0000000
--- a/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Notify interested parties of a brand new change. */
-public class CreateChangeSender extends NewChangeSender {
-  public interface Factory {
-    CreateChangeSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public CreateChangeSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    includeWatchers(NotifyType.NEW_CHANGES, !change.isWorkInProgress() && !change.isPrivate());
-    includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java
new file mode 100644
index 0000000..b2228f5
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeyEmailDecorator.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import java.util.Collections;
+import java.util.List;
+
+/** Informs a user by email about the removal of an SSH or GPG key from their account. */
+@AutoFactory
+public class DeleteKeyEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final AccountSshKey sshKey;
+  private final List<String> gpgKeyFingerprints;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public DeleteKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, AccountSshKey sshKey) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.gpgKeyFingerprints = Collections.emptyList();
+    this.sshKey = sshKey;
+  }
+
+  public DeleteKeyEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator,
+      IdentifiedUser user,
+      List<String> gpgKeyFingerprints) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.gpgKeyFingerprints = gpgKeyFingerprints;
+    this.sshKey = null;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
+    email.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("gpgKeyFingerprints", getGpgKeyFingerprints());
+    email.addSoyEmailDataParam("keyType", getKeyType());
+    email.addSoyEmailDataParam("sshKey", getSshKey());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("sshKeysSettingsUrl", email.getSettingsUrl("ssh-keys"));
+    email.addSoyEmailDataParam("gpgKeysSettingsUrl", email.getSettingsUrl("gpg-keys"));
+
+    email.appendText(email.textTemplate("DeleteKey"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("DeleteKeyHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+
+  private String getKeyType() {
+    if (sshKey != null) {
+      return "SSH";
+    } else if (gpgKeyFingerprints != null) {
+      return "GPG";
+    }
+    throw new IllegalStateException("key type is not SSH or GPG");
+  }
+
+  @Nullable
+  private String getSshKey() {
+    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
+  }
+
+  @Nullable
+  private String getGpgKeyFingerprints() {
+    if (!gpgKeyFingerprints.isEmpty()) {
+      return Joiner.on("\n").join(gpgKeyFingerprints);
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
deleted file mode 100644
index 22c26b1..0000000
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ /dev/null
@@ -1,125 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.common.base.Joiner;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountSshKey;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Sender that informs a user by email about the removal of an SSH or GPG key from their account.
- */
-public class DeleteKeySender extends OutgoingEmail {
-  public interface Factory {
-    DeleteKeySender create(IdentifiedUser user, AccountSshKey sshKey);
-
-    DeleteKeySender create(IdentifiedUser user, List<String> gpgKeyFingerprints);
-  }
-
-  private final IdentifiedUser user;
-  private final AccountSshKey sshKey;
-  private final List<String> gpgKeyFingerprints;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public DeleteKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted AccountSshKey sshKey) {
-    super(args, "deletekey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.gpgKeyFingerprints = Collections.emptyList();
-    this.sshKey = sshKey;
-  }
-
-  @AssistedInject
-  public DeleteKeySender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted List<String> gpgKeyFingerprints) {
-    super(args, "deletekey");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.gpgKeyFingerprints = gpgKeyFingerprints;
-    this.sshKey = null;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
-    setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("DeleteKey"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("DeleteKeyHtml"));
-    }
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("email", getEmail());
-    soyContextEmailData.put("gpgKeyFingerprints", getGpgKeyFingerprints());
-    soyContextEmailData.put("keyType", getKeyType());
-    soyContextEmailData.put("sshKey", getSshKey());
-    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-
-  private String getKeyType() {
-    if (sshKey != null) {
-      return "SSH";
-    } else if (gpgKeyFingerprints != null) {
-      return "GPG";
-    }
-    throw new IllegalStateException("key type is not SSH or GPG");
-  }
-
-  @Nullable
-  private String getSshKey() {
-    return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
-  }
-
-  @Nullable
-  private String getGpgKeyFingerprints() {
-    if (!gpgKeyFingerprints.isEmpty()) {
-      return Joiner.on("\n").join(gpgKeyFingerprints);
-    }
-    return null;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java
new file mode 100644
index 0000000..5b8ac5d
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerChangeEmailDecorator.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Let users know that a reviewer and possibly her review have been removed. */
+public class DeleteReviewerChangeEmailDecorator implements ChangeEmailDecorator {
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
+
+  public void addReviewers(Collection<Account.Id> cc) {
+    reviewers.addAll(cc);
+  }
+
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
+  @Nullable
+  private List<String> getReviewerNames() {
+    if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : reviewers) {
+      names.add(email.getNameFor(id));
+    }
+    for (Address a : reviewersByEmail) {
+      names.add(a.toString());
+    }
+    return names;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("reviewerNames", getReviewerNames());
+
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.ccExistingReviewers();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+    reviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r));
+    reviewersByEmail.stream().forEach(address -> email.addByEmail(RecipientType.TO, address));
+
+    email.appendText(email.textTemplate("DeleteReviewer"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("DeleteReviewerHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
deleted file mode 100644
index 52a16ac..0000000
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Let users know that a reviewer and possibly her review have been removed. */
-public class DeleteReviewerSender extends ReplyToChangeSender {
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Address> reviewersByEmail = new HashSet<>();
-
-  public interface Factory extends ReplyToChangeSender.Factory<DeleteReviewerSender> {
-    @Override
-    DeleteReviewerSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public DeleteReviewerSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "deleteReviewer", newChangeData(args, project, changeId));
-  }
-
-  public void addReviewers(Collection<Account.Id> cc) {
-    reviewers.addAll(cc);
-  }
-
-  public void addReviewersByEmail(Collection<Address> cc) {
-    reviewersByEmail.addAll(cc);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    ccExistingReviewers();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-    reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r));
-    reviewersByEmail.stream().forEach(address -> addByEmail(RecipientType.TO, address));
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("DeleteReviewer"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("DeleteReviewerHtml"));
-    }
-  }
-
-  @Nullable
-  public List<String> getReviewerNames() {
-    if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : reviewers) {
-      names.add(getNameFor(id));
-    }
-    for (Address a : reviewersByEmail) {
-      names.add(a.toString());
-    }
-    return names;
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("reviewerNames", getReviewerNames());
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
new file mode 100644
index 0000000..873db91
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/DeleteVoteChangeEmailDecorator.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Send notice about a vote that was removed from a change. */
+public class DeleteVoteChangeEmailDecorator implements ChangeEmailDecorator {
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+
+    email.appendText(email.textTemplate("DeleteVote"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("DeleteVoteHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java b/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
deleted file mode 100644
index f71cc00..0000000
--- a/java/com/google/gerrit/server/mail/send/DeleteVoteSender.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a vote that was removed from a change. */
-public class DeleteVoteSender extends ReplyToChangeSender {
-  public interface Factory extends ReplyToChangeSender.Factory<DeleteVoteSender> {
-    @Override
-    DeleteVoteSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  protected DeleteVoteSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "deleteVote", newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("DeleteVote"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("DeleteVoteHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 96effc1..f77b2c4 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AnonymousUser;
@@ -49,18 +52,21 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.template.soy.jbcsrc.api.SoySauce;
+import java.net.URI;
+import java.net.URISyntaxException;
 import java.util.List;
+import org.apache.http.client.utils.URIBuilder;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /**
  * Arguments used for sending notification emails.
  *
- * <p>Notification emails are sent by out by {@link OutgoingEmail} and it's subclasses, so called
- * senders. To construct an email the sender class needs to get various other classes injected.
- * Instead of injecting these classes into the sender classes directly, they only get {@code
- * EmailArguments} injected and {@code EmailArguments} provides them all dependencies that they
- * need.
+ * <p>Notification emails are sent by out by {@link OutgoingEmail} . To construct an email class (or
+ * its decorators) needs to get various other classes injected. Instead of injecting these classes
+ * into the sender classes directly, they only get {@code EmailArguments} injected and {@code
+ * EmailArguments} provides them all dependencies that they need.
  *
  * <p>This class is public because plugins need access to it for sending emails.
  */
@@ -164,4 +170,24 @@
     this.currentUserProvider = currentUserProvider;
     this.retryHelper = retryHelper;
   }
+
+  /** Fetch ChangeData for the specified change. */
+  public ChangeData newChangeData(Project.NameKey project, Change.Id id) {
+    return changeDataFactory.create(project, id);
+  }
+
+  /** Fetch ChangeData for specified change and revision. */
+  public ChangeData newChangeData(Project.NameKey project, Change.Id id, ObjectId metaId) {
+    return changeDataFactory.create(changeNotesFactory.createChecked(project, id, metaId));
+  }
+
+  @Nullable
+  public static String addUspParam(String url) {
+    try {
+      URI uri = new URIBuilder(url).addParameter("usp", "email").build();
+      return uri.toString();
+    } catch (URISyntaxException e) {
+      return null;
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java
new file mode 100644
index 0000000..af265a6
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateEmailDecorator.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import com.google.gerrit.server.util.time.TimeUtil;
+
+/** Sender that informs a user by email that the HTTP password of their account was updated. */
+@AutoFactory
+public class HttpPasswordUpdateEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+
+  private final IdentifiedUser user;
+  private final String operation;
+  private final MessageIdGenerator messageIdGenerator;
+
+  public HttpPasswordUpdateEmailDecorator(
+      @Provided MessageIdGenerator messageIdGenerator, IdentifiedUser user, String operation) {
+    this.messageIdGenerator = messageIdGenerator;
+    this.user = user;
+    this.operation = operation;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
+    email.setMessageId(
+        messageIdGenerator.fromReasonAccountIdAndTimestamp(
+            "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
+    email.addByAccountId(RecipientType.TO, user.getAccountId());
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("email", getEmail());
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("operation", operation);
+    email.addSoyEmailDataParam("httpPasswordSettingsUrl", email.getSettingsUrl("http-password"));
+
+    email.appendText(email.textTemplate("HttpPasswordUpdate"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("HttpPasswordUpdateHtml"));
+    }
+  }
+
+  private String getEmail() {
+    return user.getAccount().preferredEmail();
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
deleted file mode 100644
index 5fb66bb..0000000
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-
-/** Sender that informs a user by email that the HTTP password of their account was updated. */
-public class HttpPasswordUpdateSender extends OutgoingEmail {
-  public interface Factory {
-    HttpPasswordUpdateSender create(IdentifiedUser user, String operation);
-  }
-
-  private final IdentifiedUser user;
-  private final String operation;
-  private final MessageIdGenerator messageIdGenerator;
-
-  @AssistedInject
-  public HttpPasswordUpdateSender(
-      EmailArguments args,
-      MessageIdGenerator messageIdGenerator,
-      @Assisted IdentifiedUser user,
-      @Assisted String operation) {
-    super(args, "HttpPasswordUpdate");
-    this.messageIdGenerator = messageIdGenerator;
-    this.user = user;
-    this.operation = operation;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", "[Gerrit Code Review] HTTP password was " + operation);
-    setMessageId(
-        messageIdGenerator.fromReasonAccountIdAndTimestamp(
-            "HTTP_password_change", user.getAccountId(), TimeUtil.now()));
-    addByAccountId(RecipientType.TO, user.getAccountId());
-  }
-
-  @Override
-  protected boolean shouldSendMessage() {
-    // Always send an email if the HTTP password is updated.
-    return true;
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("HttpPasswordUpdate"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("HttpPasswordUpdateHtml"));
-    }
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("email", getEmail());
-    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-    soyContextEmailData.put("operation", operation);
-  }
-
-  private String getEmail() {
-    return user.getAccount().preferredEmail();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java
new file mode 100644
index 0000000..1f8fd78
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionEmailDecorator.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.mail.MailHeader;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+import org.apache.james.mime4j.dom.field.FieldName;
+
+/** Send an email to inform users that parsing their inbound email failed. */
+public class InboundEmailRejectionEmailDecorator implements EmailDecorator {
+
+  /** Used by the templating system to determine what error message should be sent */
+  public enum InboundEmailError {
+    PARSING_ERROR,
+    INACTIVE_ACCOUNT,
+    UNKNOWN_ACCOUNT,
+    INTERNAL_EXCEPTION,
+    COMMENT_REJECTED,
+    CHANGE_NOT_FOUND
+  }
+
+  private OutgoingEmail email;
+  private final Address to;
+  private final InboundEmailError reason;
+  private final String threadId;
+
+  public InboundEmailRejectionEmailDecorator(
+      Address to, String threadId, InboundEmailError reason) {
+    this.to = requireNonNull(to);
+    this.threadId = requireNonNull(threadId);
+    this.reason = requireNonNull(reason);
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    setListIdHeader();
+    email.setHeader(FieldName.SUBJECT, "[Gerrit Code Review] Unable to process your email");
+
+    if (!threadId.isEmpty()) {
+      email.setHeader(MailHeader.REFERENCES.fieldName(), threadId);
+    }
+  }
+
+  private void setListIdHeader() {
+    // Set a reasonable list id so that filters can be used to sort messages
+    email.setHeader("List-Id", "<gerrit-noreply." + email.getGerritHost() + ">");
+    if (email.getSettingsUrl() != null) {
+      email.setHeader("List-Unsubscribe", "<" + email.getSettingsUrl() + ">");
+    }
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addByEmail(RecipientType.TO, to);
+
+    email.appendText(email.textTemplate("InboundEmailRejection_" + reason.name()));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("InboundEmailRejectionHtml_" + reason.name()));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
deleted file mode 100644
index 0ddb0ad..0000000
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ /dev/null
@@ -1,94 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.MailHeader;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import org.apache.james.mime4j.dom.field.FieldName;
-
-/** Send an email to inform users that parsing their inbound email failed. */
-public class InboundEmailRejectionSender extends OutgoingEmail {
-
-  /** Used by the templating system to determine what error message should be sent */
-  public enum InboundEmailError {
-    PARSING_ERROR,
-    INACTIVE_ACCOUNT,
-    UNKNOWN_ACCOUNT,
-    INTERNAL_EXCEPTION,
-    COMMENT_REJECTED,
-    CHANGE_NOT_FOUND
-  }
-
-  public interface Factory {
-    InboundEmailRejectionSender create(Address to, String threadId, InboundEmailError reason);
-  }
-
-  private final Address to;
-  private final InboundEmailError reason;
-  private final String threadId;
-
-  @Inject
-  public InboundEmailRejectionSender(
-      EmailArguments args,
-      @Assisted Address to,
-      @Assisted String threadId,
-      @Assisted InboundEmailError reason) {
-    super(args, "error");
-    this.to = requireNonNull(to);
-    this.threadId = requireNonNull(threadId);
-    this.reason = requireNonNull(reason);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setListIdHeader();
-    setHeader(FieldName.SUBJECT, "[Gerrit Code Review] Unable to process your email");
-
-    addByEmail(RecipientType.TO, to);
-
-    if (!threadId.isEmpty()) {
-      setHeader(MailHeader.REFERENCES.fieldName(), threadId);
-    }
-  }
-
-  private void setListIdHeader() {
-    // Set a reasonable list id so that filters can be used to sort messages
-    setHeader("List-Id", "<gerrit-noreply." + getGerritHost() + ">");
-    if (getSettingsUrl() != null) {
-      setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
-    }
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("InboundEmailRejection_" + reason.name()));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("InboundEmailRejectionHtml_" + reason.name()));
-    }
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    footers.add(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index 0eaafb8..7bc319f 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -65,6 +65,8 @@
     "DeleteReviewerHtml.soy",
     "DeleteVote.soy",
     "DeleteVoteHtml.soy",
+    "Email.soy",
+    "EmailHtml.soy",
     "InboundEmailRejection.soy",
     "InboundEmailRejectionHtml.soy",
     "Footer.soy",
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
similarity index 60%
rename from java/com/google/gerrit/server/mail/send/MergedSender.java
rename to java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
index ce2e3dc..90c8b93 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedChangeEmailDecorator.java
@@ -14,84 +14,57 @@
 
 package com.google.gerrit.server.mail.send;
 
-import static com.google.common.base.Preconditions.checkNotNull;
-
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.LabelValue;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 import java.util.Optional;
 
 /** Send notice about a change successfully merged. */
-public class MergedSender extends ReplyToChangeSender {
+@AutoFactory
+public class MergedChangeEmailDecorator implements ChangeEmailDecorator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    MergedSender create(
-        Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff);
-  }
-
-  private final LabelTypes labelTypes;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+  private LabelTypes labelTypes;
+  private final EmailArguments args;
   private final Optional<String> stickyApprovalDiff;
 
-  @Inject
-  public MergedSender(
-      EmailArguments args,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
-      @Assisted Optional<String> stickyApprovalDiff) {
-    super(args, "merged", newChangeData(args, project, changeId));
-    labelTypes = changeData.getLabelTypes();
+  MergedChangeEmailDecorator(@Provided EmailArguments args, Optional<String> stickyApprovalDiff) {
+    this.args = args;
     this.stickyApprovalDiff = stickyApprovalDiff;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+    labelTypes = changeEmail.getChangeData().getLabelTypes();
+
     // We want to send the submit email even if the "send only when in attention set" is enabled.
-    emailOnlyAttentionSetIfEnabled = false;
-  }
+    changeEmail.setEmailOnlyAttentionSetIfEnabled(false);
 
-  @Override
-  public void setNotify(NotifyResolver.Result notify) {
-    checkNotNull(notify);
-    if (!stickyApprovalDiff.isEmpty()) {
-      if (!notify.handling().equals(NotifyHandling.ALL)) {
-        logger.atFine().log(
-            "Requested to notify %s, but for change submission with sticky approval diff,"
-                + " Notify=ALL is enforced.",
-            notify.handling().name());
-      }
-      this.notify = NotifyResolver.Result.create(NotifyHandling.ALL, notify.accounts());
-    } else {
-      this.notify = notify;
-    }
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-    includeWatchers(NotifyType.SUBMITTED_CHANGES);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Merged"));
-
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("MergedHtml"));
+    NotifyResolver.Result notify = email.getNotify();
+    if (!stickyApprovalDiff.isEmpty() && !notify.handling().equals(NotifyHandling.ALL)) {
+      logger.atFine().log(
+          "Requested to notify %s, but for change submission with sticky approval diff,"
+              + " Notify=ALL is enforced.",
+          notify.handling().name());
+      email.setNotify(NotifyResolver.Result.create(NotifyHandling.ALL, notify.accounts()));
     }
   }
 
@@ -99,7 +72,9 @@
     try {
       Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
-      for (PatchSetApproval ca : args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id())) {
+      for (PatchSetApproval ca :
+          args.approvalsUtil.byPatchSet(
+              changeEmail.getChangeData().notes(), changeEmail.getPatchSet().id())) {
         Optional<LabelType> lt = labelTypes.byLabel(ca.labelId());
         if (!lt.isPresent()) {
           continue;
@@ -126,7 +101,7 @@
     txt.append(type).append(":\n");
     for (Account.Id id : approvals.rowKeySet()) {
       txt.append("  ");
-      txt.append(getNameFor(id));
+      txt.append(email.getNameFor(id));
       txt.append(": ");
       boolean first = true;
       for (LabelType lt : labelTypes.getLabelTypes()) {
@@ -157,13 +132,24 @@
   }
 
   @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("approvals", getApprovals());
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("approvals", getApprovals());
     if (stickyApprovalDiff.isPresent()) {
-      soyContextEmailData.put("stickyApprovalDiff", stickyApprovalDiff.get());
-      soyContextEmailData.put(
-          "stickyApprovalDiffHtml", getDiffTemplateData(stickyApprovalDiff.get()));
+      email.addSoyEmailDataParam("stickyApprovalDiff", stickyApprovalDiff.get());
+      email.addSoyEmailDataParam(
+          "stickyApprovalDiffHtml", ChangeEmail.getDiffTemplateData(stickyApprovalDiff.get()));
+    }
+
+    changeEmail.addAuthors(RecipientType.TO);
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+    changeEmail.includeWatchers(NotifyType.SUBMITTED_CHANGES);
+
+    email.appendText(email.textTemplate("Merged"));
+
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("MergedHtml"));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
deleted file mode 100644
index dcf3b6c..0000000
--- a/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Asks a user to review a change. */
-public class ModifyReviewerSender extends NewChangeSender {
-  public interface Factory {
-    ModifyReviewerSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public ModifyReviewerSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccExistingReviewers();
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
deleted file mode 100644
index aabf7ca..0000000
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ /dev/null
@@ -1,134 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.query.change.ChangeData;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-/** Sends an email alerting a user to a new change for them to review. */
-public abstract class NewChangeSender extends ChangeEmail {
-  private final Set<Account.Id> reviewers = new HashSet<>();
-  private final Set<Address> reviewersByEmail = new HashSet<>();
-  private final Set<Account.Id> extraCC = new HashSet<>();
-  private final Set<Address> extraCCByEmail = new HashSet<>();
-  private final Set<Account.Id> removedReviewers = new HashSet<>();
-  private final Set<Address> removedByEmailReviewers = new HashSet<>();
-
-  protected NewChangeSender(EmailArguments args, ChangeData changeData) {
-    super(args, "newchange", changeData);
-  }
-
-  public void addReviewers(Collection<Account.Id> cc) {
-    reviewers.addAll(cc);
-  }
-
-  public void addReviewersByEmail(Collection<Address> cc) {
-    reviewersByEmail.addAll(cc);
-  }
-
-  public void addExtraCC(Collection<Account.Id> cc) {
-    extraCC.addAll(cc);
-  }
-
-  public void addExtraCCByEmail(Collection<Address> cc) {
-    extraCCByEmail.addAll(cc);
-  }
-
-  public void addRemovedReviewers(Collection<Account.Id> removed) {
-    removedReviewers.addAll(removed);
-  }
-
-  public void addRemovedByEmailReviewers(Collection<Address> removed) {
-    removedByEmailReviewers.addAll(removed);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    String threadId = getChangeMessageThreadId();
-    setHeader("References", threadId);
-
-    switch (notify.handling()) {
-      case NONE:
-      case OWNER:
-        break;
-      case ALL:
-      default:
-        extraCC.stream().forEach(cc -> addByAccountId(RecipientType.CC, cc));
-        extraCCByEmail.stream().forEach(cc -> addByEmail(RecipientType.CC, cc));
-        // $FALL-THROUGH$
-      case OWNER_REVIEWERS:
-        reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
-        reviewersByEmail.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
-        removedReviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
-        removedByEmailReviewers.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
-        break;
-    }
-
-    addAuthors(RecipientType.CC);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("NewChange"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("NewChangeHtml"));
-    }
-  }
-
-  @Nullable
-  private List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : reviewers) {
-      names.add(getNameFor(id));
-    }
-    return names;
-  }
-
-  @Nullable
-  private List<String> getRemovedReviewerNames() {
-    if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
-      return null;
-    }
-    List<String> names = new ArrayList<>();
-    for (Account.Id id : removedReviewers) {
-      names.add(getNameFor(id));
-    }
-    for (Address address : removedByEmailReviewers) {
-      names.add(address.toString());
-    }
-    return names;
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContext.put("ownerName", getNameFor(change.getOwner()));
-    soyContextEmailData.put("reviewerNames", getReviewerNames());
-    soyContextEmailData.put("removedReviewerNames", getRemovedReviewerNames());
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index aba8f62..afdcbad 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -18,6 +18,8 @@
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
 import static java.util.Objects.requireNonNull;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -39,6 +41,7 @@
 import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
+import com.google.template.soy.data.SanitizedContent;
 import com.google.template.soy.jbcsrc.api.SoySauce;
 import java.net.MalformedURLException;
 import java.net.URL;
@@ -57,45 +60,125 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.util.SystemReader;
 
-/** Sends an email to one or more interested parties. */
-public abstract class OutgoingEmail {
+/** Represents an email notification for some event that can be sent to interested parties. */
+@AutoFactory
+public final class OutgoingEmail {
+
+  /** Provides content, recipients and any customizations of the email. */
+  public interface EmailDecorator {
+    /**
+     * Stores the reference to the email for the subsequent calls.
+     *
+     * <p>Both init and populateEmailContent can be called multiply times in case of retries. Init
+     * is therefore responsible for clearing up any changes which are not idempotent and
+     * initializing data for use in populateEmailContent.
+     *
+     * <p>Can be used to adjust any of the behaviour of the {@link
+     * OutgoingEmail#populateEmailContent}.
+     */
+    void init(OutgoingEmail email) throws EmailException;
+
+    /**
+     * Populate headers, recipients and body of the email.
+     *
+     * <p>Method operates on the email provided in the init method.
+     *
+     * <p>By default, all the contents and parameters of the email should be set in this method.
+     */
+    void populateEmailContent() throws EmailException;
+
+    /** If returns false email is not sent to any recipients. */
+    default boolean shouldSendMessage() {
+      return true;
+    }
+
+    /**
+     * Evaluates whether account can be added to the list of recipients.
+     *
+     * @param rcpt the recipient for which it should be checker whether it can be added to the list
+     *     of recipients
+     * @throws PermissionBackendException thrown if checking permissions fails
+     */
+    default boolean isRecipientAllowed(Account.Id rcpt) throws PermissionBackendException {
+      return true;
+    }
+
+    /**
+     * Evaluates whether email can be added to the list of recipients.
+     *
+     * @param rcpt the recipient for which it should be checker whether it can be added to the list
+     *     of recipients
+     * @throws PermissionBackendException thrown if checking permissions fails
+     */
+    default boolean isRecipientAllowed(Address rcpt) throws PermissionBackendException {
+      return true;
+    }
+  }
+
   private static final String SOY_TEMPLATE_NAMESPACE = "com.google.gerrit.server.mail.template";
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  protected String messageClass;
+  private String messageClass;
   private final Set<Account.Id> rcptTo = new HashSet<>();
-  private final Map<String, EmailHeader> headers;
+  private final Map<String, EmailHeader> headers = new LinkedHashMap<>();
   private final Set<Address> smtpRcptTo = new HashSet<>();
   private final Set<Address> smtpBccRcptTo = new HashSet<>();
   private Address smtpFromAddress;
   private StringBuilder textBody;
-  private StringBuilder htmlBody;
+  private ArrayList<SanitizedContent> htmlBodySections;
   private MessageIdGenerator.MessageId messageId;
-  protected Map<String, Object> soyContext;
-  protected Map<String, Object> soyContextEmailData;
-  protected List<String> footers;
-  protected final EmailArguments args;
-  protected Account.Id fromId;
-  protected NotifyResolver.Result notify = NotifyResolver.Result.all();
+  private Map<String, Object> soyContext;
+  private Map<String, Object> soyContextEmailData;
+  private List<String> footers;
+  private final EmailArguments args;
+  private Account.Id fromId;
+  private NotifyResolver.Result notify = NotifyResolver.Result.all();
+  private final EmailDecorator templateProvider;
 
-  protected OutgoingEmail(EmailArguments args, String messageClass) {
+  public OutgoingEmail(
+      @Provided EmailArguments args, String messageClass, EmailDecorator templateProvider) {
     this.args = args;
     this.messageClass = messageClass;
-    this.headers = new LinkedHashMap<>();
+    this.templateProvider = templateProvider;
   }
 
+  /** Specify the account that triggered the notification. */
   public void setFrom(Account.Id id) {
     fromId = id;
   }
 
+  /** Get the account that triggered the notification. */
+  public Account.Id getFrom() {
+    return fromId;
+  }
+
+  /** Set how widely the email notification is allowed to be sent. */
   public void setNotify(NotifyResolver.Result notify) {
     this.notify = requireNonNull(notify);
   }
 
+  /** Returns the setting that controls how widely the email notification is allowed to be sent. */
+  public NotifyResolver.Result getNotify() {
+    return this.notify;
+  }
+
+  /** Set identifier for the email. Every email must have one. */
   public void setMessageId(MessageIdGenerator.MessageId messageId) {
     this.messageId = messageId;
   }
 
+  private String constructTextEmail() {
+    soyContext.put("body", textBody.toString());
+    soyContext.put("footer", textTemplate("Footer"));
+    return textTemplate("Email");
+  }
+
+  private String constructHtmlEmail() {
+    soyContext.put("body_sections_html", htmlBodySections);
+    soyContext.put("footer_html", soyHtmlTemplate("FooterHtml"));
+    return soyHtmlTemplate("EmailHtml").toString();
+  }
+
   /** Format and enqueue the message for delivery. */
   public void send() throws EmailException {
     try {
@@ -125,20 +208,15 @@
       return;
     }
 
+    init();
     if (!notify.shouldNotify()) {
       logger.atFine().log("Not sending '%s': Notify handling is NONE", messageClass);
       return;
     }
-
-    init();
+    populateEmailContent();
     if (messageId == null) {
       throw new IllegalStateException("All emails must have a messageId");
     }
-    format();
-    appendText(textTemplate("Footer"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("FooterHtml"));
-    }
 
     Set<Address> smtpRcptToPlaintextOnly = new HashSet<>();
     if (shouldSendMessage()) {
@@ -238,16 +316,15 @@
         setHeader(FieldName.REPLY_TO, j.toString());
       }
 
-      String textPart = textBody.toString();
       OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args();
       va.messageClass = messageClass;
       va.smtpFromAddress = smtpFromAddress;
       va.smtpRcptTo = smtpRcptTo;
       va.headers = headers;
-      va.body = textPart;
+      va.body = constructTextEmail();
 
       if (useHtml()) {
-        va.htmlBody = htmlBody.toString();
+        va.htmlBody = constructHtmlEmail();
       } else {
         va.htmlBody = null;
       }
@@ -275,7 +352,7 @@
         shallowCopy.remove(FieldName.CC);
         for (Address a : smtpRcptToPlaintextOnly) {
           // Add new To
-          EmailHeader.AddressList to = new EmailHeader.AddressList();
+          AddressList to = new AddressList();
           to.add(a);
           shallowCopy.put(FieldName.TO, to);
         }
@@ -311,39 +388,36 @@
     }
   }
 
-  /** Format the message body by calling {@link #appendText(String)}. */
-  protected abstract void format() throws EmailException;
-
   /**
    * Setup the message headers and envelope (TO, CC, BCC).
    *
    * @throws EmailException if an error occurred.
    */
-  protected void init() throws EmailException {
-    setupSoyContext();
+  public void init() throws EmailException {
+    soyContext = new HashMap<>();
+    footers = new ArrayList<>();
+    soyContextEmailData = new HashMap<>();
 
     smtpFromAddress = args.fromAddressGenerator.get().from(fromId);
     setHeader(FieldName.DATE, Instant.now());
-    headers.put(FieldName.FROM, new EmailHeader.AddressList(smtpFromAddress));
-    headers.put(FieldName.TO, new EmailHeader.AddressList());
-    headers.put(FieldName.CC, new EmailHeader.AddressList());
+    headers.put(FieldName.FROM, new AddressList(smtpFromAddress));
+    headers.put(FieldName.TO, new AddressList());
+    headers.put(FieldName.CC, new AddressList());
     setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
 
-    for (RecipientType recipientType : notify.accounts().keySet()) {
-      notify.accounts().get(recipientType).stream().forEach(a -> addByAccountId(recipientType, a));
-    }
-
     setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
-    footers.add(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
+    addFooter(MailHeader.MESSAGE_TYPE.withDelimiter() + messageClass);
     textBody = new StringBuilder();
-    htmlBody = new StringBuilder();
+    htmlBodySections = new ArrayList<>();
 
     if (fromId != null && args.fromAddressGenerator.get().isGenericAddress(fromId)) {
       appendText(getFromLine());
     }
+
+    templateProvider.init(this);
   }
 
-  protected String getFromLine() {
+  private String getFromLine() {
     StringBuilder f = new StringBuilder();
     Optional<Account> account = args.accountCache.get(fromId).map(AccountState::account);
     if (account.isPresent()) {
@@ -364,9 +438,10 @@
   }
 
   public String getGerritHost() {
-    if (getGerritUrl() != null) {
+    Optional<String> gerritUrl = args.urlFormatter.get().getWebUrl();
+    if (gerritUrl.isPresent()) {
       try {
-        return new URL(getGerritUrl()).getHost();
+        return new URL(gerritUrl.get()).getHost();
       } catch (MalformedURLException e) {
         // Try something else.
       }
@@ -381,44 +456,49 @@
 
   @Nullable
   public String getSettingsUrl() {
-    return args.urlFormatter.get().getSettingsUrl().orElse(null);
+    return args.urlFormatter.get().getSettingsUrl().map(EmailArguments::addUspParam).orElse(null);
   }
 
   @Nullable
-  private String getGerritUrl() {
-    return args.urlFormatter.get().getWebUrl().orElse(null);
+  public String getSettingsUrl(String section) {
+    return args.urlFormatter
+        .get()
+        .getSettingsUrl(section)
+        .map(EmailArguments::addUspParam)
+        .orElse(null);
   }
 
   /** Set a header in the outgoing message. */
-  protected void setHeader(String name, String value) {
+  public void setHeader(String name, String value) {
     headers.put(name, new StringEmailHeader(value));
   }
 
   /** Remove a header from the outgoing message. */
-  protected void removeHeader(String name) {
+  public void removeHeader(String name) {
     headers.remove(name);
   }
 
-  protected void setHeader(String name, Instant date) {
+  /** Set a date header in the outgoing message. */
+  public void setHeader(String name, Instant date) {
     headers.put(name, new EmailHeader.Date(date));
   }
 
   /** Append text to the outgoing email body. */
-  protected void appendText(String text) {
+  public void appendText(String text) {
     if (text != null) {
       textBody.append(text);
     }
   }
 
   /** Append html to the outgoing email body. */
-  protected void appendHtml(String html) {
+  public void appendHtml(SanitizedContent html) {
     if (html != null) {
-      htmlBody.append(html);
+      htmlBodySections.add(html);
     }
   }
 
   /** Lookup a human readable name for an account, usually the "full name". */
-  protected String getNameFor(@Nullable Account.Id accountId) {
+  public String getNameFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       return args.gerritPersonIdent.get().getName();
     }
@@ -444,7 +524,7 @@
    * @param accountId user to fetch.
    * @return name/email of account, or Anonymous Coward if unset.
    */
-  protected String getNameEmailFor(@Nullable Account.Id accountId) {
+  public String getNameEmailFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       PersonIdent gerritIdent = args.gerritPersonIdent.get();
       return gerritIdent.getName() + " <" + gerritIdent.getEmailAddress() + ">";
@@ -473,7 +553,7 @@
    * @return name/email of account, username, or null if unset or the accountId is null.
    */
   @Nullable
-  protected String getUserNameEmailFor(@Nullable Account.Id accountId) {
+  public String getUserNameEmailFor(@Nullable Account.Id accountId) {
     if (accountId == null) {
       return null;
     }
@@ -496,7 +576,7 @@
     return accountState.get().userName().orElse(null);
   }
 
-  protected boolean shouldSendMessage() {
+  private boolean shouldSendMessage() {
     if (textBody.length() == 0) {
       // If we have no message body, don't send.
       logger.atFine().log("Not sending '%s': No message body", messageClass);
@@ -521,7 +601,7 @@
       return false;
     }
 
-    return true;
+    return templateProvider.shouldSendMessage();
   }
 
   /**
@@ -559,8 +639,8 @@
    * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
    *     permission backend
    */
-  protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
-    return true;
+  public boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+    return templateProvider.isRecipientAllowed(addr);
   }
 
   /**
@@ -569,7 +649,7 @@
    * @param rt category of recipient (TO, CC, BCC)
    * @param to Gerrit Account of the recipient.
    */
-  protected void addByAccountId(RecipientType rt, Account.Id to) {
+  public void addByAccountId(RecipientType rt, Account.Id to) {
     addByAccountId(rt, to, false);
   }
 
@@ -581,7 +661,7 @@
    * @param override if the recipient was added previously and override is false no change is made
    *     regardless of {@code rt}.
    */
-  protected void addByAccountId(RecipientType rt, Account.Id to, boolean override) {
+  public void addByAccountId(RecipientType rt, Account.Id to, boolean override) {
     try {
       if (!rcptTo.contains(to) && isRecipientAllowed(to)) {
         rcptTo.add(to);
@@ -599,8 +679,8 @@
    * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
    *     permission backend
    */
-  protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
-    return true;
+  public boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
+    return templateProvider.isRecipientAllowed(to);
   }
 
   private final void add(RecipientType rt, Address addr, boolean override) {
@@ -612,16 +692,16 @@
           if (!override) {
             return;
           }
-          ((EmailHeader.AddressList) headers.get(FieldName.TO)).remove(addr.email());
-          ((EmailHeader.AddressList) headers.get(FieldName.CC)).remove(addr.email());
+          ((AddressList) headers.get(FieldName.TO)).remove(addr.email());
+          ((AddressList) headers.get(FieldName.CC)).remove(addr.email());
           smtpBccRcptTo.remove(addr);
         }
         switch (rt) {
           case TO:
-            ((EmailHeader.AddressList) headers.get(FieldName.TO)).add(addr);
+            ((AddressList) headers.get(FieldName.TO)).add(addr);
             break;
           case CC:
-            ((EmailHeader.AddressList) headers.get(FieldName.CC)).add(addr);
+            ((AddressList) headers.get(FieldName.CC)).add(addr);
             break;
           case BCC:
             smtpBccRcptTo.add(addr);
@@ -646,31 +726,30 @@
     return Address.create(account.fullName(), e);
   }
 
-  protected void setupSoyContext() {
-    soyContext = new HashMap<>();
-    footers = new ArrayList<>();
+  /** Set recipients, headers, body of the email. */
+  public void populateEmailContent() throws EmailException {
+    for (RecipientType recipientType : notify.accounts().keySet()) {
+      notify.accounts().get(recipientType).stream().forEach(a -> addByAccountId(recipientType, a));
+    }
 
-    soyContext.put("messageClass", messageClass);
-    soyContext.put("footers", footers);
+    addSoyParam("messageClass", messageClass);
+    addSoyParam("footers", footers);
+    addSoyEmailDataParam("settingsUrl", getSettingsUrl());
+    addSoyEmailDataParam("instanceName", getInstanceName());
+    addSoyEmailDataParam("gerritHost", getGerritHost());
+    addSoyParam("email", soyContextEmailData);
 
-    soyContextEmailData = new HashMap<>();
-    soyContextEmailData.put("settingsUrl", getSettingsUrl());
-    soyContextEmailData.put("instanceName", getInstanceName());
-    soyContextEmailData.put("gerritHost", getGerritHost());
-    soyContextEmailData.put("gerritUrl", getGerritUrl());
-    soyContext.put("email", soyContextEmailData);
+    templateProvider.populateEmailContent();
   }
 
-  /** Mutable map of parameters passed into email templates when rendering. */
-  public Map<String, Object> getSoyContext() {
-    return this.soyContext;
+  /** Adds param to the data map passed into soy when rendering templates. */
+  public void addSoyParam(String key, Object value) {
+    soyContext.put(key, value);
   }
 
-  // TODO: It's not clear why we need this explicit separation. Probably worth
-  // simplifying.
-  /** Mutable content of `email` parameter in the templates. */
-  public Map<String, Object> getSoyContextEmailData() {
-    return this.soyContextEmailData;
+  /** Adds entry to the `email` param passed to the soy when rendering templates. */
+  public void addSoyEmailDataParam(String key, Object value) {
+    soyContextEmailData.put(key, value);
   }
 
   /**
@@ -686,13 +765,13 @@
   }
 
   /** Renders a soy template of kind="text". */
-  protected String textTemplate(String name) {
+  public String textTemplate(String name) {
     return configureRenderer(name).renderText().get();
   }
 
   /** Renders a soy template of kind="html". */
-  protected String soyHtmlTemplate(String name) {
-    return configureRenderer(name).renderHtml().get().toString();
+  public SanitizedContent soyHtmlTemplate(String name) {
+    return configureRenderer(name).renderHtml().get();
   }
 
   /** Configures a soy renderer for the given template name and rendering data map. */
@@ -715,7 +794,8 @@
     return soySauce.renderTemplate(fullTemplateName).setData(soyContext);
   }
 
-  protected void removeUser(Account user) {
+  /** Remove user from the multipart email recipients. */
+  private void removeUser(Account user) {
     String fromEmail = user.preferredEmail();
     for (Iterator<Address> j = smtpRcptTo.iterator(); j.hasNext(); ) {
       if (j.next().email().equals(fromEmail)) {
@@ -730,7 +810,8 @@
     }
   }
 
-  protected final boolean useHtml() {
+  /** Return true, if the email should include html body. */
+  public boolean useHtml() {
     return args.settings.html;
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java
new file mode 100644
index 0000000..c82a016
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailDecorator.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.mail.EmailTokenVerifier;
+import com.google.gerrit.server.mail.send.OutgoingEmail.EmailDecorator;
+
+/**
+ * Sender that informs a user by email about the registration of a new email address for their
+ * account.
+ */
+@AutoFactory
+public class RegisterNewEmailDecorator implements EmailDecorator {
+  private OutgoingEmail email;
+  private final EmailArguments args;
+  private final EmailTokenVerifier tokenVerifier;
+  private final IdentifiedUser user;
+  private final String addr;
+  private String emailToken;
+
+  RegisterNewEmailDecorator(
+      @Provided EmailArguments args,
+      @Provided EmailTokenVerifier tokenVerifier,
+      @Provided IdentifiedUser callingUser,
+      final String address) {
+    this.args = args;
+    this.tokenVerifier = tokenVerifier;
+    this.user = callingUser;
+    this.addr = address;
+  }
+
+  @Override
+  public void init(OutgoingEmail email) {
+    this.email = email;
+
+    email.setHeader("Subject", "[Gerrit Code Review] Email Verification");
+    email.addByEmail(RecipientType.TO, Address.create(addr));
+  }
+
+  public boolean isAllowed() {
+    return args.emailSender.canEmail(addr);
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyEmailDataParam("userNameEmail", email.getUserNameEmailFor(user.getAccountId()));
+    email.addSoyEmailDataParam("emailRegistrationLink", getEmailRegistrationLink());
+
+    email.appendText(email.textTemplate("RegisterNewEmail"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("RegisterNewEmailHtml"));
+    }
+  }
+
+  private String getEmailRegistrationLink() {
+    if (emailToken == null) {
+      emailToken = requireNonNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
+    }
+    return args.urlFormatter.get().getWebUrl().orElse("") + "#/VE/" + emailToken;
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
deleted file mode 100644
index f7bc336..0000000
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.mail.EmailTokenVerifier;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/**
- * Sender that informs a user by email about the registration of a new email address for their
- * account.
- */
-public class RegisterNewEmailSender extends OutgoingEmail {
-  public interface Factory {
-    RegisterNewEmailSender create(String address);
-  }
-
-  private final EmailTokenVerifier tokenVerifier;
-  private final IdentifiedUser user;
-  private final String addr;
-  private String emailToken;
-
-  @Inject
-  public RegisterNewEmailSender(
-      EmailArguments args,
-      EmailTokenVerifier tokenVerifier,
-      IdentifiedUser callingUser,
-      @Assisted final String address) {
-    super(args, "registernewemail");
-    this.tokenVerifier = tokenVerifier;
-    this.user = callingUser;
-    this.addr = address;
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-    setHeader("Subject", "[Gerrit Code Review] Email Verification");
-    addByEmail(RecipientType.TO, Address.create(addr));
-  }
-
-  @Override
-  protected void format() throws EmailException {
-    appendText(textTemplate("RegisterNewEmail"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RegisterNewEmailHtml"));
-    }
-  }
-
-  public boolean isAllowed() {
-    return args.emailSender.canEmail(addr);
-  }
-
-  @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("emailRegistrationToken", getEmailRegistrationToken());
-    soyContextEmailData.put("userNameEmail", getUserNameEmailFor(user.getAccountId()));
-  }
-
-  private String getEmailRegistrationToken() {
-    if (emailToken == null) {
-      emailToken = requireNonNull(tokenVerifier.encode(user.getAccountId(), addr), "token");
-    }
-    return emailToken;
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
deleted file mode 100644
index 5242bfb..0000000
--- a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2020 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Let users know of a user removed from the attention set. */
-public class RemoveFromAttentionSetSender extends AttentionSetSender {
-
-  public interface Factory extends ReplyToChangeSender.Factory<RemoveFromAttentionSetSender> {
-    @Override
-    RemoveFromAttentionSetSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public RemoveFromAttentionSetSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "removeFromAttentionSet", project, changeId);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("RemoveFromAttentionSet"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RemoveFromAttentionSetHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java
similarity index 73%
rename from java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
rename to java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java
index 188c5d8..a73933c 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetChangeEmailDecorator.java
@@ -16,6 +16,8 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableList;
@@ -28,13 +30,11 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubmitRequirementResult;
-import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashSet;
@@ -44,18 +44,13 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Send notice of new patch sets for reviewers. */
-public class ReplacePatchSetSender extends ReplyToChangeSender {
+@AutoFactory
+public class ReplacePatchSetChangeEmailDecorator implements ChangeEmailDecorator {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public interface Factory {
-    ReplacePatchSetSender create(
-        Project.NameKey project,
-        Change.Id changeId,
-        ChangeKind changeKind,
-        ObjectId preUpdateMetaId,
-        Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
-  }
-
+  private final EmailArguments args;
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
   private final Set<Account.Id> reviewers = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
   private final ChangeKind changeKind;
@@ -64,16 +59,14 @@
       preUpdateSubmitRequirementResultsSupplier;
   private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
 
-  @Inject
-  public ReplacePatchSetSender(
-      EmailArguments args,
-      @Assisted Project.NameKey project,
-      @Assisted Change.Id changeId,
-      @Assisted ChangeKind changeKind,
-      @Assisted ObjectId preUpdateMetaId,
-      @Assisted
-          Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
-    super(args, "newpatchset", newChangeData(args, project, changeId));
+  ReplacePatchSetChangeEmailDecorator(
+      @Provided EmailArguments args,
+      Project.NameKey project,
+      Change.Id changeId,
+      ChangeKind changeKind,
+      ObjectId preUpdateMetaId,
+      Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
+    this.args = args;
     this.changeKind = changeKind;
 
     this.preUpdateSubmitRequirementResultsSupplier =
@@ -81,22 +74,21 @@
             () ->
                 // Triggers an (expensive) evaluation of the submit requirements. This is OK since
                 // all callers sent this email asynchronously, see EmailNewPatchSet.
-                newChangeData(args, project, changeId, preUpdateMetaId)
+                args.newChangeData(project, changeId, preUpdateMetaId)
                     .submitRequirementsIncludingLegacy());
 
     this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
   }
 
   @Override
-  protected boolean shouldSendMessage() {
+  public boolean shouldSendMessage() {
     if (!isChangeNoLongerSubmittable() && changeKind.isTrivialRebase()) {
       logger.atFine().log(
           "skip email because new patch set is a trivial rebase that didn't make the change"
               + " non-submittable");
       return false;
     }
-
-    return super.shouldSendMessage();
+    return true;
   }
 
   public void addReviewers(Collection<Account.Id> cc) {
@@ -114,42 +106,27 @@
   }
 
   @Override
-  protected void init() throws EmailException {
-    super.init();
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
 
+    Account.Id fromId = email.getFrom();
     if (fromId != null) {
       // Don't call yourself a reviewer of your own patch set.
       //
       reviewers.remove(fromId);
     }
-    if (args.settings.sendNewPatchsetEmails) {
-      if (notify.handling().equals(NotifyHandling.ALL)
-          || notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)) {
-        reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r));
-        extraCC.stream().forEach(cc -> addByAccountId(RecipientType.CC, cc));
-      }
-      addAuthors(RecipientType.CC);
-    }
-    bccStarredBy();
-    includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("ReplacePatchSet"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("ReplacePatchSetHtml"));
-    }
   }
 
   @Nullable
-  public ImmutableList<String> getReviewerNames() {
+  private ImmutableList<String> getReviewerNames() {
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
-      if (id.equals(fromId)) {
+      if (id.equals(email.getFrom())) {
         continue;
       }
-      names.add(getNameFor(id));
+      names.add(email.getNameFor(id));
     }
     if (names.isEmpty()) {
       return null;
@@ -164,25 +141,43 @@
                 String.format(
                     "%s by %s",
                     LabelVote.create(outdatedApproval.label(), outdatedApproval.value()).format(),
-                    getNameFor(outdatedApproval.accountId())))
+                    email.getNameFor(outdatedApproval.accountId())))
         .sorted()
         .collect(toImmutableList());
   }
 
   @Override
-  protected void setupSoyContext() {
-    super.setupSoyContext();
-    soyContextEmailData.put("reviewerNames", getReviewerNames());
-    soyContextEmailData.put("outdatedApprovals", formatOutdatedApprovals());
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
+    email.addSoyEmailDataParam("reviewerNames", getReviewerNames());
+    email.addSoyEmailDataParam("outdatedApprovals", formatOutdatedApprovals());
 
     if (isChangeNoLongerSubmittable()) {
-      soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
-      soyContext.put(
+      email.addSoyParam("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
+      email.addSoyParam(
           "oldSubmitRequirements",
           formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
-      soyContext.put(
+      email.addSoyParam(
           "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
     }
+
+    if (args.settings.sendNewPatchsetEmails) {
+      if (email.getNotify().handling().equals(NotifyHandling.ALL)
+          || email.getNotify().handling().equals(NotifyHandling.OWNER_REVIEWERS)) {
+        reviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r));
+        extraCC.stream().forEach(cc -> email.addByAccountId(RecipientType.CC, cc));
+      }
+    }
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(
+        NotifyType.NEW_PATCHSETS,
+        !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+
+    email.appendText(email.textTemplate("ReplacePatchSet"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("ReplacePatchSetHtml"));
+    }
   }
 
   /**
@@ -197,7 +192,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s before the update is %s",
-        change.getId(), isSubmittablePreUpdate);
+        changeEmail.getChange().getId(), isSubmittablePreUpdate);
     if (!isSubmittablePreUpdate) {
       return false;
     }
@@ -207,7 +202,7 @@
             .allMatch(SubmitRequirementResult::fulfilled);
     logger.atFine().log(
         "the submitability of change %s after the update is %s",
-        change.getId(), isSubmittablePostUpdate);
+        changeEmail.getChange().getId(), isSubmittablePostUpdate);
     return !isSubmittablePostUpdate;
   }
 
diff --git a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
deleted file mode 100644
index 696cd17..0000000
--- a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.query.change.ChangeData;
-
-/** Alert a user to a reply to a change, usually commentary made during review. */
-public abstract class ReplyToChangeSender extends ChangeEmail {
-  public interface Factory<T extends ReplyToChangeSender> {
-    T create(Project.NameKey project, Change.Id id);
-  }
-
-  protected ReplyToChangeSender(EmailArguments args, String messageClass, ChangeData changeData) {
-    super(args, messageClass, changeData);
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    final String threadId = getChangeMessageThreadId();
-    setHeader("In-Reply-To", threadId);
-    setHeader("References", threadId);
-
-    addAuthors(RecipientType.TO);
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
new file mode 100644
index 0000000..38eab48
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RestoredChangeEmailDecorator.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Send notice about a change being restored by its owner. */
+public class RestoredChangeEmailDecorator implements ChangeEmailDecorator {
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+
+    email.appendText(email.textTemplate("Restored"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("RestoredHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/RestoredSender.java b/java/com/google/gerrit/server/mail/send/RestoredSender.java
deleted file mode 100644
index e37d8f9..0000000
--- a/java/com/google/gerrit/server/mail/send/RestoredSender.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being restored by its owner. */
-public class RestoredSender extends ReplyToChangeSender {
-  public interface Factory extends ReplyToChangeSender.Factory<RestoredSender> {
-    @Override
-    RestoredSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public RestoredSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "restore", ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Restored"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RestoredHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
new file mode 100644
index 0000000..d1cff9c
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RevertedChangeEmailDecorator.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+
+/** Send notice about a change being reverted. */
+public class RevertedChangeEmailDecorator implements ChangeEmailDecorator {
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+    changeEmail.markAsReply();
+  }
+
+  @Override
+  public void populateEmailContent() {
+    changeEmail.addAuthors(RecipientType.TO);
+
+    changeEmail.ccAllApprovals();
+    changeEmail.bccStarredBy();
+    changeEmail.includeWatchers(NotifyType.ALL_COMMENTS);
+
+    email.appendText(email.textTemplate("Reverted"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("RevertedHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/mail/send/RevertedSender.java b/java/com/google/gerrit/server/mail/send/RevertedSender.java
deleted file mode 100644
index 1d7223d..0000000
--- a/java/com/google/gerrit/server/mail/send/RevertedSender.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/** Send notice about a change being reverted. */
-public class RevertedSender extends ReplyToChangeSender {
-  public interface Factory {
-    RevertedSender create(Project.NameKey project, Change.Id changeId);
-  }
-
-  @Inject
-  public RevertedSender(
-      EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
-    super(args, "revert", ChangeEmail.newChangeData(args, project, changeId));
-  }
-
-  @Override
-  protected void init() throws EmailException {
-    super.init();
-
-    ccAllApprovals();
-    bccStarredBy();
-    includeWatchers(NotifyType.ALL_COMMENTS);
-  }
-
-  @Override
-  protected void formatChange() throws EmailException {
-    appendText(textTemplate("Reverted"));
-    if (useHtml()) {
-      appendHtml(soyHtmlTemplate("RevertedHtml"));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java
new file mode 100644
index 0000000..6ae1c6a
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/StartReviewChangeEmailDecorator.java
@@ -0,0 +1,143 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.server.mail.send.ChangeEmail.ChangeEmailDecorator;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Sends an email alerting a user to a new change for them to review. */
+public class StartReviewChangeEmailDecorator implements ChangeEmailDecorator {
+  private OutgoingEmail email;
+  private ChangeEmail changeEmail;
+
+  private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
+  private final Set<Account.Id> extraCC = new HashSet<>();
+  private final Set<Address> extraCCByEmail = new HashSet<>();
+  private final Set<Account.Id> removedReviewers = new HashSet<>();
+  private final Set<Address> removedByEmailReviewers = new HashSet<>();
+  private boolean isCreateChange = false;
+
+  public void addReviewers(Collection<Account.Id> cc) {
+    reviewers.addAll(cc);
+  }
+
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
+  public void addExtraCC(Collection<Account.Id> cc) {
+    extraCC.addAll(cc);
+  }
+
+  public void addExtraCCByEmail(Collection<Address> cc) {
+    extraCCByEmail.addAll(cc);
+  }
+
+  public void addRemovedReviewers(Collection<Account.Id> removed) {
+    removedReviewers.addAll(removed);
+  }
+
+  public void addRemovedByEmailReviewers(Collection<Address> removed) {
+    removedByEmailReviewers.addAll(removed);
+  }
+
+  public void markAsCreateChange() {
+    isCreateChange = true;
+  }
+
+  @Override
+  public void init(OutgoingEmail email, ChangeEmail changeEmail) {
+    this.email = email;
+    this.changeEmail = changeEmail;
+  }
+
+  @Nullable
+  private List<String> getReviewerNames() {
+    if (reviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : reviewers) {
+      names.add(email.getNameFor(id));
+    }
+    return names;
+  }
+
+  @Nullable
+  private List<String> getRemovedReviewerNames() {
+    if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : removedReviewers) {
+      names.add(email.getNameFor(id));
+    }
+    for (Address address : removedByEmailReviewers) {
+      names.add(address.toString());
+    }
+    return names;
+  }
+
+  @Override
+  public void populateEmailContent() {
+    email.addSoyParam("ownerName", email.getNameFor(changeEmail.getChange().getOwner()));
+    email.addSoyEmailDataParam("reviewerNames", getReviewerNames());
+    email.addSoyEmailDataParam("removedReviewerNames", getRemovedReviewerNames());
+
+    switch (email.getNotify().handling()) {
+      case NONE:
+      case OWNER:
+        break;
+      case ALL:
+      default:
+        extraCC.stream().forEach(cc -> email.addByAccountId(RecipientType.CC, cc));
+        extraCCByEmail.stream().forEach(cc -> email.addByEmail(RecipientType.CC, cc));
+        // $FALL-THROUGH$
+      case OWNER_REVIEWERS:
+        reviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r, true));
+        reviewersByEmail.stream().forEach(r -> email.addByEmail(RecipientType.TO, r, true));
+        removedReviewers.stream().forEach(r -> email.addByAccountId(RecipientType.TO, r, true));
+        removedByEmailReviewers.stream().forEach(r -> email.addByEmail(RecipientType.TO, r, true));
+        break;
+    }
+    changeEmail.addAuthors(RecipientType.CC);
+
+    if (isCreateChange) {
+      changeEmail.includeWatchers(
+          NotifyType.NEW_CHANGES,
+          !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+      changeEmail.includeWatchers(
+          NotifyType.NEW_PATCHSETS,
+          !changeEmail.getChange().isWorkInProgress() && !changeEmail.getChange().isPrivate());
+    } else {
+      changeEmail.ccExistingReviewers();
+    }
+
+    email.appendText(email.textTemplate("NewChange"));
+    if (email.useHtml()) {
+      email.appendHtml(email.soyHtmlTemplate("NewChangeHtml"));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
index 3be55ea..eb1f692 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
@@ -23,6 +23,7 @@
   public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id");
   public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit");
   public static final FooterKey FOOTER_CURRENT = new FooterKey("Current");
+  public static final FooterKey FOOTER_CUSTOM_KEYED_VALUE = new FooterKey("Custom-Keyed-Value");
   public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
   public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
   public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index c75fd29..4a31e23 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -394,6 +394,7 @@
 
   // Lazy defensive copies of mutable ReviewDb types, to avoid polluting the
   // ChangeNotesCache from handlers.
+  private ImmutableSortedMap<String, String> customKeyedValues;
   private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
   private PatchSetApprovals approvals;
   private ImmutableSet<Comment.Key> commentKeys;
@@ -477,6 +478,16 @@
     return state.allAttentionSetUpdates();
   }
 
+  /** Returns the key-value pairs that are attached to this change */
+  public ImmutableSortedMap<String, String> getCustomKeyedValues() {
+    if (customKeyedValues == null) {
+      ImmutableSortedMap.Builder<String, String> b = ImmutableSortedMap.naturalOrder();
+      b.putAll(state.customKeyedValues());
+      customKeyedValues = b.build();
+    }
+    return customKeyedValues;
+  }
+
   /**
    * Returns the evaluated submit requirements for the change. We only intend to store submit
    * requirements in NoteDb for closed changes. For closed changes, the results represent the state
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 2d3902c..37729b8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -62,7 +62,7 @@
             .weigher(Weigher.class)
             .maximumWeight(10 << 20)
             .diskLimit(-1)
-            .version(5)
+            .version(6)
             .keySerializer(Key.Serializer.INSTANCE)
             .valueSerializer(ChangeNotesState.Serializer.INSTANCE);
       }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 951a478..5c84589 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -22,6 +22,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CUSTOM_KEYED_VALUE;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
@@ -47,6 +48,7 @@
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -98,6 +100,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.TreeSet;
 import java.util.function.Function;
 import java.util.stream.Collectors;
@@ -166,6 +169,7 @@
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
   private final List<PatchSet.Id> currentPatchSets;
+  private final TreeMap<String, String> customKeyedValues;
   private final Map<PatchSetApproval.Key, PatchSetApproval.Builder> approvals;
   private final List<PatchSetApproval.Builder> bufferedApprovals;
   private final List<ChangeMessage> allChangeMessages;
@@ -234,6 +238,7 @@
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
     currentPatchSets = new ArrayList<>();
+    customKeyedValues = new TreeMap<>();
   }
 
   ChangeNotesState parseAll() throws ConfigInvalidException, IOException {
@@ -264,6 +269,7 @@
       checkMandatoryFooters();
     }
 
+    pruneEmptyCustomKeyedValues();
     return buildState();
   }
 
@@ -288,6 +294,7 @@
         submissionId,
         status,
         firstNonNull(hashtags, ImmutableSet.of()),
+        ImmutableSortedMap.copyOfSorted(customKeyedValues),
         buildPatchSets(),
         buildApprovals(),
         ReviewerSet.fromTable(Tables.transpose(reviewers)),
@@ -491,6 +498,7 @@
     }
 
     parseHashtags(commit);
+    parseCustomKeyedValues(commit);
     parseAttentionSetUpdates(commit);
 
     parseSubmission(commit, commitTimestamp);
@@ -722,6 +730,30 @@
     }
   }
 
+  private void parseCustomKeyedValues(ChangeNotesCommit commit) {
+    for (String customKeyedValueLine : commit.getFooterLineValues(FOOTER_CUSTOM_KEYED_VALUE)) {
+      String[] parts = customKeyedValueLine.split("=", 2);
+      String key = parts[0];
+      String value = parts[1];
+      // Commits are parsed in reverse order and only the last set of values
+      // should be used.  An empty value for a key means it's a deletion.
+      customKeyedValues.putIfAbsent(key, value);
+    }
+  }
+
+  private void pruneEmptyCustomKeyedValues() {
+    List<String> toRemove = new ArrayList<>();
+    for (Map.Entry<String, String> entry : customKeyedValues.entrySet()) {
+      if (entry.getValue().length() == 0) {
+        toRemove.add(entry.getKey());
+      }
+    }
+
+    for (String key : toRemove) {
+      customKeyedValues.remove(key);
+    }
+  }
+
   private void parseAttentionSetUpdates(ChangeNotesCommit commit) throws ConfigInvalidException {
     List<String> attentionStrings = commit.getFooterLineValues(FOOTER_ATTENTION);
     for (String attentionString : attentionStrings) {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 1715b43..304b54d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -28,6 +28,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
@@ -111,6 +112,7 @@
       @Nullable String submissionId,
       @Nullable Change.Status status,
       Set<String> hashtags,
+      ImmutableSortedMap<String, String> customKeyedValues,
       Map<PatchSet.Id, PatchSet> patchSets,
       ListMultimap<PatchSet.Id, PatchSetApproval> approvals,
       ReviewerSet reviewers,
@@ -163,6 +165,7 @@
                 .cherryPickOf(cherryPickOf)
                 .build())
         .hashtags(hashtags)
+        .customKeyedValues(customKeyedValues.entrySet())
         .serverId(serverId)
         .patchSets(patchSets.entrySet())
         .approvals(approvals.entries())
@@ -290,6 +293,16 @@
   // Other related to this Change.
   abstract ImmutableSet<String> hashtags();
 
+  /*
+    Custom values are small key value pairs. They can be used to associate the
+    change with external, potentially proprietary systems (e.g. Bug trackers)
+    without requiring dedicated fields in Gerrit-core.
+
+    This data is visible to everyone who can see the change. It must not contain
+    personally identify-able information.
+  */
+  abstract ImmutableList<Map.Entry<String, String>> customKeyedValues();
+
   @Nullable
   abstract String serverId();
 
@@ -384,6 +397,7 @@
       return new AutoValue_ChangeNotesState.Builder()
           .changeId(changeId)
           .hashtags(ImmutableSet.of())
+          .customKeyedValues(ImmutableList.of())
           .patchSets(ImmutableList.of())
           .approvals(ImmutableList.of())
           .reviewers(ReviewerSet.empty())
@@ -411,6 +425,8 @@
 
     abstract Builder hashtags(Iterable<String> hashtags);
 
+    abstract Builder customKeyedValues(Iterable<Map.Entry<String, String>> customKeyedValues);
+
     abstract Builder patchSets(Iterable<Map.Entry<PatchSet.Id, PatchSet>> patchSets);
 
     abstract Builder approvals(Iterable<Map.Entry<PatchSet.Id, PatchSetApproval>> approvals);
@@ -475,6 +491,14 @@
         b.setHasServerId(true);
       }
       object.hashtags().forEach(b::addHashtag);
+
+      object
+          .customKeyedValues()
+          .forEach(
+              entry -> {
+                b.putCustomKeyedValues(entry.getKey(), entry.getValue());
+              });
+
       object
           .patchSets()
           .forEach(e -> b.addPatchSet(PatchSetProtoConverter.INSTANCE.toProto(e.getValue())));
@@ -614,6 +638,7 @@
               .columns(toChangeColumns(changeId, proto.getColumns()))
               .serverId(proto.getHasServerId() ? proto.getServerId() : null)
               .hashtags(proto.getHashtagList())
+              .customKeyedValues(proto.getCustomKeyedValuesMap().entrySet())
               .patchSets(
                   proto.getPatchSetList().stream()
                       .map(msg -> PatchSetProtoConverter.INSTANCE.fromProto(msg))
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 0a895fb..42588cf 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -25,6 +25,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CUSTOM_KEYED_VALUE;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
@@ -100,6 +101,7 @@
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.TreeMap;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -138,6 +140,10 @@
         ChangeNotes notes, CurrentUser user, Instant when, Comparator<String> labelNameComparator);
   }
 
+  public static final int MAX_CUSTOM_KEY_LENGTH = 100;
+  public static final int MAX_CUSTOM_KEYED_VALUE_LENGTH = 1000;
+  public static final int MAX_CUSTOM_KEYED_VALUES = 100;
+
   private final NoteDbUpdateManager.Factory updateManagerFactory;
   private final ChangeDraftUpdate.Factory draftUpdateFactory;
   private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
@@ -163,6 +169,7 @@
   private Map<Account.Id, AttentionSetUpdate> plannedAttentionSetUpdates;
   private boolean ignoreFurtherAttentionSetUpdates;
   private Set<String> hashtags;
+  private TreeMap<String, String> customKeyedValues = new TreeMap<>();
   private String changeMessage;
   private String tag;
   private PatchSetState psState;
@@ -462,6 +469,23 @@
     this.hashtags = hashtags;
   }
 
+  public void addCustomKeyedValue(String key, String value) throws ValidationException {
+    if (key.length() > MAX_CUSTOM_KEY_LENGTH) {
+      throw new ValidationException("Custom Key is too long.");
+    }
+    if (value.length() > MAX_CUSTOM_KEYED_VALUE_LENGTH) {
+      throw new ValidationException("Custom Keyed value is too long.");
+    }
+    this.customKeyedValues.put(key, value);
+  }
+
+  public void deleteCustomKeyedValue(String key) throws ValidationException {
+    if (key.length() > MAX_CUSTOM_KEY_LENGTH) {
+      throw new ValidationException("Custom Key is too long.");
+    }
+    this.customKeyedValues.put(key, "");
+  }
+
   /**
    * Adds attention set updates that should be stored in NoteDb.
    *
@@ -764,6 +788,10 @@
       addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags));
     }
 
+    for (Map.Entry<String, String> entry : customKeyedValues.entrySet()) {
+      addFooter(msg, FOOTER_CUSTOM_KEYED_VALUE, entry.getKey() + "=" + entry.getValue());
+    }
+
     if (tag != null) {
       addFooter(msg, FOOTER_TAG, tag);
     }
@@ -1159,6 +1187,7 @@
         && submissionId == null
         && submitRecords == null
         && hashtags == null
+        && customKeyedValues.isEmpty()
         && topic == null
         && commit == null
         && psState == null
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
index 115830e..500d3df 100644
--- a/java/com/google/gerrit/server/patch/DiffUtil.java
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -168,13 +168,14 @@
     }
   }
 
-  public static String cleanPatch(final String patch) {
+  public static String normalizePatchForComparison(final String patch) {
     String res = removePatchHeader(patch);
     return res
-        // Remove "index NN..NN" lines
-        .replaceAll("(?m)^index.*", "")
-        // Remove hunk-headers lines
-        .replaceAll("(?m)^@@ .*", "")
+        // Remove any lines which are not diff lines or file header lines - such index,
+        // hunk-headers, and context lines.
+        .replaceAll("(?m)^[^+-].*", "")
+        .replaceAll("(?m)^[+]{3} [ab]/", "+++")
+        .replaceAll("(?m)^-{3} [ab]/", "+++")
         // Remove empty lines
         .replaceAll("\n+", "\n")
         // Trim
@@ -184,21 +185,21 @@
   public static String removePatchHeader(final String patch) {
     String res = patch.trim();
     if (!res.startsWith("diff --") && res.contains("\ndiff --")) {
-      return res.substring(patch.indexOf("\ndiff --"), patch.length() - 1);
+      return res.substring(res.indexOf("\ndiff --"));
     }
     return res;
   }
 
   public static Optional<String> getPatchHeader(final String patch) {
-    if (patch.startsWith("diff --")) {
+    String res = patch.trim();
+    if (res.startsWith("diff ")) {
       return Optional.empty();
     }
-    return Optional.ofNullable(
-        Strings.emptyToNull(patch.trim().substring(0, patch.indexOf("\ndiff --git"))));
+    return Optional.ofNullable(Strings.emptyToNull(res.substring(0, res.indexOf("\ndiff "))));
   }
 
-  public static String cleanPatch(BinaryResult bin) throws IOException {
-    return cleanPatch(bin.asString());
+  public static String normalizePatchForComparison(BinaryResult bin) throws IOException {
+    return normalizePatchForComparison(bin.asString());
   }
 
   private static boolean isRootOrMergeCommit(RevCommit commit) {
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 993c68d..2770f64 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -19,6 +19,7 @@
 
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
@@ -36,6 +37,8 @@
 
 /** Access control management for a user accessing a single change. */
 class ChangeControl {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final RefControl refControl;
   private final ChangeData changeData;
 
@@ -184,11 +187,43 @@
         || getProjectControl().isAdmin();
   }
 
+  /** Can this user edit the custom keyed values? */
+  private boolean canEditCustomKeyedValues() {
+    return isOwner() // owner (aka creator) of the change can edit custom keyed values
+        || getProjectControl().isAdmin();
+  }
+
   private boolean isPrivateVisible(ChangeData cd) {
-    return isOwner()
-        || isReviewer(cd)
-        || refControl.canPerform(Permission.VIEW_PRIVATE_CHANGES)
-        || getUser().isInternalUser();
+    if (isOwner()) {
+      logger.atFine().log(
+          "%s can see private change %s because this user is the change owner",
+          getUser().getLoggableName(), cd.getId());
+      return true;
+    }
+
+    if (isReviewer(cd)) {
+      logger.atFine().log(
+          "%s can see private change %s because this user is a reviewer",
+          getUser().getLoggableName(), cd.getId());
+      return true;
+    }
+
+    if (refControl.canPerform(Permission.VIEW_PRIVATE_CHANGES)) {
+      logger.atFine().log(
+          "%s can see private change %s because this user can view private changes",
+          getUser().getLoggableName(), cd.getId());
+      return true;
+    }
+
+    if (getUser().isInternalUser()) {
+      logger.atFine().log(
+          "%s can see private change %s because this user is an internal user",
+          getUser().getLoggableName(), cd.getId());
+      return true;
+    }
+
+    logger.atFine().log("%s cannot see private change %s", getUser().getLoggableName(), cd.getId());
+    return false;
   }
 
   private class ForChangeImpl extends ForChange {
@@ -262,6 +297,8 @@
             return canEditDescription();
           case EDIT_HASHTAGS:
             return canEditHashtags();
+          case EDIT_CUSTOM_KEYED_VALUES:
+            return canEditCustomKeyedValues();
           case EDIT_TOPIC_NAME:
             return canEditTopicName();
           case REBASE:
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index 7741adac..d9f83c7 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -38,6 +38,7 @@
    */
   ABANDON,
   EDIT_DESCRIPTION,
+  EDIT_CUSTOM_KEYED_VALUES,
   EDIT_HASHTAGS,
   EDIT_TOPIC_NAME,
   REMOVE_REVIEWER,
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 958de1b..1b87446 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -91,6 +91,7 @@
       ImmutableBiMap.<ChangePermission, String>builder()
           .put(ChangePermission.READ, Permission.READ)
           .put(ChangePermission.ABANDON, Permission.ABANDON)
+          .put(ChangePermission.EDIT_CUSTOM_KEYED_VALUES, Permission.EDIT_CUSTOM_KEYED_VALUES)
           .put(ChangePermission.EDIT_HASHTAGS, Permission.EDIT_HASHTAGS)
           .put(ChangePermission.EDIT_TOPIC_NAME, Permission.EDIT_TOPIC_NAME)
           .put(ChangePermission.REMOVE_REVIEWER, Permission.REMOVE_REVIEWER)
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 1d999dd..1fe8641 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -34,7 +34,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.DefaultSubmitRule;
-import com.google.gerrit.server.rules.PrologRule;
+import com.google.gerrit.server.rules.PrologSubmitRuleUtil;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -49,7 +49,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final ProjectCache projectCache;
-  private final PrologRule prologRule;
+  private final PrologSubmitRuleUtil prologSubmitRuleUtil;
   private final PluginSetContext<SubmitRule> submitRules;
   private final Timer0 submitRuleEvaluationLatency;
   private final Timer0 submitTypeEvaluationLatency;
@@ -64,12 +64,12 @@
   @Inject
   private SubmitRuleEvaluator(
       ProjectCache projectCache,
-      PrologRule prologRule,
+      PrologSubmitRuleUtil prologSubmitRuleUtil,
       PluginSetContext<SubmitRule> submitRules,
       MetricMaker metricMaker,
       @Assisted SubmitRuleOptions options) {
     this.projectCache = projectCache;
-    this.prologRule = prologRule;
+    this.prologSubmitRuleUtil = prologSubmitRuleUtil;
     this.submitRules = submitRules;
     this.submitRuleEvaluationLatency =
         metricMaker.newTimer(
@@ -184,7 +184,7 @@
         throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
 
-      return prologRule.getSubmitType(cd);
+      return prologSubmitRuleUtil.getSubmitType(cd);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index cbd8d83..80c8085 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -382,6 +382,7 @@
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
   private Set<String> hashtags;
+  private ImmutableMap<String, String> customKeyedValues;
   /**
    * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the edit ref for this
    * change and a given user.
@@ -1252,6 +1253,16 @@
     this.hashtags = hashtags;
   }
 
+  public Map<String, String> customKeyedValues() {
+    if (customKeyedValues == null) {
+      if (!lazyload()) {
+        return Collections.emptyMap();
+      }
+      customKeyedValues = notes().getCustomKeyedValues();
+    }
+    return customKeyedValues;
+  }
+
   public ImmutableListMultimap<Account.Id, String> stars() {
     if (stars == null) {
       if (!lazyload()) {
diff --git a/java/com/google/gerrit/server/restapi/account/AddSshKey.java b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
index f18cc67..2544d3b 100644
--- a/java/com/google/gerrit/server/restapi/account/AddSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/AddSshKey.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
-import com.google.gerrit.server.mail.send.AddKeySender;
+import com.google.gerrit.server.mail.EmailModule.AddKeyEmailFactories;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -58,7 +58,7 @@
   private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
-  private final AddKeySender.Factory addKeyFactory;
+  private final AddKeyEmailFactories addKeyEmailFactories;
 
   @Inject
   AddSshKey(
@@ -66,12 +66,12 @@
       PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
-      AddKeySender.Factory addKeyFactory) {
+      AddKeyEmailFactories addKeyEmailFactories) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
-    this.addKeyFactory = addKeyFactory;
+    this.addKeyEmailFactories = addKeyEmailFactories;
   }
 
   @Override
@@ -106,7 +106,7 @@
       AccountSshKey sshKey = authorizedKeys.addKey(user.getAccountId(), sshPublicKey);
 
       try {
-        addKeyFactory.create(user, sshKey).send();
+        addKeyEmailFactories.createEmail(user, sshKey).send();
       } catch (EmailException e) {
         logger.atSevere().withCause(e).log(
             "Cannot send SSH key added message to %s", user.getAccount().preferredEmail());
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index 70fbb26..92a7722 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -37,9 +37,11 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gerrit.server.mail.EmailModule.RegisterNewEmailFactories;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
-import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.RegisterNewEmailDecorator;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -78,7 +80,7 @@
   private final Realm realm;
   private final PermissionBackend permissionBackend;
   private final AccountManager accountManager;
-  private final RegisterNewEmailSender.Factory registerNewEmailFactory;
+  private final RegisterNewEmailFactories registerNewEmailFactories;
   private final PutPreferred putPreferred;
   private final OutgoingEmailValidator validator;
   private final MessageIdGenerator messageIdGenerator;
@@ -92,7 +94,7 @@
       PermissionBackend permissionBackend,
       AuthConfig authConfig,
       AccountManager accountManager,
-      RegisterNewEmailSender.Factory registerNewEmailFactory,
+      RegisterNewEmailFactories registerNewEmailFactories,
       PutPreferred putPreferred,
       OutgoingEmailValidator validator,
       MessageIdGenerator messageIdGenerator,
@@ -101,7 +103,7 @@
     this.realm = realm;
     this.permissionBackend = permissionBackend;
     this.accountManager = accountManager;
-    this.registerNewEmailFactory = registerNewEmailFactory;
+    this.registerNewEmailFactories = registerNewEmailFactories;
     this.putPreferred = putPreferred;
     this.validator = validator;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
@@ -164,12 +166,14 @@
       }
     } else {
       try {
-        RegisterNewEmailSender emailSender = registerNewEmailFactory.create(email);
-        if (!emailSender.isAllowed()) {
+        RegisterNewEmailDecorator emailDecorator =
+            registerNewEmailFactories.createRegisterNewEmail(email);
+        if (!emailDecorator.isAllowed()) {
           throw new MethodNotAllowedException("Not allowed to add email address " + email);
         }
-        emailSender.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
-        emailSender.send();
+        OutgoingEmail outgoingEmail = registerNewEmailFactories.createEmail(emailDecorator);
+        outgoingEmail.setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
+        outgoingEmail.send();
         info.pendingConfirmation = true;
       } catch (EmailException | RuntimeException e) {
         logger.atSevere().withCause(e).log("Cannot send email verification message to %s", email);
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
index e09e48f..61d43d2 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteSshKey.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
-import com.google.gerrit.server.mail.send.DeleteKeySender;
+import com.google.gerrit.server.mail.EmailModule.DeleteKeyEmailFactories;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -51,7 +51,7 @@
   private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
-  private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final DeleteKeyEmailFactories deleteKeyEmailFactories;
 
   @Inject
   DeleteSshKey(
@@ -59,12 +59,12 @@
       PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
-      DeleteKeySender.Factory deleteKeySenderFactory) {
+      DeleteKeyEmailFactories deleteKeyEmailFactories) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
-    this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.deleteKeyEmailFactories = deleteKeyEmailFactories;
   }
 
   @Override
@@ -82,7 +82,7 @@
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
     authorizedKeys.deleteKey(user.getAccountId(), sshKey.seq());
     try {
-      deleteKeySenderFactory.create(user, sshKey).send();
+      deleteKeyEmailFactories.createEmail(user, sshKey).send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
           "Cannot send SSH key deletion message to %s", user.getAccount().preferredEmail());
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index 9361e27..edfc41c 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -37,7 +37,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
-import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
+import com.google.gerrit.server.mail.EmailModule.HttpPasswordUpdateEmailFactory;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -78,7 +78,7 @@
   private final PermissionBackend permissionBackend;
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
-  private final HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory;
+  private final HttpPasswordUpdateEmailFactory httpPasswordUpdateEmailFactory;
   private final ExternalIdFactory externalIdFactory;
   private final ExternalIdKeyFactory externalIdKeyFactory;
 
@@ -88,14 +88,14 @@
       PermissionBackend permissionBackend,
       ExternalIds externalIds,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
-      HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory,
+      HttpPasswordUpdateEmailFactory httpPasswordUpdateEmailFactory,
       ExternalIdFactory externalIdFactory,
       ExternalIdKeyFactory externalIdKeyFactory) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
-    this.httpPasswordUpdateSenderFactory = httpPasswordUpdateSenderFactory;
+    this.httpPasswordUpdateEmailFactory = httpPasswordUpdateEmailFactory;
     this.externalIdFactory = externalIdFactory;
     this.externalIdKeyFactory = externalIdKeyFactory;
   }
@@ -146,8 +146,8 @@
                         extId.key(), extId.accountId(), extId.email(), newPassword)));
 
     try {
-      httpPasswordUpdateSenderFactory
-          .create(user, newPassword == null ? "deleted" : "added or updated")
+      httpPasswordUpdateEmailFactory
+          .createEmail(user, newPassword == null ? "deleted" : "added or updated")
           .send();
     } catch (EmailException e) {
       logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
index a5df0f8..1a252e5 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
@@ -175,8 +175,8 @@
   }
 
   private static Optional<String> verifyAppliedPatch(String originalPatch, String resultPatch) {
-    String cleanOriginalPatch = DiffUtil.cleanPatch(originalPatch);
-    String cleanResultPatch = DiffUtil.cleanPatch(resultPatch);
+    String cleanOriginalPatch = DiffUtil.normalizePatchForComparison(originalPatch);
+    String cleanResultPatch = DiffUtil.normalizePatchForComparison(resultPatch);
     if (cleanOriginalPatch.equals(cleanResultPatch)) {
       return Optional.empty();
     }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 33e6342..3e985c2 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.SetCherryPickOp;
+import com.google.gerrit.server.change.SetCustomKeyedValuesOp;
 import com.google.gerrit.server.change.SetHashtagsOp;
 import com.google.gerrit.server.change.SetPrivateOp;
 import com.google.gerrit.server.change.SetTopicOp;
@@ -216,6 +217,7 @@
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetCherryPickOp.Factory.class);
+    factory(SetCustomKeyedValuesOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
     factory(SetTopicOp.Factory.class);
     factory(SetPrivateOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index a1bb987..4a70684 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -22,6 +22,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -447,6 +448,15 @@
           ins.setValidationOptions(validationOptions.build());
         }
 
+        if (input.customKeyedValues != null) {
+          ImmutableMap.Builder<String, String> customKeyedValues = ImmutableMap.builder();
+          input
+              .customKeyedValues
+              .entrySet()
+              .forEach(e -> customKeyedValues.put(e.getKey(), e.getValue()));
+          ins.setCustomKeyedValues(customKeyedValues.build());
+        }
+
         try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
           bu.setRepository(git, rw, oi);
           bu.setNotify(
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
index 3ac4d22..2ff3ab0 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -35,9 +35,10 @@
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.extensions.events.VoteDeleted;
-import com.google.gerrit.server.mail.send.DeleteVoteSender;
+import com.google.gerrit.server.mail.EmailModule.DeleteVoteChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.permissions.LabelRemovalPermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.DeleteVoteControl;
@@ -76,7 +77,7 @@
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
   private final VoteDeleted voteDeleted;
-  private final DeleteVoteSender.Factory deleteVoteSenderFactory;
+  private final DeleteVoteChangeEmailFactories deleteVoteChangeEmailFactories;
 
   private final DeleteVoteControl deleteVoteControl;
   private final RemoveReviewerControl removeReviewerControl;
@@ -99,7 +100,7 @@
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
       VoteDeleted voteDeleted,
-      DeleteVoteSender.Factory deleteVoteSenderFactory,
+      DeleteVoteChangeEmailFactories deleteVoteChangeEmailFactories,
       DeleteVoteControl deleteVoteControl,
       MessageIdGenerator messageIdGenerator,
       RemoveReviewerControl removeReviewerControl,
@@ -113,7 +114,7 @@
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
     this.voteDeleted = voteDeleted;
-    this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+    this.deleteVoteChangeEmailFactories = deleteVoteChangeEmailFactories;
     this.deleteVoteControl = deleteVoteControl;
     this.removeReviewerControl = removeReviewerControl;
     this.messageIdGenerator = messageIdGenerator;
@@ -187,17 +188,18 @@
 
     CurrentUser user = ctx.getUser();
     try {
+      ChangeEmail changeEmail =
+          deleteVoteChangeEmailFactories.createChangeEmail(ctx.getProject(), change.getId());
+      changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+      OutgoingEmail outgoingEmail = deleteVoteChangeEmailFactories.createEmail(changeEmail);
       NotifyResolver.Result notify = ctx.getNotify(change.getId());
-      ReplyToChangeSender emailSender =
-          deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
       if (user.isIdentifiedUser()) {
-        emailSender.setFrom(user.getAccountId());
+        outgoingEmail.setFrom(user.getAccountId());
       }
-      emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
+      outgoingEmail.setNotify(notify);
+      outgoingEmail.setMessageId(
           messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-      emailSender.send();
+      outgoingEmail.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/GetCommit.java b/java/com/google/gerrit/server/restapi/change/GetCommit.java
index 5193501..ead9f38 100644
--- a/java/com/google/gerrit/server/restapi/change/GetCommit.java
+++ b/java/com/google/gerrit/server/restapi/change/GetCommit.java
@@ -66,7 +66,8 @@
                   addLinks,
                   /* fillCommit= */ true,
                   rsrc.getChange().getDest().branch(),
-                  rsrc.getChange().getKey().get());
+                  rsrc.getChange().getKey().get(),
+                  rsrc.getChange().getId().get());
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
         r.caching(CacheControl.PRIVATE(7, DAYS));
diff --git a/java/com/google/gerrit/server/restapi/change/GetCustomKeyedValues.java b/java/com/google/gerrit/server/restapi/change/GetCustomKeyedValues.java
new file mode 100644
index 0000000..47765ab
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/GetCustomKeyedValues.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class GetCustomKeyedValues implements RestReadView<ChangeResource> {
+  @Override
+  public Response<ImmutableMap<String, String>> apply(ChangeResource req)
+      throws AuthException, IOException, BadRequestException {
+    ChangeNotes notes = req.getNotes().load();
+    ImmutableMap<String, String> customKeyedValues = notes.getCustomKeyedValues();
+    if (customKeyedValues == null) {
+      customKeyedValues = ImmutableMap.of();
+    }
+    return Response.ok(customKeyedValues);
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/GetMergeList.java b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
index 551b50f..3d8f4e3 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMergeList.java
@@ -90,7 +90,8 @@
                 addLinks,
                 /* fillCommit= */ true,
                 rsrc.getChange().getDest().branch(),
-                rsrc.getChange().getKey().get()));
+                rsrc.getChange().getKey().get(),
+                rsrc.getChange().getId().get()));
       }
       return createResponse(rsrc, result);
     }
diff --git a/java/com/google/gerrit/server/restapi/change/PostCustomKeyedValues.java b/java/com/google/gerrit/server/restapi/change/PostCustomKeyedValues.java
new file mode 100644
index 0000000..d97107a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PostCustomKeyedValues.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetCustomKeyedValuesOp;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class PostCustomKeyedValues
+    implements RestModifyView<ChangeResource, CustomKeyedValuesInput>, UiAction<ChangeResource> {
+  private final BatchUpdate.Factory updateFactory;
+  private final SetCustomKeyedValuesOp.Factory customKeyedValuesFactory;
+
+  @Inject
+  PostCustomKeyedValues(
+      BatchUpdate.Factory updateFactory, SetCustomKeyedValuesOp.Factory customKeyedValuesFactory) {
+    this.updateFactory = updateFactory;
+    this.customKeyedValuesFactory = customKeyedValuesFactory;
+  }
+
+  @Override
+  public Response<ImmutableMap<String, String>> apply(
+      ChangeResource req, CustomKeyedValuesInput input)
+      throws RestApiException, UpdateException, PermissionBackendException {
+    req.permissions().check(ChangePermission.EDIT_CUSTOM_KEYED_VALUES);
+    try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+      try (BatchUpdate bu =
+          updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
+        SetCustomKeyedValuesOp op = customKeyedValuesFactory.create(input);
+        bu.addOp(req.getId(), op);
+        bu.execute();
+        return Response.ok(op.getUpdatedCustomKeyedValues());
+      }
+    }
+  }
+
+  @Override
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Edit custom keyed values")
+        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_CUSTOM_KEYED_VALUES));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index a8f8adf..db6fa77 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -649,8 +649,8 @@
           del.add(c);
           update.putApproval(normName, (short) 0);
         }
-        // Only allow voting again the values are different, if the real account differs or if the
-        // vote is copied over from a past patch-set.
+        // Only allow voting again if the values are different, if the real account differs or if
+        // the vote is copied over from a past patch-set.
       } else if (c != null
           && (c.value() != ent.getValue()
               || !c.realAccountId().equals(reviewerId)
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 6ac9c21..35ea183 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -35,9 +35,10 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
+import com.google.gerrit.server.mail.EmailModule.RestoredChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.ReplyToChangeSender;
-import com.google.gerrit.server.mail.send.RestoredSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -60,7 +61,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final BatchUpdate.Factory updateFactory;
-  private final RestoredSender.Factory restoredSenderFactory;
+  private final RestoredChangeEmailFactories restoredChangeEmailFactories;
   private final ChangeJson.Factory json;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
@@ -71,7 +72,7 @@
   @Inject
   Restore(
       BatchUpdate.Factory updateFactory,
-      RestoredSender.Factory restoredSenderFactory,
+      RestoredChangeEmailFactories restoredChangeEmailFactories,
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
@@ -79,7 +80,7 @@
       ProjectCache projectCache,
       MessageIdGenerator messageIdGenerator) {
     this.updateFactory = updateFactory;
-    this.restoredSenderFactory = restoredSenderFactory;
+    this.restoredChangeEmailFactories = restoredChangeEmailFactories;
     this.json = json;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
@@ -151,13 +152,14 @@
     @Override
     public void postUpdate(PostUpdateContext ctx) {
       try {
-        ReplyToChangeSender emailSender =
-            restoredSenderFactory.create(ctx.getProject(), change.getId());
-        emailSender.setFrom(ctx.getAccountId());
-        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
-        emailSender.setMessageId(
+        ChangeEmail changeEmail =
+            restoredChangeEmailFactories.createChangeEmail(ctx.getProject(), change.getId());
+        changeEmail.setChangeMessage(mailMessage, ctx.getWhen());
+        OutgoingEmail outgoingEmail = restoredChangeEmailFactories.createEmail(changeEmail);
+        outgoingEmail.setFrom(ctx.getAccountId());
+        outgoingEmail.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-        emailSender.send();
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index 97f866b..cc607f4 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -31,9 +31,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.rules.PrologOptions;
-import com.google.gerrit.server.rules.PrologRule;
-import com.google.gerrit.server.rules.RulesCache;
+import com.google.gerrit.server.rules.PrologSubmitRuleUtil;
 import com.google.inject.Inject;
 import java.util.LinkedHashMap;
 import java.util.Optional;
@@ -41,10 +39,9 @@
 
 public class TestSubmitRule implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
   private final ChangeData.Factory changeDataFactory;
-  private final RulesCache rules;
   private final AccountLoader.Factory accountInfoFactory;
   private final ProjectCache projectCache;
-  private final PrologRule prologRule;
+  private final PrologSubmitRuleUtil prologSubmitRuleUtil;
 
   @Option(name = "--filters", usage = "impact of filters in parent projects")
   private Filters filters = Filters.RUN;
@@ -52,15 +49,13 @@
   @Inject
   TestSubmitRule(
       ChangeData.Factory changeDataFactory,
-      RulesCache rules,
       AccountLoader.Factory infoFactory,
       ProjectCache projectCache,
-      PrologRule prologRule) {
+      PrologSubmitRuleUtil prologSubmitRuleUtil) {
     this.changeDataFactory = changeDataFactory;
-    this.rules = rules;
     this.accountInfoFactory = infoFactory;
     this.projectCache = projectCache;
-    this.prologRule = prologRule;
+    this.prologSubmitRuleUtil = prologSubmitRuleUtil;
   }
 
   @Override
@@ -72,7 +67,7 @@
     if (input.rule == null) {
       throw new BadRequestException("rule is required");
     }
-    if (!rules.isProjectRulesEnabled()) {
+    if (!prologSubmitRuleUtil.isProjectRulesEnabled()) {
       throw new AuthException("project rules are disabled");
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
@@ -84,8 +79,7 @@
     }
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     SubmitRecord record =
-        prologRule.evaluate(
-            cd, PrologOptions.dryRunOptions(input.rule, input.filters == Filters.SKIP));
+        prologSubmitRuleUtil.evaluate(cd, input.rule, input.filters == Filters.SKIP);
 
     AccountLoader accounts = accountInfoFactory.create(true);
     TestSubmitRuleInfo out = newSubmitRuleInfo(record, accounts);
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
index ecb455e..7a47b92 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
@@ -29,25 +29,21 @@
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.rules.PrologOptions;
-import com.google.gerrit.server.rules.PrologRule;
-import com.google.gerrit.server.rules.RulesCache;
+import com.google.gerrit.server.rules.PrologSubmitRuleUtil;
 import com.google.inject.Inject;
 import org.kohsuke.args4j.Option;
 
 public class TestSubmitType implements RestModifyView<RevisionResource, TestSubmitRuleInput> {
   private final ChangeData.Factory changeDataFactory;
-  private final RulesCache rules;
-  private final PrologRule prologRule;
+  private final PrologSubmitRuleUtil prologSubmitRuleUtil;
 
   @Option(name = "--filters", usage = "impact of filters in parent projects")
   private Filters filters = Filters.RUN;
 
   @Inject
-  TestSubmitType(ChangeData.Factory changeDataFactory, RulesCache rules, PrologRule prologRule) {
+  TestSubmitType(ChangeData.Factory changeDataFactory, PrologSubmitRuleUtil prologRule) {
     this.changeDataFactory = changeDataFactory;
-    this.rules = rules;
-    this.prologRule = prologRule;
+    this.prologSubmitRuleUtil = prologRule;
   }
 
   @Override
@@ -59,15 +55,14 @@
     if (input.rule == null) {
       throw new BadRequestException("rule is required");
     }
-    if (!rules.isProjectRulesEnabled()) {
+    if (!prologSubmitRuleUtil.isProjectRulesEnabled()) {
       throw new AuthException("project rules are disabled");
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
 
     ChangeData cd = changeDataFactory.create(rsrc.getNotes());
     SubmitTypeRecord rec =
-        prologRule.getSubmitType(
-            cd, PrologOptions.dryRunOptions(input.rule, input.filters == Filters.SKIP));
+        prologSubmitRuleUtil.getSubmitType(cd, input.rule, input.filters == Filters.SKIP);
 
     if (rec.status != SubmitTypeRecord.Status.OK) {
       throw new BadRequestException(String.format("rule produced invalid result: %s", rec));
diff --git a/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java b/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java
new file mode 100644
index 0000000..a94fb6e
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/PrologSubmitRuleUtil.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.rules;
+
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
+import com.google.gerrit.server.query.change.ChangeData;
+
+/** Provides prolog-related operations to different callers. */
+public interface PrologSubmitRuleUtil {
+
+  /**
+   * Returns the submit-type of a change depending on the change data and the definition of the
+   * prolog rules file.
+   */
+  SubmitTypeRecord getSubmitType(ChangeData cd);
+
+  /**
+   * Returns the submit-type of a change depending on the change data and the definition of the
+   * prolog rules file.
+   */
+  SubmitTypeRecord getSubmitType(ChangeData cd, String ruleToTest, boolean skipFilters);
+
+  /** Evaluates a submit rule. */
+  SubmitRecord evaluate(ChangeData cd, String ruleToTest, boolean skipFilters);
+
+  /** Returns true if prolog rules are enabled for the project. */
+  boolean isProjectRulesEnabled();
+}
diff --git a/java/com/google/gerrit/server/rules/prolog/BUILD b/java/com/google/gerrit/server/rules/prolog/BUILD
new file mode 100644
index 0000000..5e38d06
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/prolog/BUILD
@@ -0,0 +1,23 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "prolog",
+    srcs = glob(
+        ["*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/exceptions",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib/auto:auto-value-annotations",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/prolog:runtime",
+    ],
+)
diff --git a/java/com/google/gerrit/server/rules/PredicateClassLoader.java b/java/com/google/gerrit/server/rules/prolog/PredicateClassLoader.java
similarity index 89%
rename from java/com/google/gerrit/server/rules/PredicateClassLoader.java
rename to java/com/google/gerrit/server/rules/prolog/PredicateClassLoader.java
index 0a7a47f..67b8a60 100644
--- a/java/com/google/gerrit/server/rules/PredicateClassLoader.java
+++ b/java/com/google/gerrit/server/rules/prolog/PredicateClassLoader.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.SetMultimap;
@@ -20,13 +20,12 @@
 import java.util.Collection;
 
 /** Loads the classes for Prolog predicates. */
-public class PredicateClassLoader extends ClassLoader {
+class PredicateClassLoader extends ClassLoader {
 
   private final SetMultimap<String, ClassLoader> packageClassLoaderMap =
       LinkedHashMultimap.create();
 
-  public PredicateClassLoader(
-      PluginSetContext<PredicateProvider> predicateProviders, ClassLoader parent) {
+  PredicateClassLoader(PluginSetContext<PredicateProvider> predicateProviders, ClassLoader parent) {
     super(parent);
 
     predicateProviders.runEach(
diff --git a/java/com/google/gerrit/server/rules/PredicateProvider.java b/java/com/google/gerrit/server/rules/prolog/PredicateProvider.java
similarity index 96%
rename from java/com/google/gerrit/server/rules/PredicateProvider.java
rename to java/com/google/gerrit/server/rules/prolog/PredicateProvider.java
index 57ca7cd..6f38625 100644
--- a/java/com/google/gerrit/server/rules/PredicateProvider.java
+++ b/java/com/google/gerrit/server/rules/prolog/PredicateProvider.java
@@ -11,7 +11,7 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/prolog/PrologEnvironment.java
similarity index 99%
rename from java/com/google/gerrit/server/rules/PrologEnvironment.java
rename to java/com/google/gerrit/server/rules/prolog/PrologEnvironment.java
index 2bf4175..3610c93 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologEnvironment.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.server.AnonymousUser;
diff --git a/java/com/google/gerrit/server/rules/PrologModule.java b/java/com/google/gerrit/server/rules/prolog/PrologModule.java
similarity index 81%
rename from java/com/google/gerrit/server/rules/PrologModule.java
rename to java/com/google/gerrit/server/rules/prolog/PrologModule.java
index ebb5ec0..4b9fad1 100644
--- a/java/com/google/gerrit/server/rules/PrologModule.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologModule.java
@@ -12,12 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.server.rules.RulesCache.RulesCacheModule;
+import com.google.gerrit.server.rules.PrologSubmitRuleUtil;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.rules.prolog.RulesCache.RulesCacheModule;
 import org.eclipse.jgit.lib.Config;
 
 public class PrologModule extends FactoryModule {
@@ -31,9 +33,11 @@
   protected void configure() {
     install(new EnvironmentModule());
     install(new RulesCacheModule(config));
+    bind(RulesCache.class);
     bind(PrologEnvironment.Args.class);
     factory(PrologRuleEvaluator.Factory.class);
 
+    bind(PrologSubmitRuleUtil.class).to(PrologSubmitRuleUtilImpl.class);
     bind(SubmitRule.class).annotatedWith(Exports.named("PrologRule")).to(PrologRule.class);
   }
 
diff --git a/java/com/google/gerrit/server/rules/PrologOptions.java b/java/com/google/gerrit/server/rules/prolog/PrologOptions.java
similarity index 97%
rename from java/com/google/gerrit/server/rules/PrologOptions.java
rename to java/com/google/gerrit/server/rules/prolog/PrologOptions.java
index a176f04..124f527 100644
--- a/java/com/google/gerrit/server/rules/PrologOptions.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologOptions.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.flogger.FluentLogger;
diff --git a/java/com/google/gerrit/server/rules/PrologRule.java b/java/com/google/gerrit/server/rules/prolog/PrologRule.java
similarity index 93%
rename from java/com/google/gerrit/server/rules/PrologRule.java
rename to java/com/google/gerrit/server/rules/prolog/PrologRule.java
index 8f17fa1..13814bb 100644
--- a/java/com/google/gerrit/server/rules/PrologRule.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologRule.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
@@ -21,12 +21,13 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Optional;
 
 @Singleton
-public class PrologRule implements SubmitRule {
+class PrologRule implements SubmitRule {
   private final PrologRuleEvaluator.Factory factory;
   private final ProjectCache projectCache;
 
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/prolog/PrologRuleEvaluator.java
similarity index 99%
rename from java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
rename to java/com/google/gerrit/server/rules/prolog/PrologRuleEvaluator.java
index bfcbffc..3033dd7 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/prolog/PrologRuleEvaluator.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
diff --git a/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
new file mode 100644
index 0000000..3d017e2
--- /dev/null
+++ b/java/com/google/gerrit/server/rules/prolog/PrologSubmitRuleUtilImpl.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.rules.prolog;
+
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.PrologSubmitRuleUtil;
+import com.google.inject.Singleton;
+import javax.inject.Inject;
+
+/** Implementation of {@link PrologSubmitRuleUtil}. */
+@Singleton
+public class PrologSubmitRuleUtilImpl implements PrologSubmitRuleUtil {
+  private final PrologRule prologRule;
+
+  private final RulesCache rulesCache;
+
+  @Inject
+  public PrologSubmitRuleUtilImpl(PrologRule prologRule, RulesCache rulesCache) {
+    this.prologRule = prologRule;
+    this.rulesCache = rulesCache;
+  }
+
+  @Override
+  public SubmitTypeRecord getSubmitType(ChangeData cd) {
+    return prologRule.getSubmitType(cd);
+  }
+
+  @Override
+  public SubmitTypeRecord getSubmitType(ChangeData cd, String ruleToTest, boolean skipFilters) {
+    return prologRule.getSubmitType(cd, PrologOptions.dryRunOptions(ruleToTest, skipFilters));
+  }
+
+  @Override
+  public SubmitRecord evaluate(ChangeData cd, String ruleToTest, boolean skipFilters) {
+    return prologRule.evaluate(cd, PrologOptions.dryRunOptions(ruleToTest, skipFilters));
+  }
+
+  @Override
+  public boolean isProjectRulesEnabled() {
+    return rulesCache.isProjectRulesEnabled();
+  }
+}
diff --git a/java/com/google/gerrit/server/rules/RuleUtil.java b/java/com/google/gerrit/server/rules/prolog/RuleUtil.java
similarity index 89%
rename from java/com/google/gerrit/server/rules/RuleUtil.java
rename to java/com/google/gerrit/server/rules/prolog/RuleUtil.java
index f4e7eff..16fd9af 100644
--- a/java/com/google/gerrit/server/rules/RuleUtil.java
+++ b/java/com/google/gerrit/server/rules/prolog/RuleUtil.java
@@ -12,18 +12,18 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import org.eclipse.jgit.lib.Config;
 
 /** Provides utility methods for configuring and running Prolog rules inside Gerrit. */
-public class RuleUtil {
+class RuleUtil {
 
   /**
    * Returns the reduction limit to be applied to the Prolog machine to prevent infinite loops and
    * other forms of computational overflow.
    */
-  public static int reductionLimit(Config gerritConfig) {
+  static int reductionLimit(Config gerritConfig) {
     int limit = gerritConfig.getInt("rules", null, "reductionLimit", 100000);
     return limit <= 0 ? Integer.MAX_VALUE : limit;
   }
@@ -33,7 +33,7 @@
    * loops and other forms of computational overflow. The compiled reduction limit should be used
    * when user-provided Prolog code is compiled by the interpreter before the limit gets applied.
    */
-  public static int compileReductionLimit(Config gerritConfig) {
+  static int compileReductionLimit(Config gerritConfig) {
     int limit =
         gerritConfig.getInt(
             "rules",
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/prolog/RulesCache.java
similarity index 99%
rename from java/com/google/gerrit/server/rules/RulesCache.java
rename to java/com/google/gerrit/server/rules/prolog/RulesCache.java
index 167b84e..3d36b4f 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/prolog/RulesCache.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
 import static com.googlecode.prolog_cafe.lang.PrologMachineCopy.save;
diff --git a/java/com/google/gerrit/server/rules/StoredValue.java b/java/com/google/gerrit/server/rules/prolog/StoredValue.java
similarity index 98%
rename from java/com/google/gerrit/server/rules/StoredValue.java
rename to java/com/google/gerrit/server/rules/prolog/StoredValue.java
index 593d474..e06ddde 100644
--- a/java/com/google/gerrit/server/rules/StoredValue.java
+++ b/java/com/google/gerrit/server/rules/prolog/StoredValue.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.Prolog;
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/prolog/StoredValues.java
similarity index 99%
rename from java/com/google/gerrit/server/rules/StoredValues.java
rename to java/com/google/gerrit/server/rules/prolog/StoredValues.java
index dbaefb9..774da38 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/prolog/StoredValues.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 7aa3716..a823013 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -23,8 +23,10 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
-import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.EmailModule.MergedChangeEmailFactories;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -49,7 +51,7 @@
   }
 
   private final ExecutorService sendEmailsExecutor;
-  private final MergedSender.Factory mergedSenderFactory;
+  private final MergedChangeEmailFactories mergedChangeEmailFactories;
   private final ThreadLocalRequestContext requestContext;
   private final MessageIdGenerator messageIdGenerator;
 
@@ -63,7 +65,7 @@
   @Inject
   EmailMerge(
       @SendEmailExecutor ExecutorService executor,
-      MergedSender.Factory mergedSenderFactory,
+      MergedChangeEmailFactories mergedChangeEmailFactories,
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
       @Assisted Project.NameKey project,
@@ -73,7 +75,7 @@
       @Assisted RepoView repoView,
       @Assisted String stickyApprovalDiff) {
     this.sendEmailsExecutor = executor;
-    this.mergedSenderFactory = mergedSenderFactory;
+    this.mergedChangeEmailFactories = mergedChangeEmailFactories;
     this.requestContext = requestContext;
     this.messageIdGenerator = messageIdGenerator;
     this.project = project;
@@ -93,18 +95,19 @@
   public void run() {
     RequestContext old = requestContext.setContext(this);
     try {
-      MergedSender emailSender =
-          mergedSenderFactory.create(
+      ChangeEmail changeEmail =
+          mergedChangeEmailFactories.createChangeEmail(
               project,
               change.getId(),
               Optional.ofNullable(Strings.emptyToNull(stickyApprovalDiff)));
+      OutgoingEmail outgoingEmail = mergedChangeEmailFactories.createEmail(changeEmail);
       if (submitter != null) {
-        emailSender.setFrom(submitter.getAccountId());
+        outgoingEmail.setFrom(submitter.getAccountId());
       }
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
+      outgoingEmail.setNotify(notify);
+      outgoingEmail.setMessageId(
           messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
-      emailSender.send();
+      outgoingEmail.send();
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email merged notification for %s", change.getId());
     } finally {
diff --git a/java/com/google/gerrit/server/submit/MergeMetrics.java b/java/com/google/gerrit/server/submit/MergeMetrics.java
index 4c11925..b56d9ef 100644
--- a/java/com/google/gerrit/server/submit/MergeMetrics.java
+++ b/java/com/google/gerrit/server/submit/MergeMetrics.java
@@ -57,15 +57,19 @@
   public void countChangesThatWereSubmittedWithRebaserApproval(ChangeData cd) {
     if (isRebaseOnBehalfOfUploader(cd)
         && hasCodeReviewApprovalOfRealUploader(cd)
+        && !hasCodeReviewApprovalOfUserThatIsNotTheRealUploader(cd)
         && ignoresCodeReviewApprovalsOfUploader(cd)) {
       // 1. The patch set that is being submitted was created by rebasing on behalf of the uploader.
       // The uploader of the patch set is the original uploader on whom's behalf the rebase was
       // done. The real uploader is the user that did the rebase on behalf of the uploader (e.g. by
       // clicking on the rebase button).
       //
-      // 2. The change has Code-Review approvals of the real uploader (aka the rebaser).
+      // 2. The change has a Code-Review approval of the real uploader (aka the rebaser).
       //
-      // 3. Code-Review approvals of the uploader are ignored.
+      // 3. The change doesn't have a Code-Review approval of any other user (a user that is not the
+      // real uploader).
+      //
+      // 4. Code-Review approvals of the uploader are ignored.
       //
       // If instead of a rebase on behalf of the uploader a normal rebase would have been done the
       // rebaser would have been the uploader of the patch set. In this case the Code-Review
@@ -99,6 +103,16 @@
     return hasCodeReviewApprovalOfRealUploader;
   }
 
+  private boolean hasCodeReviewApprovalOfUserThatIsNotTheRealUploader(ChangeData cd) {
+    boolean hasCodeReviewApprovalOfUserThatIsNotTheRealUploader =
+        cd.currentApprovals().stream()
+            .anyMatch(psa -> !psa.accountId().equals(cd.currentPatchSet().realUploader()));
+    logger.atFine().log(
+        "hasCodeReviewApprovalOfUserThatIsNotTheRealUploader = %s",
+        hasCodeReviewApprovalOfUserThatIsNotTheRealUploader);
+    return hasCodeReviewApprovalOfUserThatIsNotTheRealUploader;
+  }
+
   private boolean ignoresCodeReviewApprovalsOfUploader(ChangeData cd) {
     for (SubmitRequirement submitRequirement : cd.submitRequirements().keySet()) {
       try {
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 9250513..88d62b5 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -19,6 +19,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
 import static com.google.common.flogger.LazyArgs.lazy;
+import static com.google.gerrit.common.UsedAt.Project.GOOGLE;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toMap;
@@ -39,6 +40,7 @@
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -52,6 +54,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.RefLogIdentityProvider;
@@ -666,9 +669,14 @@
     }
   }
 
+  // For upstream implementation, AccessPath.WEB_BROWSER is never set, so the method will always
+  // return false.
+  @UsedAt(GOOGLE)
   private boolean indexAsync() {
-    return experimentFeatures.isFeatureEnabled(
-        ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING);
+    return user.getAccessPath().equals(AccessPath.WEB_BROWSER)
+        && experimentFeatures.isFeatureEnabled(
+            ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING,
+            project);
   }
 
   private void fireRefChangeEvent() {
@@ -751,6 +759,8 @@
         }
       }
       if (indexAsync) {
+        logger.atFine().log(
+            "Asynchronously reindexing changes, %s in project %s", results.keySet(), project.get());
         // We want to index asynchronously. However, the callers will await all
         // index futures. This allows us to - even in synchronous case -
         // parallelize indexing changes.
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 948b6e3..102b052 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -17,14 +17,17 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.config.SendEmailExecutor;
-import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
-import com.google.gerrit.server.mail.send.AttentionSetSender;
+import com.google.gerrit.server.mail.EmailModule.AttentionSetChangeEmailFactories;
+import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator;
+import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator.AttentionSetChange;
+import com.google.gerrit.server.mail.send.ChangeEmail;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.mail.send.MessageIdGenerator.MessageId;
-import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
+import com.google.gerrit.server.mail.send.OutgoingEmail;
 import com.google.gerrit.server.update.Context;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -42,15 +45,14 @@
     /**
      * factory for sending an email when adding users to the attention set or removing them from it.
      *
-     * @param sender sender in charge of sending the email, can be {@link AddToAttentionSetSender}
-     *     or {@link RemoveFromAttentionSetSender}.
+     * @param attentionSetChange whether the user is added or removed.
      * @param ctx context for sending the email.
      * @param change the change that the user was added/removed in.
      * @param reason reason for adding/removing the user.
      * @param attentionUserId the user added/removed.
      */
     AttentionSetEmail create(
-        AttentionSetSender sender,
+        AttentionSetChange attentionSetChange,
         Context ctx,
         Change change,
         String reason,
@@ -66,7 +68,8 @@
       ThreadLocalRequestContext requestContext,
       MessageIdGenerator messageIdGenerator,
       AccountTemplateUtil accountTemplateUtil,
-      @Assisted AttentionSetSender sender,
+      AttentionSetChangeEmailFactories attentionSetChangeEmailFactories,
+      @Assisted AttentionSetChange attentionSetChange,
       @Assisted Context ctx,
       @Assisted Change change,
       @Assisted String reason,
@@ -85,8 +88,10 @@
     this.asyncSender =
         new AsyncSender(
             requestContext,
+            attentionSetChangeEmailFactories,
             ctx.getUser(),
-            sender,
+            ctx.getProject(),
+            attentionSetChange,
             messageId,
             ctx.getNotify(change.getId()),
             attentionUserId,
@@ -107,8 +112,10 @@
    */
   private static class AsyncSender implements Runnable, RequestContext {
     private final ThreadLocalRequestContext requestContext;
+    private final AttentionSetChangeEmailFactories attentionSetChangeEmailFactories;
     private final CurrentUser user;
-    private final AttentionSetSender sender;
+    private final AttentionSetChange attentionSetChange;
+    private final Project.NameKey projectId;
     private final MessageIdGenerator.MessageId messageId;
     private final NotifyResolver.Result notify;
     private final Account.Id attentionUserId;
@@ -117,16 +124,20 @@
 
     AsyncSender(
         ThreadLocalRequestContext requestContext,
+        AttentionSetChangeEmailFactories attentionSetChangeEmailFactories,
         CurrentUser user,
-        AttentionSetSender sender,
+        Project.NameKey projectId,
+        AttentionSetChange attentionSetChange,
         MessageIdGenerator.MessageId messageId,
         NotifyResolver.Result notify,
         Account.Id attentionUserId,
         String reason,
         Change.Id changeId) {
       this.requestContext = requestContext;
+      this.attentionSetChangeEmailFactories = attentionSetChangeEmailFactories;
       this.user = user;
-      this.sender = sender;
+      this.projectId = projectId;
+      this.attentionSetChange = attentionSetChange;
       this.messageId = messageId;
       this.notify = notify;
       this.attentionUserId = attentionUserId;
@@ -138,18 +149,27 @@
     public void run() {
       RequestContext old = requestContext.setContext(this);
       try {
+        AttentionSetChangeEmailDecorator changeEmailParams =
+            attentionSetChangeEmailFactories.createAttentionSetChangeEmail();
+        changeEmailParams.setAttentionSetChange(attentionSetChange);
+        changeEmailParams.setAttentionSetUser(attentionUserId);
+        changeEmailParams.setReason(reason);
+        ChangeEmail changeEmail =
+            attentionSetChangeEmailFactories.createChangeEmail(
+                projectId, changeId, changeEmailParams);
+        OutgoingEmail outgoingEmail =
+            attentionSetChangeEmailFactories.createEmail(attentionSetChange, changeEmail);
+
         Optional<Account.Id> accountId =
             user.isIdentifiedUser()
                 ? Optional.of(user.asIdentifiedUser().getAccountId())
                 : Optional.empty();
         if (accountId.isPresent()) {
-          sender.setFrom(accountId.get());
+          outgoingEmail.setFrom(accountId.get());
         }
-        sender.setNotify(notify);
-        sender.setAttentionSetUser(attentionUserId);
-        sender.setReason(reason);
-        sender.setMessageId(messageId);
-        sender.send();
+        outgoingEmail.setNotify(notify);
+        outgoingEmail.setMessageId(messageId);
+        outgoingEmail.send();
       } catch (Exception e) {
         logger.atSevere().withCause(e).log("Cannot email update for change %s", changeId);
       } finally {
diff --git a/java/com/google/gerrit/server/validators/CustomKeyedValueValidationListener.java b/java/com/google/gerrit/server/validators/CustomKeyedValueValidationListener.java
new file mode 100644
index 0000000..f13330d
--- /dev/null
+++ b/java/com/google/gerrit/server/validators/CustomKeyedValueValidationListener.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.validators;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Listener to provide validation of custom keyed values changes. */
+@ExtensionPoint
+public interface CustomKeyedValueValidationListener {
+  /**
+   * Invoked by Gerrit before custom keyed values are changed.
+   *
+   * @param change the change on which the custom keyed values are changed
+   * @param toAdd the custom keyed values to be added
+   * @param toRemove the custom keys to be removed
+   * @throws ValidationException if validation fails
+   */
+  void validateCustomKeyedValues(
+      Change change, ImmutableMap<String, String> toAdd, ImmutableSet<String> toRemove)
+      throws ValidationException;
+}
diff --git a/java/gerrit/AbstractCommitUserIdentityPredicate.java b/java/gerrit/AbstractCommitUserIdentityPredicate.java
index 51c4a3b..8d7e513 100644
--- a/java/gerrit/AbstractCommitUserIdentityPredicate.java
+++ b/java/gerrit/AbstractCommitUserIdentityPredicate.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.Emails;
-import com.google.gerrit.server.rules.PrologEnvironment;
+import com.google.gerrit.server.rules.prolog.PrologEnvironment;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index fea2696..6923c3d 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -9,6 +9,7 @@
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/rules/prolog",
         "//lib:jgit",
         "//lib/flogger:api",
         "//lib/prolog:runtime",
diff --git a/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
index 9a656b8..38af608 100644
--- a/java/gerrit/PRED__load_commit_labels_1.java
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -6,7 +6,7 @@
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.ListTerm;
diff --git a/java/gerrit/PRED_change_branch_1.java b/java/gerrit/PRED_change_branch_1.java
index 62744f7..2bac305 100644
--- a/java/gerrit/PRED_change_branch_1.java
+++ b/java/gerrit/PRED_change_branch_1.java
@@ -15,7 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/java/gerrit/PRED_change_owner_1.java b/java/gerrit/PRED_change_owner_1.java
index f6fbb80..6fcda6c 100644
--- a/java/gerrit/PRED_change_owner_1.java
+++ b/java/gerrit/PRED_change_owner_1.java
@@ -15,7 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_change_project_1.java b/java/gerrit/PRED_change_project_1.java
index b2ef109..f9c7822 100644
--- a/java/gerrit/PRED_change_project_1.java
+++ b/java/gerrit/PRED_change_project_1.java
@@ -15,7 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/java/gerrit/PRED_change_topic_1.java b/java/gerrit/PRED_change_topic_1.java
index f0175ef..cd524e3 100644
--- a/java/gerrit/PRED_change_topic_1.java
+++ b/java/gerrit/PRED_change_topic_1.java
@@ -15,7 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/java/gerrit/PRED_commit_author_3.java b/java/gerrit/PRED_commit_author_3.java
index 3381344..5606a09 100644
--- a/java/gerrit/PRED_commit_author_3.java
+++ b/java/gerrit/PRED_commit_author_3.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
diff --git a/java/gerrit/PRED_commit_committer_3.java b/java/gerrit/PRED_commit_committer_3.java
index 1757336..15ef320 100644
--- a/java/gerrit/PRED_commit_committer_3.java
+++ b/java/gerrit/PRED_commit_committer_3.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Prolog;
diff --git a/java/gerrit/PRED_commit_delta_4.java b/java/gerrit/PRED_commit_delta_4.java
index 502b15b..1d6c1c0 100644
--- a/java/gerrit/PRED_commit_delta_4.java
+++ b/java/gerrit/PRED_commit_delta_4.java
@@ -15,7 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
diff --git a/java/gerrit/PRED_commit_edits_2.java b/java/gerrit/PRED_commit_edits_2.java
index 6083010..23def3a 100644
--- a/java/gerrit/PRED_commit_edits_2.java
+++ b/java/gerrit/PRED_commit_edits_2.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.server.patch.Text;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.patch.filediff.TaggedEdit;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
diff --git a/java/gerrit/PRED_commit_message_1.java b/java/gerrit/PRED_commit_message_1.java
index 3485af6..57a65c1 100644
--- a/java/gerrit/PRED_commit_message_1.java
+++ b/java/gerrit/PRED_commit_message_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/java/gerrit/PRED_commit_parent_count_1.java b/java/gerrit/PRED_commit_parent_count_1.java
index 81589dd..46a5e4d 100644
--- a/java/gerrit/PRED_commit_parent_count_1.java
+++ b/java/gerrit/PRED_commit_parent_count_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_commit_stats_3.java b/java/gerrit/PRED_commit_stats_3.java
index 82fad3d..379dc18 100644
--- a/java/gerrit/PRED_commit_stats_3.java
+++ b/java/gerrit/PRED_commit_stats_3.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_files_1.java b/java/gerrit/PRED_files_1.java
index dbf96da..e97b8ba 100644
--- a/java/gerrit/PRED_files_1.java
+++ b/java/gerrit/PRED_files_1.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.ListTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_get_legacy_label_types_1.java b/java/gerrit/PRED_get_legacy_label_types_1.java
index dfed17b..52247c8 100644
--- a/java/gerrit/PRED_get_legacy_label_types_1.java
+++ b/java/gerrit/PRED_get_legacy_label_types_1.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelValue;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.ListTerm;
diff --git a/java/gerrit/PRED_project_default_submit_type_1.java b/java/gerrit/PRED_project_default_submit_type_1.java
index 77a0261..d99d5f1 100644
--- a/java/gerrit/PRED_project_default_submit_type_1.java
+++ b/java/gerrit/PRED_project_default_submit_type_1.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
diff --git a/java/gerrit/PRED_pure_revert_1.java b/java/gerrit/PRED_pure_revert_1.java
index 19e7b68..b13fbe4 100644
--- a/java/gerrit/PRED_pure_revert_1.java
+++ b/java/gerrit/PRED_pure_revert_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_unresolved_comments_count_1.java b/java/gerrit/PRED_unresolved_comments_count_1.java
index 9a1fcca..c932495 100644
--- a/java/gerrit/PRED_unresolved_comments_count_1.java
+++ b/java/gerrit/PRED_unresolved_comments_count_1.java
@@ -14,7 +14,7 @@
 
 package gerrit;
 
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/java/gerrit/PRED_uploader_1.java b/java/gerrit/PRED_uploader_1.java
index 89e367e..3ab961f 100644
--- a/java/gerrit/PRED_uploader_1.java
+++ b/java/gerrit/PRED_uploader_1.java
@@ -17,7 +17,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.server.rules.StoredValues;
+import com.google.gerrit.server.rules.prolog.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
index f31ae9b..6d51cb4 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -21,7 +21,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.patch.DiffUtil.cleanPatch;
+import static com.google.gerrit.server.patch.DiffUtil.normalizePatchForComparison;
 import static com.google.gerrit.server.patch.DiffUtil.removePatchHeader;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -157,6 +157,28 @@
   }
 
   @Test
+  public void applyValidTraditionalPatch_success() throws Exception {
+    final String fileName = "file_name.txt";
+    final String originalContent = "original line";
+    final String newContent = "new line\n";
+    final String diff =
+        "diff file_name.txt file_name.txt\n"
+            + "--- file_name.txt\n"
+            + "+++ file_name.txt\n"
+            + "@@ -1 +1 @@\n"
+            + "-original line\n"
+            + "+new line\n";
+    initBaseWithFile(fileName, originalContent);
+    ApplyPatchPatchSetInput in = buildInput(diff);
+
+    ChangeInfo result = applyPatch(in);
+
+    DiffInfo fileDiff = fetchDiffForFile(result, fileName);
+    assertDiffForFullyModifiedFile(
+        fileDiff, result.currentRevision, fileName, originalContent, newContent);
+  }
+
+  @Test
   public void applyGerritBasedPatchWithSingleFile_success() throws Exception {
     String head = getHead(repo(), HEAD).name();
     createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
@@ -169,7 +191,8 @@
     ChangeInfo result = applyPatch(in);
 
     BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
-    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+    assertThat(normalizePatchForComparison(resultPatch))
+        .isEqualTo(normalizePatchForComparison(originalPatch));
   }
 
   @Test
@@ -191,7 +214,8 @@
     ChangeInfo result = applyPatch(in);
 
     BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
-    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+    assertThat(normalizePatchForComparison(resultPatch))
+        .isEqualTo(normalizePatchForComparison(originalPatch));
   }
 
   @Test
@@ -214,7 +238,8 @@
 
     resp.assertOK();
     BinaryResult resultPatch = gApi.changes().id(destChange.getChangeId()).current().patch();
-    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+    assertThat(normalizePatchForComparison(resultPatch))
+        .isEqualTo(normalizePatchForComparison(originalPatch));
   }
 
   @Test
@@ -238,7 +263,8 @@
 
     resp.assertOK();
     BinaryResult resultPatch = gApi.changes().id(destChange.getChangeId()).current().patch();
-    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalDecodedPatch));
+    assertThat(normalizePatchForComparison(resultPatch))
+        .isEqualTo(normalizePatchForComparison(originalDecodedPatch));
   }
 
   @Test
@@ -428,7 +454,8 @@
         .isEqualTo(inputParent.getCommit().name());
 
     BinaryResult resultPatch = gApi.changes().id(dest.getChangeId()).current().patch();
-    assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(ADDED_FILE_DIFF));
+    assertThat(normalizePatchForComparison(resultPatch))
+        .isEqualTo(normalizePatchForComparison(ADDED_FILE_DIFF));
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 29ea7f4..ec474f1 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -247,11 +247,14 @@
   private Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = "GerritBackendFeature__return_new_change_info_id")
   public void get() throws Exception {
     PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    ChangeInfo c = info(triplet);
-    assertThat(c.id).isEqualTo(triplet);
+    String id = project.get() + "~" + r.getChange().getId().get();
+    ChangeInfo c = info(id);
+    assertThat(c.id).isEqualTo(id);
     assertThat(c.project).isEqualTo(project.get());
     assertThat(c.branch).isEqualTo("master");
     assertThat(c.status).isEqualTo(ChangeStatus.NEW);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index 5ecb5a7..ade7dc6 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -663,6 +663,87 @@
       assertThat(r1.getPatchSetId().get()).isEqualTo(3);
     }
 
+    private void rebaseWithConflict_strategy(String strategy) throws Exception {
+      String patchSetSubject = "patch set change";
+      String patchSetContent = "patch set content";
+      String baseSubject = "base change";
+      String baseContent = "base content";
+      String expectedContent = strategy.equals("theirs") ? baseContent : patchSetContent;
+
+      PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
+      gApi.changes()
+          .id(r1.getChangeId())
+          .revision(r1.getCommit().name())
+          .review(ReviewInput.approve());
+      gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+      testRepo.reset("HEAD~1");
+      PushOneCommit push =
+          pushFactory.create(
+              admin.newIdent(),
+              testRepo,
+              patchSetSubject,
+              PushOneCommit.FILE_NAME,
+              patchSetContent);
+      PushOneCommit.Result r2 = push.to("refs/for/master");
+      r2.assertOkStatus();
+
+      String changeId = r2.getChangeId();
+      RevCommit patchSet = r2.getCommit();
+      RevCommit base = r1.getCommit();
+
+      TestWorkInProgressStateChangedListener wipStateChangedListener =
+          new TestWorkInProgressStateChangedListener();
+      try (ExtensionRegistry.Registration registration =
+          extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+        RebaseInput rebaseInput = new RebaseInput();
+        rebaseInput.strategy = strategy;
+
+        testMetricMaker.reset();
+        ChangeInfo changeInfo =
+            gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
+        assertThat(changeInfo.containsGitConflicts).isNull();
+        assertThat(changeInfo.workInProgress).isNull();
+
+        // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+        assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false))
+            .isEqualTo(1);
+      }
+      assertThat(wipStateChangedListener.invoked).isFalse();
+      assertThat(wipStateChangedListener.wip).isNull();
+
+      // To get the revisions, we must retrieve the change with more change options.
+      ChangeInfo changeInfo =
+          gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+      assertThat(changeInfo.revisions).hasSize(2);
+      assertThat(changeInfo.getCurrentRevision().commit.parents.get(0).commit)
+          .isEqualTo(base.name());
+
+      // Verify that the file content in the created patch set is correct.
+      BinaryResult bin =
+          gApi.changes().id(changeId).current().file(PushOneCommit.FILE_NAME).content();
+      ByteArrayOutputStream os = new ByteArrayOutputStream();
+      bin.writeTo(os);
+      String fileContent = new String(os.toByteArray(), UTF_8);
+      assertThat(fileContent).isEqualTo(expectedContent);
+
+      // Verify the message that has been posted on the change.
+      List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+      assertThat(messages).hasSize(2);
+      assertThat(Iterables.getLast(messages).message)
+          .isEqualTo("Patch Set 2: Patch Set 1 was rebased");
+    }
+
+    @Test
+    public void rebaseWithConflict_strategyAcceptTheirs() throws Exception {
+      rebaseWithConflict_strategy("theirs");
+    }
+
+    @Test
+    public void rebaseWithConflict_strategyAcceptOurs() throws Exception {
+      rebaseWithConflict_strategy("ours");
+    }
+
     @Test
     public void rebaseWithConflict_conflictsAllowed() throws Exception {
       String patchSetSubject = "patch set change";
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
index 96e3e8e..0fb8a82 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
@@ -1103,6 +1103,86 @@
   }
 
   @Test
+  public void submittedWithRebaserApprovalMetricIsNotIncreasedIfANonRebaserApprovalIsPresent()
+      throws Exception {
+    allowVotingOnCodeReviewToAllUsers();
+
+    createVerifiedLabel();
+    allowVotingOnVerifiedToAllUsers();
+
+    // Require a Code-Review approval from a non-uploader for submit.
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .upsertSubmitRequirement(
+              SubmitRequirement.builder()
+                  .setName(TestLabels.verified().getName())
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create(
+                          String.format("label:%s=MAX", TestLabels.verified().getName())))
+                  .setAllowOverrideInChildProjects(false)
+                  .build());
+      u.getConfig()
+          .upsertSubmitRequirement(
+              SubmitRequirement.builder()
+                  .setName(TestLabels.codeReview().getName())
+                  .setSubmittabilityExpression(
+                      SubmitRequirementExpression.create(
+                          String.format(
+                              "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+                  .setAllowOverrideInChildProjects(false)
+                  .build());
+      u.save();
+    }
+
+    allowPermissionToAllUsers(Permission.REBASE);
+
+    String uploaderEmail = "uploader@example.com";
+    Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+    Account.Id approver = admin.id();
+    Account.Id rebaser = accountOperations.newAccount().create();
+
+    // Create two changes both with the same parent
+    requestScopeOperations.setApiUser(uploader);
+    Change.Id changeToBeTheNewBase =
+        changeOperations.newChange().project(project).owner(uploader).create();
+    Change.Id changeToBeRebased =
+        changeOperations.newChange().project(project).owner(uploader).create();
+
+    // Approve and submit the change that will be the new base for the change that will be rebased.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes()
+        .id(changeToBeTheNewBase.get())
+        .current()
+        .review(ReviewInput.approve().label(TestLabels.verified().getName(), 1));
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+    assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(0);
+
+    // Rebase it on behalf of the uploader
+    requestScopeOperations.setApiUser(rebaser);
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.onBehalfOfUploader = true;
+    gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+    // Approve the change as the rebaser.
+    gApi.changes()
+        .id(changeToBeRebased.get())
+        .current()
+        .review(ReviewInput.approve().label(TestLabels.verified().getName(), 1));
+
+    // Approve the change as another user.
+    requestScopeOperations.setApiUser(approver);
+    gApi.changes().id(changeToBeRebased.get()).current().review(ReviewInput.approve());
+
+    // Due to the second approval the change would also be submittable if the approval of the
+    // rebaser would be ignored due to the rebaser being the uploader.
+    allowPermissionToAllUsers(Permission.SUBMIT);
+    testMetricMaker.reset();
+    gApi.changes().id(changeToBeRebased.get()).current().submit();
+    assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(0);
+  }
+
+  @Test
   public void testCountRebasesMetric() throws Exception {
     allowPermissionToAllUsers(Permission.REBASE);
 
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 09957b3..c9607f5 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -26,11 +25,11 @@
 import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.inject.Inject;
@@ -57,7 +56,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private IndexOperations.Change changeIndexOperations;
   @Inject private IndexOperations.Account accountIndexOperations;
-  @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
+  @Inject SubmitRequirementsEvaluator submitRequirementsEvaluator;
 
   @Test
   @GerritConfig(name = "submodule.enableSuperProjectSubscriptions", value = "false")
@@ -651,7 +650,7 @@
     cherryPickInput.allowConflicts = true;
 
     // The rule will fail if the next change has a submodule file modification with subKey.
-    modifySubmitRulesToBlockSubmoduleChanges(String.format("file('%s','M','SUBMODULE')", subKey));
+    addBlockingSubmodulesSubmitRequirement();
 
     // Cherry-pick the newly created commit which contains a submodule update, to branch "branch".
     ChangeApi changeApi =
@@ -660,8 +659,9 @@
     // Add another file to this change for good measure.
     PushOneCommit.Result result =
         amendChange(changeApi.get().changeId, "subject", "newFile", "content");
+    approve(result.getChangeId());
 
-    assertThat(getStatus(result.getChange())).isEqualTo("NOT_READY");
+    assertThat(getStatus(result.getChange())).isFalse();
     assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isFalse();
   }
 
@@ -673,7 +673,7 @@
     cherryPickInput.allowConflicts = true;
 
     // The rule will fail if the next change has any submodule file.
-    modifySubmitRulesToBlockSubmoduleChanges("file(_,_,'SUBMODULE')");
+    addBlockingSubmodulesSubmitRequirement();
 
     // Cherry-pick the newly created commit which contains a submodule update, to branch "branch".
     ChangeApi changeApi =
@@ -682,19 +682,21 @@
     // Add another file to this change for good measure.
     PushOneCommit.Result result =
         amendChange(changeApi.get().changeId, "subject", "newFile", "content");
+    approve(result.getChangeId());
 
-    assertThat(getStatus(result.getChange())).isEqualTo("NOT_READY");
+    assertThat(getStatus(result.getChange())).isFalse();
     assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isFalse();
   }
 
   @Test
   public void doNotBlockSubmissionWithoutSubmodules() throws Exception {
-    modifySubmitRulesToBlockSubmoduleChanges("file(_,_,'SUBMODULE')");
+    addBlockingSubmodulesSubmitRequirement();
 
     PushOneCommit.Result result =
         createChange(superRepo, "refs/heads/master", "subject", "newFile", "content", null);
+    approve(result.getChangeId());
 
-    assertThat(getStatus(result.getChange())).isEqualTo("OK");
+    assertThat(getStatus(result.getChange())).isTrue();
     assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isTrue();
   }
 
@@ -725,36 +727,22 @@
         .getObjectId();
   }
 
-  private void modifySubmitRulesToBlockSubmoduleChanges(String filePrologQuery) throws Exception {
-    String newContent =
-        String.format(
-            "submit_rule(submit(R)) :-\n"
-                + "  gerrit:includes_file(%s),\n"
-                + "  !,\n"
-                + "  R = label('All-Submodules-Resolved', need(_)).\n"
-                + "submit_rule(submit(label('All-Submodules-Resolved', ok(A)))) :-\n"
-                + "  gerrit:commit_author(A).",
-            filePrologQuery);
-
-    try (Repository repo = repoManager.openRepository(superKey);
-        TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
-      testRepo
-          .branch(RefNames.REFS_CONFIG)
-          .commit()
-          .author(admin.newIdent())
-          .committer(admin.newIdent())
-          .add(RULES_PL_FILE, newContent)
-          .message("Modify rules.pl")
-          .create();
-    }
-    projectCache.evict(superKey);
+  private void addBlockingSubmodulesSubmitRequirement() throws Exception {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = "Block-Submodule-Change";
+    input.submittabilityExpression = "-has:submodule-update";
+    gApi.projects()
+        .name(allProjects.get())
+        .submitRequirement("Block-Submodule-Change")
+        .create(input)
+        .get();
   }
 
-  private String getStatus(ChangeData cd) throws Exception {
+  private boolean getStatus(ChangeData cd) throws Exception {
     try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites();
         AutoCloseable accountIndex = accountIndexOperations.disableReadsAndWrites()) {
-      SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
-      return ruleEvaluator.evaluate(cd).iterator().next().status.toString();
+      return submitRequirementsEvaluator.evaluateAllRequirements(cd).values().stream()
+          .allMatch(SubmitRequirementResult::fulfilled);
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
index c868d0b..2cc4857 100644
--- a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.httpd.restapi.RestApiServlet.SC_CLIENT_CLOSED_REQUEST;
+import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
 import static org.apache.http.HttpStatus.SC_REQUEST_TIMEOUT;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -100,7 +101,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       RestResponse response = adminRestSession.put("/projects/" + name("new"));
-      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded");
     }
   }
@@ -119,7 +120,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       RestResponse response = adminRestSession.put("/projects/" + name("new"));
-      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getEntityContent())
           .isEqualTo("Server Deadline Exceeded\n\ndeadline = 10m");
     }
@@ -140,7 +141,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       RestResponse response = adminRestSession.put("/projects/" + name("new"));
-      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getEntityContent())
           .isEqualTo("Server Deadline Exceeded\n\ndeadline = 10m");
     }
@@ -197,7 +198,7 @@
   public void abortIfServerDeadlineExceeded() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded\n\ntimeout=1ms");
   }
 
@@ -207,7 +208,7 @@
   public void stricterDeadlineTakesPrecedence() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\nfoo.timeout=1ms");
   }
@@ -218,7 +219,7 @@
   public void abortIfServerDeadlineExceeded_requestType() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -229,7 +230,7 @@
   public void abortIfServerDeadlineExceeded_requestUriPattern() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -242,7 +243,7 @@
   public void abortIfServerDeadlineExceeded_excludedRequestUriPattern() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -257,7 +258,7 @@
       throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -268,7 +269,7 @@
   public void abortIfServerDeadlineExceeded_projectPattern() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -279,7 +280,7 @@
   public void abortIfServerDeadlineExceeded_account() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(2));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
   }
@@ -356,7 +357,7 @@
   public void nonAdvisoryDeadlineIsAppliedIfStricterAdvisoryDeadlineExists() throws Exception {
     testTicker.useFakeTicker().setAutoIncrementStep(Duration.ofMillis(4));
     RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
     assertThat(response.getEntityContent())
         .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=2ms");
   }
@@ -462,7 +463,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationValidationListener)) {
       RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
-      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
       assertThat(response.getEntityContent())
           .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=500ms");
     }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 824e01e..cbc3f9d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
@@ -67,6 +68,7 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.change.ReaddOwnerUtil;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -102,6 +104,7 @@
   @Inject private Provider<InternalChangeQuery> changeQueryProvider;
   @Inject private ProjectOperations projectOperations;
   @Inject private GetAttentionSet getAttentionSet;
+  @Inject private ReaddOwnerUtil readdOwnerUtil;
 
   /** Simulates a fake clock. Uses second granularity. */
   private static class FakeClock implements LongSupplier {
@@ -687,9 +690,9 @@
   }
 
   @Test
-  public void reviewersAreNotAddedForNoReasonBecauseOfAnUpdate() throws Exception {
+  public void reviewersAreNotAddedForNoReasonBecauseOfAHashtagUpdate() throws Exception {
     PushOneCommit.Result r = createChange();
-    // implictly adds the user to the attention set when adding as reviewer
+    // implicitly adds the user to the attention set when adding as reviewer
     change(r).addReviewer(user.email());
 
     change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
@@ -705,6 +708,24 @@
   }
 
   @Test
+  public void reviewersAreNotAddedForNoReasonBecauseOfACustomKeyedValuesUpdate() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // implicitly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
+
+    CustomKeyedValuesInput customKeyedValuesInput = new CustomKeyedValuesInput();
+    customKeyedValuesInput.add = ImmutableMap.of("key1", "value1");
+    change(r).setCustomKeyedValues(customKeyedValuesInput);
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("removed");
+  }
+
+  @Test
   public void reviewAddsManuallyAddedUserToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
     requestScopeOperations.setApiUser(user.id());
@@ -2810,6 +2831,44 @@
         .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n");
   }
 
+  @Test
+  @GerritConfig(name = "attentionSet.readdOwnerAfter", value = "1w")
+  @GerritConfig(name = "attentionSet.readdOwnerMessage", value = "Owner has been added")
+  public void readdOwnerForInactiveOpenChanges() throws Exception {
+    // create 2 changes where the owner will be added to the attention-set
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+
+    // ... because they are older than 1 week
+    fakeClock.advance(Duration.ofDays(7));
+
+    // create 1 change where the owner should not be added to the attention-set
+    PushOneCommit.Result r3 = createChange();
+
+    assertThat(r1.getChange().attentionSet()).isEmpty();
+    assertThat(r2.getChange().attentionSet()).isEmpty();
+    assertThat(r3.getChange().attentionSet()).isEmpty();
+
+    sender.clear();
+    readdOwnerUtil.readdOwnerForInactiveOpenChanges(batchUpdateFactory);
+    assertThat(r1.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Owner has been added"));
+    assertThat(r2.getChange().attentionSet())
+        .containsExactly(
+            AttentionSetUpdate.createFromRead(
+                fakeClock.now(),
+                admin.id(),
+                AttentionSetUpdate.Operation.ADD,
+                "Owner has been added"));
+    assertThat(r3.getChange().attentionSet()).isEmpty();
+    assertThat(sender.getMessages()).hasSize(2);
+  }
+
   private void setEmailStrategyForUser(EmailStrategy es) throws Exception {
     requestScopeOperations.setApiUser(user.id());
     GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 1952b32..0550cb9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -1296,6 +1296,19 @@
     }
   }
 
+  @Test
+  public void createChangeWithCustomKeyedValues() throws Exception {
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = "master";
+    changeInput.subject = "A change";
+    changeInput.status = ChangeStatus.NEW;
+    changeInput.customKeyedValues = ImmutableMap.of("key", "value");
+
+    ChangeInfo result = assertCreateSucceeds(changeInput);
+    assertThat(result.customKeyedValues).containsExactly("key", "value");
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java
new file mode 100644
index 0000000..03722e6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CustomKeyedValuesIT.java
@@ -0,0 +1,278 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUES;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEYED_VALUE_LENGTH;
+import static com.google.gerrit.server.notedb.ChangeUpdate.MAX_CUSTOM_KEY_LENGTH;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.truth.MapSubject;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.api.changes.CustomKeyedValuesInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@NoHttpd
+@UseClockStep
+public class CustomKeyedValuesIT extends AbstractDaemonTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void getNoCustomKeyedValues() throws Exception {
+    // Get on a change with no hashtags returns an empty list.
+    PushOneCommit.Result r = createChange();
+    assertThatGet(r).isEmpty();
+  }
+
+  @Test
+  public void addSingleCustomKeyedValue() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+    assertThatGet(r).containsExactly("key1", "value1");
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key2", "value2"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void addInvalidCustomKeyedValue() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> addCustomKeyedValues(r, ImmutableMap.of("key=", "value")));
+    assertThat(thrown).hasMessageThat().contains("custom keys may not contain equals");
+  }
+
+  @Test
+  public void addMultipleCustomKeyedValues() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key3", "value3"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void addAlreadyExistingCustomKeyedValue() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+    assertThatGet(r).containsExactly("key1", "value1");
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value2"));
+    assertThatGet(r).containsExactly("key1", "value2");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void removeSingleCustomKeyedValue() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+    assertThatGet(r).containsExactly("key1", "value1");
+    assertNoNewMessageSince(r, last);
+
+    removeCustomKeys(r, ImmutableSet.of("key1"));
+    assertThatGet(r).containsExactly();
+    assertNoNewMessageSince(r, last);
+
+    // Removing a single custom keyed value returns the other custom keyed values.
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+
+    removeCustomKeys(r, ImmutableSet.of("key1"));
+    assertThatGet(r).containsExactly("key2", "value2");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void removeMultipleCustomKeys() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+    removeCustomKeys(r, ImmutableSet.of("key1", "key2"));
+    assertThatGet(r).containsExactly();
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2", "key3", "value3"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+    assertNoNewMessageSince(r, last);
+    removeCustomKeys(r, ImmutableSet.of("key1", "key2"));
+    assertThatGet(r).containsExactly("key3", "value3");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void removeNotExistingCustomKey() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    removeCustomKeys(r, ImmutableSet.of("key1"));
+    assertThatGet(r).isEmpty();
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1"));
+    assertThatGet(r).containsExactly("key1", "value1");
+    assertNoNewMessageSince(r, last);
+    removeCustomKeys(r, ImmutableSet.of("key2"));
+    assertThatGet(r).containsExactly("key1", "value1");
+    assertNoNewMessageSince(r, last);
+
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2", "key3", "value3"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+    assertNoNewMessageSince(r, last);
+    removeCustomKeys(r, ImmutableSet.of("key4"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2", "key3", "value3");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void addAndRemove() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ChangeMessageInfo last = getLastMessage(r);
+    addCustomKeyedValues(r, ImmutableMap.of("key1", "value1", "key2", "value2"));
+    assertThatGet(r).containsExactly("key1", "value1", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+
+    // Adding and removing the same key updates it
+    CustomKeyedValuesInput input = new CustomKeyedValuesInput();
+    input.add = ImmutableMap.of("key1", "value3");
+    input.remove = ImmutableSet.of("key1");
+    change(r).setCustomKeyedValues(input);
+    assertThatGet(r).containsExactly("key1", "value3", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+
+    // Adding and removing same key with same value is a no-op.
+    input = new CustomKeyedValuesInput();
+    input.add = ImmutableMap.of("key1", "value3");
+    input.remove = ImmutableSet.of("key1");
+    change(r).setCustomKeyedValues(input);
+    assertThatGet(r).containsExactly("key1", "value3", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+
+    // Adding and removing separate keys should work as expected.
+    input = new CustomKeyedValuesInput();
+    input.add = ImmutableMap.of("key4", "value4");
+    input.remove = ImmutableSet.of("key1");
+    change(r).setCustomKeyedValues(input);
+    assertThatGet(r).containsExactly("key4", "value4", "key2", "value2");
+    assertNoNewMessageSince(r, last);
+  }
+
+  @Test
+  public void addCustomKeyedValuesWithoutPermissionNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown =
+        assertThrows(
+            AuthException.class, () -> addCustomKeyedValues(r, ImmutableMap.of("key1", "value1")));
+    assertThat(thrown).hasMessageThat().contains("edit custom keyed values not permitted");
+  }
+
+  @Test
+  public void addCustomKeyedValueKeyTooLongNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                addCustomKeyedValues(
+                    r, ImmutableMap.of("k".repeat(MAX_CUSTOM_KEY_LENGTH + 1), "value1")));
+    assertThat(thrown).hasMessageThat().contains("Custom Key is too long.");
+  }
+
+  @Test
+  public void addCustomKeyedValueValueTooLongNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () ->
+                addCustomKeyedValues(
+                    r, ImmutableMap.of("key1", "v".repeat(MAX_CUSTOM_KEYED_VALUE_LENGTH + 1))));
+    assertThat(thrown).hasMessageThat().contains("Custom Keyed value is too long.");
+  }
+
+  @Test
+  public void addCustomKeyedValueTooManyKeyedValuesNotAllowed() throws Exception {
+    PushOneCommit.Result r = createChange();
+    ImmutableMap.Builder<String, String> input = ImmutableMap.builder();
+    for (int i = 0; i <= MAX_CUSTOM_KEYED_VALUES; i++) {
+      input.put("key" + i, "value" + i);
+    }
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> addCustomKeyedValues(r, input.build()));
+    assertThat(thrown).hasMessageThat().contains("Too many custom keyed values.");
+  }
+
+  private MapSubject assertThatGet(PushOneCommit.Result r) throws Exception {
+    return assertThat(change(r).getCustomKeyedValues());
+  }
+
+  private void addCustomKeyedValues(PushOneCommit.Result r, ImmutableMap<String, String> toAdd)
+      throws Exception {
+    CustomKeyedValuesInput input = new CustomKeyedValuesInput();
+    input.add = toAdd;
+    change(r).setCustomKeyedValues(input);
+  }
+
+  private void removeCustomKeys(PushOneCommit.Result r, ImmutableSet<String> toRemove)
+      throws Exception {
+    CustomKeyedValuesInput input = new CustomKeyedValuesInput();
+    input.remove = toRemove;
+    change(r).setCustomKeyedValues(input);
+  }
+
+  private void assertNoNewMessageSince(PushOneCommit.Result r, ChangeMessageInfo expected)
+      throws Exception {
+    requireNonNull(expected);
+    ChangeMessageInfo last = getLastMessage(r);
+    assertThat(last.message).isEqualTo(expected.message);
+    assertThat(last.id).isEqualTo(expected.id);
+  }
+
+  private ChangeMessageInfo getLastMessage(PushOneCommit.Result r) throws Exception {
+    ChangeMessageInfo lastMessage = Iterables.getLast(change(r).get().messages, null);
+    assertWithMessage(lastMessage.message).that(lastMessage).isNotNull();
+    return lastMessage;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 15baa78..abaf98f 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -1262,6 +1262,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(0).id
+                + "?usp=email"
                 + " :\n"
                 + "PS1, Line 1: initial\n"
                 + "what happened to this?\n"
@@ -1274,6 +1275,7 @@
                 + c
                 + "/comment/"
                 + ps1List.get(1).id
+                + "?usp=email"
                 + " :\n"
                 + "PS1, Line 1: boring\n"
                 + "Is it that bad?\n"
@@ -1288,6 +1290,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(0).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 1: initial content\n"
                 + "comment 1 on base\n"
@@ -1300,6 +1303,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(1).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 2: \n"
                 + "comment 2 on base\n"
@@ -1312,6 +1316,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(2).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 1: interesting\n"
                 + "better now\n"
@@ -1324,6 +1329,7 @@
                 + c
                 + "/comment/"
                 + ps2List.get(3).id
+                + "?usp=email"
                 + " :\n"
                 + "PS2, Line 2: cntent\n"
                 + "typo: content\n"
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index f728995..5e00230 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -62,7 +62,7 @@
     Map<String, EmailHeader> headers = sender.getMessages().iterator().next().headers();
     String hostname = URI.create(canonicalWebUrl.get()).getHost();
     String listId = String.format("<gerrit-%s.%s>", project.get(), hostname);
-    String unsubscribeLink = String.format("<%ssettings>", canonicalWebUrl.get());
+    String unsubscribeLink = String.format("<%ssettings?usp=email>", canonicalWebUrl.get());
     String threadId =
         String.format(
             "<gerrit.%s.%s@%s>",
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
index 6e67d5f..e13661e 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
@@ -87,6 +87,35 @@
   }
 
   @Test
+  public void pluginConfig_inheritanceCanOverrideValuesAndKeepsRest() throws Exception {
+    try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(allProjects)) {
+      u.getConfig()
+          .updatePluginConfig(
+              "important-plugin2",
+              cfg -> {
+                cfg.setString("key", "kept");
+                cfg.setString("key2", "my-plugin-value2");
+              });
+      u.save();
+    }
+
+    try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updatePluginConfig(
+              "important-plugin2",
+              cfg -> {
+                cfg.setString("key2", "overridden");
+              });
+      u.save();
+    }
+
+    PluginConfig pluginConfig =
+        pluginConfigFactory.getFromProjectConfigWithInheritance(project, "important-plugin2");
+    assertThat(pluginConfig.getString("key")).isEqualTo("kept");
+    assertThat(pluginConfig.getString("key2")).isEqualTo("overridden");
+  }
+
+  @Test
   public void allProjectsProjectsConfig_ChangeInFileInvalidatesPersistedCache() throws Exception {
     assertThat(projectCache.getAllProjects().getConfig().getCheckReceivedObjects()).isTrue();
     // Change etc/All-Projects-project.config
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/BUILD b/javatests/com/google/gerrit/acceptance/server/rules/BUILD
index 1f547f7..911c85c 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/rules/BUILD
@@ -4,7 +4,4 @@
     srcs = glob(["*IT.java"]),
     group = "server_rules",
     labels = ["server"],
-    deps = [
-        "@prolog-runtime//jar",
-    ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/prolog/BUILD b/javatests/com/google/gerrit/acceptance/server/rules/prolog/BUILD
new file mode 100644
index 0000000..03c24a2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/rules/prolog/BUILD
@@ -0,0 +1,11 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "prolog_rules",
+    labels = ["server"],
+    deps = [
+        "//java/com/google/gerrit/server/rules/prolog",
+        "//lib/prolog:runtime",
+    ],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/prolog/PrologRuleEvaluatorIT.java
similarity index 96%
rename from javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
rename to javatests/com/google/gerrit/acceptance/server/rules/prolog/PrologRuleEvaluatorIT.java
index bf8b1f8..850fe8e 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/prolog/PrologRuleEvaluatorIT.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance.server.rules;
+package com.google.gerrit.acceptance.server.rules.prolog;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -23,8 +23,8 @@
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.rules.PrologOptions;
-import com.google.gerrit.server.rules.PrologRuleEvaluator;
+import com.google.gerrit.server.rules.prolog.PrologOptions;
+import com.google.gerrit.server.rules.prolog.PrologRuleEvaluator;
 import com.google.gerrit.testing.TestChanges;
 import com.google.inject.Inject;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
similarity index 99%
rename from javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
rename to javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
index 2938065..74bdb56 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/prolog/RulesIT.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance.server.rules;
+package com.google.gerrit.acceptance.server.rules.prolog;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index bbf10bd..296d801 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.inject.TypeLiteral;
 import java.lang.reflect.Type;
 import java.time.Instant;
 import org.junit.Test;
@@ -284,6 +285,9 @@
                 .put("currentPatchSetId", int.class)
                 .put("subject", String.class)
                 .put("topic", String.class)
+                .put(
+                    "customKeyedValues",
+                    new TypeLiteral<ImmutableMap<String, String>>() {}.getType())
                 .put("originalSubject", String.class)
                 .put("submissionId", String.class)
                 .put("isPrivate", boolean.class)
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
index 8bafafe..c360b2f 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyCheckerTest.java
@@ -279,7 +279,6 @@
 
   private PGPPublicKeyRing removeRevokers(PGPPublicKeyRing kr) {
     PGPPublicKey k = kr.getPublicKey();
-    @SuppressWarnings("unchecked")
     Iterator<PGPSignature> sigs = k.getSignaturesOfType(DIRECT_KEY);
     while (sigs.hasNext()) {
       PGPSignature sig = sigs.next();
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
index 390aa84..0aaa437 100644
--- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
@@ -103,6 +104,26 @@
   }
 
   @Test
+  public void customKeyedValuesChangedEvent() {
+    Change change = newChange();
+    CustomKeyedValuesChangedEvent orig = new CustomKeyedValuesChangedEvent(change);
+    orig.change = asChangeAttribute(change);
+    orig.editor = newAccount("editor");
+    orig.added = ImmutableMap.of("key1", "value1");
+    orig.removed = new String[] {"removed"};
+    orig.customKeyedValues = ImmutableMap.of("key2", "value2");
+
+    CustomKeyedValuesChangedEvent e = roundTrip(orig);
+
+    assertThat(e).isNotNull();
+    assertSameChangeEvent(e, orig);
+    assertSameAccount(e.editor, orig.editor);
+    assertThat(e.added).isEqualTo(orig.added);
+    assertThat(e.removed).isEqualTo(orig.removed);
+    assertThat(e.customKeyedValues).isEqualTo(orig.customKeyedValues);
+  }
+
+  @Test
   public void changeAbandonedEvent() {
     Change change = newChange();
     ChangeAbandonedEvent orig = new ChangeAbandonedEvent(change);
diff --git a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java b/javatests/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorTest.java
similarity index 68%
rename from javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
rename to javatests/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorTest.java
index d7a6282..ae45209 100644
--- a/javatests/com/google/gerrit/server/mail/send/CommentSenderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/CommentChangeEmailDecoratorTest.java
@@ -18,44 +18,41 @@
 
 import java.util.Collections;
 import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
 
-public class CommentSenderTest {
-  private static class TestSender extends CommentSender {
-    TestSender() {
-      super(null, null, null, null, null, null, null);
-    }
-  }
-
+@RunWith(JUnit4.class)
+public class CommentChangeEmailDecoratorTest {
   // A 100-character long string.
   private static String chars100 = String.join("", Collections.nCopies(25, "abcd"));
 
   @Test
   public void shortMessageNotShortened() {
     String message = "foo bar baz";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(message);
+    assertThat(CommentChangeEmailDecorator.getShortenedCommentMessage(message)).isEqualTo(message);
 
     message = "foo bar baz.";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(message);
+    assertThat(CommentChangeEmailDecorator.getShortenedCommentMessage(message)).isEqualTo(message);
   }
 
   @Test
   public void longMessageIsShortened() {
     String message = chars100 + "x";
     String expected = chars100 + " […]";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+    assertThat(CommentChangeEmailDecorator.getShortenedCommentMessage(message)).isEqualTo(expected);
   }
 
   @Test
   public void shortenedToFirstLine() {
     String message = "abc\n" + chars100;
     String expected = "abc […]";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+    assertThat(CommentChangeEmailDecorator.getShortenedCommentMessage(message)).isEqualTo(expected);
   }
 
   @Test
   public void shortenedToFirstSentence() {
     String message = "foo bar baz. " + chars100;
     String expected = "foo bar baz. […]";
-    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+    assertThat(CommentChangeEmailDecorator.getShortenedCommentMessage(message)).isEqualTo(expected);
   }
 }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 9a29230..04ff4fc 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -65,6 +65,7 @@
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import java.time.Instant;
+import java.util.AbstractMap.SimpleEntry;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -334,6 +335,23 @@
   }
 
   @Test
+  public void serializeCustomKeyedValues() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .customKeyedValues(
+                ImmutableList.of(
+                    new SimpleEntry<>("key1", "value1"), new SimpleEntry<>("key2", "value2")))
+            .build(),
+        ChangeNotesStateProto.newBuilder()
+            .setMetaId(SHA_BYTES)
+            .setChangeId(ID.get())
+            .setColumns(colsProto)
+            .putCustomKeyedValues("key1", "value1")
+            .putCustomKeyedValues("key2", "value2")
+            .build());
+  }
+
+  @Test
   public void serializePatchSets() throws Exception {
     PatchSet ps1 =
         PatchSet.builder()
@@ -918,6 +936,9 @@
                 .put("columns", ChangeColumns.class)
                 .put("hashtags", new TypeLiteral<ImmutableSet<String>>() {}.getType())
                 .put(
+                    "customKeyedValues",
+                    new TypeLiteral<ImmutableList<Map.Entry<String, String>>>() {}.getType())
+                .put(
                     "patchSets",
                     new TypeLiteral<ImmutableList<Map.Entry<PatchSet.Id, PatchSet>>>() {}.getType())
                 .put(
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 15eefcd..69b1870 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -34,6 +34,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
@@ -67,6 +68,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.TreeMap;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -1504,6 +1506,78 @@
   }
 
   @Test
+  public void customKeyedValuesCommit() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value1");
+    update.addCustomKeyedValue("key2", "value2");
+    update.commit();
+    try (RevWalk walk = new RevWalk(repo)) {
+      RevCommit commit = walk.parseCommit(update.getResult());
+      walk.parseBody(commit);
+      assertThat(commit.getFullMessage()).contains("Custom-Keyed-Value: key1=value1\n");
+      assertThat(commit.getFullMessage()).contains("Custom-Keyed-Value: key2=value2\n");
+    }
+  }
+
+  @Test
+  public void customKeyedValuesChangeNotes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value\n1");
+    update.addCustomKeyedValue("key2", "value2=value3");
+    update.addCustomKeyedValue("key3", "value3: value4");
+    update.commit();
+
+    TreeMap<String, String> customKeyedValues = new TreeMap<>();
+    customKeyedValues.put("key1", "value 1");
+    customKeyedValues.put("key2", "value2=value3");
+    customKeyedValues.put("key3", "value3: value4");
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getCustomKeyedValues())
+        .isEqualTo(ImmutableSortedMap.copyOfSorted(customKeyedValues));
+  }
+
+  @Test
+  public void customKeyedValuesChangeNotes_Override() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value1");
+    update.addCustomKeyedValue("key2", "value2");
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value3");
+    update.commit();
+
+    TreeMap<String, String> customKeyedValues = new TreeMap<>();
+    customKeyedValues.put("key1", "value3");
+    customKeyedValues.put("key2", "value2");
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getCustomKeyedValues())
+        .isEqualTo(ImmutableSortedMap.copyOfSorted(customKeyedValues));
+  }
+
+  @Test
+  public void customKeyedValuesChangeNotes_Deletion() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.addCustomKeyedValue("key1", "value1");
+    update.addCustomKeyedValue("key2", "value2");
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.deleteCustomKeyedValue("key1");
+    update.commit();
+
+    TreeMap<String, String> customKeyedValues = new TreeMap<>();
+    customKeyedValues.put("key2", "value2");
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getCustomKeyedValues())
+        .isEqualTo(ImmutableSortedMap.copyOfSorted(customKeyedValues));
+  }
+
+  @Test
   public void topicChangeNotes() throws Exception {
     Change c = newChange();
 
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index a188251..688e5e4 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -83,6 +83,7 @@
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -2886,7 +2887,8 @@
   }
 
   @Test
-  public void byStar() throws Exception {
+  public void byStar_withStarOptionSet() throws Exception {
+    // When star option is set, the 'starred' field is set in the change infos in response.
     repo = createAndOpenProject("repo");
     Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
 
@@ -2899,6 +2901,32 @@
     // check default star
     assertQuery("has:star", change1);
     assertQuery("is:starred", change1);
+
+    // The 'Star' bit in the change data is also set correctly
+    List<ChangeInfo> changeInfos =
+        gApi.changes().query("has:star").withOptions(ListChangesOption.STAR).get();
+    assertThat(changeInfos.get(0).starred).isTrue();
+  }
+
+  @Test
+  public void byStar_withStarOptionNotSet() throws Exception {
+    // When star option is not set, the 'starred' field is not set in the change infos in response.
+    repo = createAndOpenProject("repo");
+    Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    requestContext.setContext(newRequestContext(user2));
+
+    gApi.accounts().self().starChange(change1.getId().toString());
+
+    // check default star
+    assertQuery("has:star", change1);
+    assertQuery("is:starred", change1);
+
+    // The 'Star' bit in the change data is not set if the backfilling option is not set
+    List<ChangeInfo> changeInfos = gApi.changes().query("has:star").get();
+    assertThat(changeInfos.get(0).starred).isNull();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
index 5c57ede..3732bd4 100644
--- a/javatests/com/google/gerrit/server/rules/BUILD
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -1,22 +1,11 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
 junit_tests(
-    name = "prolog_tests",
+    name = "rules_tests",
     srcs = glob(["*.java"]),
-    resource_strip_prefix = "prologtests",
-    resources = ["//prologtests:gerrit_common_test"],
-    runtime_deps = ["//prolog:gerrit-prolog-common"],
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/project/testing:project-test-util",
-        "//java/com/google/gerrit/server/util/time",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:guava",
-        "//lib:jgit",
-        "//lib/guice",
-        "//lib/mockito",
-        "//lib/prolog:runtime",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/rules/prolog/BUILD b/javatests/com/google/gerrit/server/rules/prolog/BUILD
new file mode 100644
index 0000000..ce02a06
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/prolog/BUILD
@@ -0,0 +1,23 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "prolog_tests",
+    srcs = glob(["*.java"]),
+    resource_strip_prefix = "prologtests",
+    resources = ["//prologtests:gerrit_common_test"],
+    runtime_deps = ["//prolog:gerrit-prolog-common"],
+    deps = [
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/server/rules/prolog",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib/guice",
+        "//lib/mockito",
+        "//lib/prolog:runtime",
+        "//lib/truth",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/prolog/GerritCommonTest.java
similarity index 98%
rename from javatests/com/google/gerrit/server/rules/GerritCommonTest.java
rename to javatests/com/google/gerrit/server/rules/prolog/GerritCommonTest.java
index 871c871..4f9863e 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/prolog/GerritCommonTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
diff --git a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java b/javatests/com/google/gerrit/server/rules/prolog/PrologRuleEvaluatorTest.java
similarity index 96%
rename from javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
rename to javatests/com/google/gerrit/server/rules/prolog/PrologRuleEvaluatorTest.java
index a5357e1..703ed7b 100644
--- a/javatests/com/google/gerrit/server/rules/PrologRuleEvaluatorTest.java
+++ b/javatests/com/google/gerrit/server/rules/prolog/PrologRuleEvaluatorTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.common.truth.Truth.assertThat;
 
diff --git a/javatests/com/google/gerrit/server/rules/PrologTestCase.java b/javatests/com/google/gerrit/server/rules/prolog/PrologTestCase.java
similarity index 98%
rename from javatests/com/google/gerrit/server/rules/PrologTestCase.java
rename to javatests/com/google/gerrit/server/rules/prolog/PrologTestCase.java
index c2b6dbb..52a8314 100644
--- a/javatests/com/google/gerrit/server/rules/PrologTestCase.java
+++ b/javatests/com/google/gerrit/server/rules/prolog/PrologTestCase.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.rules;
+package com.google.gerrit.server.rules.prolog;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
diff --git a/plugins/delete-project b/plugins/delete-project
index b183ee5..0322a37 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit b183ee5230273670f3235cc5b3cf32562ccfb7ee
+Subproject commit 0322a37009071da525bfd8569e98538c2e8891d5
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 4bf253d..b968553 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -302,11 +302,15 @@
   code_range: LineRange;
 }
 
-/** LOST LineNumber is for ported comments without a range, they have their own
- *  line number and are added on top of the FILE row in gr-diff
+/**
+ * LOST LineNumber is for ported comments without a range, they have their own
+ * line number and are added on top of the FILE row in <gr-diff>.
  */
 export declare type LineNumber = number | 'FILE' | 'LOST';
 
+export const FILE: LineNumber = 'FILE';
+export const LOST: LineNumber = 'LOST';
+
 /** The detail of the 'create-comment' event dispatched by gr-diff. */
 export declare interface CreateCommentEventDetail {
   side: Side;
@@ -360,6 +364,7 @@
 export declare interface DiffContextExpandedExternalDetail {
   expandedLines: number;
   buttonType: ContextButtonType;
+  numLines: number;
 }
 
 /**
diff --git a/polygerrit-ui/app/api/package-lock.json b/polygerrit-ui/app/api/package-lock.json
new file mode 100644
index 0000000..75fb4dc
--- /dev/null
+++ b/polygerrit-ui/app/api/package-lock.json
@@ -0,0 +1,5 @@
+{
+  "name": "@gerritcodereview/typescript-api",
+  "version": "3.8.0",
+  "lockfileVersion": 1
+}
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 244002e..5b6019e 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -367,6 +367,7 @@
   topic?: TopicName;
   attention_set?: IdToAttentionSetMap;
   hashtags?: Hashtag[];
+  custom_keyed_values?: CustomKeyedValues;
   change_id: ChangeId;
   subject: string;
   status: ChangeStatus;
@@ -732,6 +733,12 @@
 
 export type Hashtag = BrandType<string, '_hashtag'>;
 
+export type CustomKey = BrandType<string, '_custom_key'>;
+export type CustomValue = BrandType<string, '_custom_value'>;
+
+// A map from CustomKey to CustomValue
+export type CustomKeyedValues = {[key: CustomKey]: CustomValue};
+
 export type IdToAttentionSetMap = {[accountId: string]: AttentionSetInfo};
 
 /**
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index ad59edd..ef2f63e 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -10,6 +10,7 @@
   STARTED_AS_GUEST = 'Started as guest',
   VISIBILILITY_HIDDEN = 'Visibility changed to hidden',
   VISIBILILITY_VISIBLE = 'Visibility changed to visible',
+  FOCUS = 'Focus changed',
   EXTENSION_DETECTED = 'Extension detected',
   PLUGINS_INSTALLED = 'Plugins installed',
   PLUGINS_FAILED = 'Some plugins failed to load',
@@ -95,6 +96,12 @@
   LCP = 'LCP',
   // WebVitals - Interaction to Next Paint (INP): measures responsiveness
   INP = 'INP',
+  // Time to load preview for a user suggested edit or a fix from checks
+  PREVIEW_FIX_LOAD = 'PreviewFixLoad',
+  // Time to apply fix for a user suggested edit or a fix from checks
+  APPLY_FIX_LOAD = 'ApplyFixLoad',
+  // Time to copy target to clipboard
+  COPY_TO_CLIPBOARD = 'CopyToClipboard',
 }
 
 export enum Interaction {
@@ -127,4 +134,6 @@
   CHANGE_ACTION_FIRED = 'change-action-fired',
   BUTTON_CLICK = 'button-click',
   LINK_CLICK = 'link-click',
+  USER_ACTIVE = 'user-active',
+  USER_PASSIVE = 'user-passive',
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 7f03d1d..ff7a528 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -127,8 +127,9 @@
   }
 
   override willUpdate(changedProperties: PropertyValues<GrPermission>): void {
-    if (changedProperties.has('editing')) {
-      this.handleEditingChanged(changedProperties.get('editing'));
+    const oldEditing = changedProperties.get('editing');
+    if (oldEditing !== null && oldEditing !== undefined) {
+      this.handleEditingChanged(oldEditing);
     }
     if (
       changedProperties.has('permission') ||
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 19207bc..e656404 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -44,6 +44,7 @@
 import {createChangeUrl} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {configModelToken} from '../../../models/config/config-model';
 
 enum ChangeSize {
   XS = 10,
@@ -74,9 +75,16 @@
 
 @customElement('gr-change-list-item')
 export class GrChangeListItem extends LitElement {
-  /** The logged-in user's account, or null if no user is logged in. */
+  /** The logged-in user's account, or undefined if no user is logged in. */
   @property({type: Object})
-  account: AccountInfo | null = null;
+  loggedInUser?: AccountInfo;
+
+  /**
+   * When the list is part of the dashboard, the user for which the dashboard is
+   * generated.
+   */
+  @property({type: String})
+  dashboardUser?: string;
 
   @property({type: Array})
   visibleChangeTableColumns?: string[];
@@ -87,9 +95,6 @@
   @property({type: Object})
   change?: ChangeInfo;
 
-  @property({type: Object})
-  config?: ServerInfo;
-
   /** Name of the section in the change-list. Used for reporting. */
   @property({type: String})
   sectionName?: string;
@@ -113,6 +118,9 @@
   // private but used in tests
   @property({type: Boolean, reflect: true}) checked = false;
 
+  @state()
+  config?: ServerInfo;
+
   @state() private dynamicCellEndpoints?: string[];
 
   private readonly reporting = getAppContext().reportingService;
@@ -121,6 +129,8 @@
 
   private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
   private readonly getUserModel = resolve(this, userModelToken);
@@ -141,6 +151,13 @@
       () => this.getUserModel().loggedIn$,
       isLoggedIn => (this.isLoggedIn = isLoggedIn)
     );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.config = config;
+      }
+    );
   }
 
   override connectedCallback() {
@@ -754,9 +771,9 @@
         !isServiceUser(r)
     );
     reviewers.sort((r1, r2) => {
-      if (this.account) {
-        if (isSelf(r1, this.account)) return -1;
-        if (isSelf(r2, this.account)) return 1;
+      if (this.loggedInUser) {
+        if (isSelf(r1, this.loggedInUser)) return -1;
+        if (isSelf(r2, this.loggedInUser)) return 1;
       }
       if (this.hasAttention(r1) && !this.hasAttention(r2)) return -1;
       if (this.hasAttention(r2) && !this.hasAttention(r1)) return 1;
@@ -815,9 +832,15 @@
   }
 
   private computeWaiting(): Timestamp | undefined {
-    if (!this.account?._account_id || !this.change?.attention_set)
-      return undefined;
-    return this.change?.attention_set[this.account._account_id]?.last_update;
+    // TODO: dashboardUser comes from DashboardViewState and can be an
+    // Email Address. In this case the attention_set lookup will return
+    // undefined.
+    const userId =
+      this.dashboardUser === 'self'
+        ? this.loggedInUser?._account_id
+        : this.dashboardUser;
+    if (!userId || !this.change?.attention_set) return undefined;
+    return this.change?.attention_set[userId]?.last_update;
   }
 
   private computeIsColumnHidden(
@@ -839,7 +862,7 @@
     // Don't prevent the default and neither stop bubbling. We just want to
     // report the click, but then let the browser handle the click on the link.
 
-    const selfId = (this.account && this.account._account_id) || -1;
+    const selfId = (this.loggedInUser && this.loggedInUser._account_id) || -1;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index 5e31cc8..25e97af 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -243,7 +243,9 @@
     attSetIds: number[],
     expected: number[]
   ) {
-    element.account = userId ? {_account_id: userId as AccountId} : null;
+    element.loggedInUser = userId
+      ? {_account_id: userId as AccountId}
+      : undefined;
     element.change = {
       ...change,
       owner: {
@@ -384,7 +386,7 @@
       registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
     });
     element.showNumber = true;
-    element.account = createAccountWithId(1);
+    element.loggedInUser = createAccountWithId(1);
     element.config = createServerInfo();
     element.change = change;
     await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index 61b276e..5e99df2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -9,7 +9,7 @@
 import '../gr-change-list-action-bar/gr-change-list-action-bar';
 import {CLOSED, YOUR_TURN} from '../../../utils/dashboard-util';
 import {getAppContext} from '../../../services/app-context';
-import {ChangeInfo, ServerInfo, AccountInfo} from '../../../api/rest-api';
+import {ChangeInfo, AccountInfo} from '../../../api/rest-api';
 import {changeListStyles} from '../../../styles/gr-change-list-styles';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -67,9 +67,6 @@
   @property({type: Object})
   changeSection!: ChangeListSection;
 
-  @property({type: Object})
-  config?: ServerInfo;
-
   @property({type: Boolean})
   isCursorMoving = false;
 
@@ -78,7 +75,14 @@
    * in.
    */
   @property({type: Object})
-  account: AccountInfo | undefined = undefined;
+  loggedInUser?: AccountInfo;
+
+  /**
+   * When the list is part of the dashboard, the user for which the dashboard is
+   * generated.
+   */
+  @property({type: String})
+  dashboardUser?: string;
 
   @property({type: String})
   usp?: string;
@@ -322,10 +326,10 @@
     return html`
       <gr-change-list-item
         tabindex="0"
-        .account=${this.account}
+        .loggedInUser=${this.loggedInUser}
+        .dashboardUser=${this.dashboardUser}
         .selected=${selected}
         .change=${change}
-        .config=${this.config}
         .sectionName=${this.changeSection.name}
         .visibleChangeTableColumns=${columns}
         .showNumber=${this.showNumber}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
index 63552c7..4ec8491 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
@@ -56,7 +56,7 @@
     };
     element = await fixture<GrChangeListSection>(
       html`<gr-change-list-section
-        .account=${createAccountDetailWithId(1)}
+        .loggedInUser=${createAccountDetailWithId(1)}
         .config=${createServerInfo()}
         .visibleChangeTableColumns=${Object.values(ColumnNames)}
         .changeSection=${changeSection}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 96b01e1..dbca42f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -11,7 +11,6 @@
   AccountId,
   ChangeInfo,
   EmailAddress,
-  PreferencesInput,
   RepoName,
 } from '../../../types/common';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
@@ -44,9 +43,6 @@
   @state() loggedIn = false;
 
   // private but used in test
-  @state() preferences?: PreferencesInput;
-
-  // private but used in test
   @state() changesPerPage?: number;
 
   // private but used in test
@@ -127,11 +123,6 @@
       () => this.getUserModel().preferenceChangesPerPage$,
       x => (this.changesPerPage = x)
     );
-    subscribe(
-      this,
-      () => this.getUserModel().preferences$,
-      x => (this.preferences = x)
-    );
   }
 
   static override get styles() {
@@ -186,9 +177,8 @@
       <div ?hidden=${this.loading}>
         ${this.renderRepoHeader()} ${this.renderUserHeader()}
         <gr-change-list
-          .account=${this.account}
+          .loggedInUser=${this.account}
           .changes=${this.changes}
-          .preferences=${this.preferences}
           @toggle-star=${(e: CustomEvent<ChangeStarToggleStarDetail>) => {
             this.handleToggleStar(e);
           }}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 117abd6..38ee01a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -35,6 +35,9 @@
 import {resolve} from '../../../models/dependency';
 import {createChangeUrl} from '../../../models/views/change';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
+import {configModelToken} from '../../../models/config/config-model';
 
 export interface ChangeListSection {
   countLabel?: string;
@@ -81,7 +84,14 @@
    * in.
    */
   @property({type: Object})
-  account: AccountInfo | undefined = undefined;
+  loggedInUser?: AccountInfo;
+
+  /**
+   * When the list is part of the dashboard, the user for which the dashboard is
+   * generated.
+   */
+  @property({type: String})
+  dashboardUser?: string;
 
   @property({type: Array})
   changes?: ChangeInfo[];
@@ -112,7 +122,7 @@
   @property({type: Array})
   visibleChangeTableColumns?: string[];
 
-  @property({type: Object})
+  @state()
   preferences?: PreferencesInput;
 
   @property({type: Boolean})
@@ -123,16 +133,18 @@
 
   private readonly flagsService = getAppContext().flagsService;
 
-  private readonly restApiService = getAppContext().restApiService;
-
   private readonly reporting = getAppContext().reportingService;
 
   private readonly shortcuts = new ShortcutController(this);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
   private cursor = new GrCursorManager();
 
   constructor() {
@@ -158,13 +170,22 @@
       this.toggleCheckbox()
     );
     this.shortcuts.addGlobal({key: Key.ENTER}, () => this.openChange());
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      x => (this.preferences = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      config => {
+        this.config = config;
+      }
+    );
   }
 
   override connectedCallback() {
     super.connectedCallback();
-    this.restApiService.getConfig().then(config => {
-      this.config = config;
-    });
     this.getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -248,8 +269,8 @@
         .labelNames=${labelNames}
         .dynamicHeaderEndpoints=${this.dynamicHeaderEndpoints}
         .isCursorMoving=${this.isCursorMoving}
-        .config=${this.config}
-        .account=${this.account}
+        .loggedInUser=${this.loggedInUser}
+        .dashboardUser=${this.dashboardUser}
         .selectedIndex=${computeRelativeIndex(
           this.selectedIndex,
           sectionIndex,
@@ -276,7 +297,7 @@
 
   override willUpdate(changedProperties: PropertyValues) {
     if (
-      changedProperties.has('account') ||
+      changedProperties.has('loggedInUser') ||
       changedProperties.has('preferences') ||
       changedProperties.has('config') ||
       changedProperties.has('sections')
@@ -327,7 +348,7 @@
     this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
       this.isColumnEnabled(col, this.config)
     );
-    if (this.account && this.preferences) {
+    if (this.loggedInUser && this.preferences) {
       this.showNumber = !!this.preferences?.legacycid_in_change_table;
       if (
         this.preferences?.change_table &&
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index e201ab4..62a4f7d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -51,7 +51,7 @@
       time_format: TimeFormat.HHMM_12,
       change_table: [],
     };
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.config = createServerInfo();
     element.sections = [
       {
@@ -106,7 +106,7 @@
   });
 
   test('show change number disabled when not logged in', async () => {
-    element.account = undefined;
+    element.loggedInUser = undefined;
     element.preferences = undefined;
     element.config = createServerInfo();
     await element.updateComplete;
@@ -120,7 +120,7 @@
       time_format: TimeFormat.HHMM_12,
       change_table: [],
     };
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.config = createServerInfo();
     await element.updateComplete;
 
@@ -133,7 +133,7 @@
       time_format: TimeFormat.HHMM_12,
       change_table: [],
     };
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.config = createServerInfo();
     await element.updateComplete;
 
@@ -415,7 +415,7 @@
       stubFlags('isEnabled').returns(true);
       element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
-      element.account = {_account_id: 1001 as AccountId};
+      element.loggedInUser = {_account_id: 1001 as AccountId};
       element.preferences = {
         legacycid_in_change_table: true,
         time_format: TimeFormat.HHMM_12,
@@ -447,7 +447,7 @@
       stubFlags('isEnabled').returns(true);
       element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
-      element.account = {_account_id: 1001 as AccountId};
+      element.loggedInUser = {_account_id: 1001 as AccountId};
       element.preferences = {
         legacycid_in_change_table: true,
         time_format: TimeFormat.HHMM_12,
@@ -486,7 +486,7 @@
       stubFlags('isEnabled').returns(true);
       element = await fixture(html`<gr-change-list></gr-change-list>`);
       element.sections = [{results: [{...createChange()}]}];
-      element.account = {_account_id: 1001 as AccountId};
+      element.loggedInUser = {_account_id: 1001 as AccountId};
       element.preferences = {
         legacycid_in_change_table: true,
         time_format: TimeFormat.HHMM_12,
@@ -537,7 +537,7 @@
 
   test('loggedIn and showNumber', async () => {
     element.sections = [{results: [{...createChange()}], name: 'a'}];
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.preferences = {
       legacycid_in_change_table: false, // sets showNumber false
       time_format: TimeFormat.HHMM_12,
@@ -586,7 +586,7 @@
 
   test('garbage columns in preference are not shown', async () => {
     // This would only exist if somebody manually updated the config file.
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.preferences = {
       legacycid_in_change_table: true,
       time_format: TimeFormat.HHMM_12,
@@ -603,7 +603,7 @@
       html`<gr-change-list></gr-change-list>`
     );
     element.sections = [{results: [{...createChange()}]}];
-    element.account = {_account_id: 1001 as AccountId};
+    element.loggedInUser = {_account_id: 1001 as AccountId};
     element.preferences = {
       change_table: [
         'Status', // old status
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index a870835..f4c5215 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -79,7 +79,7 @@
   protected confirmDeleteModal?: HTMLDialogElement;
 
   @property({type: Object})
-  account?: AccountDetailInfo;
+  loggedInUser?: AccountDetailInfo;
 
   @property({type: Object})
   preferences?: PreferencesInput;
@@ -126,7 +126,14 @@
     subscribe(
       this,
       () => this.getUserModel().account$,
-      x => (this.account = x)
+      x => (this.loggedInUser = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().preferences$,
+      prefs => {
+        this.preferences = prefs ?? {};
+      }
     );
     subscribe(
       this,
@@ -154,7 +161,6 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.loadPreferences();
     document.addEventListener(
       'visibilitychange',
       this.visibilityChangeListener
@@ -280,7 +286,8 @@
         ${this.renderUserHeader()}
         <h1 class="assistive-tech-only">Dashboard</h1>
         <gr-change-list
-          .account=${this.account}
+          .loggedInUser=${this.loggedInUser}
+          .dashboardUser=${this.viewState?.user}
           .preferences=${this.preferences}
           .sections=${this.results}
           .usp=${'dashboard'}
@@ -325,18 +332,6 @@
     `;
   }
 
-  private loadPreferences() {
-    return this.restApiService.getLoggedIn().then(loggedIn => {
-      if (loggedIn) {
-        this.restApiService.getPreferences().then(preferences => {
-          this.preferences = preferences;
-        });
-      } else {
-        this.preferences = {};
-      }
-    });
-  }
-
   // private but used in test
   getRepositoryDashboard(
     repo: RepoName,
@@ -398,15 +393,16 @@
       : Promise.resolve(
           getUserDashboard(user, sections, title || this.computeTitle(user))
         );
-    // Checking `this.account` to make sure that the user is logged in.
+    // Checking `this.loggedInUser` to make sure that the user is logged in.
     // Otherwise sending a query for 'owner:self' will result in an error.
-    const checkForNewUser = !project && !!this.account && user === 'self';
+    const isLoggedInUserDashboard =
+      !project && !!this.loggedInUser && user === 'self';
     return dashboardPromise
       .then(res => {
         if (res && res.title) {
           fireTitleChange(res.title);
         }
-        return this.fetchDashboardChanges(res, checkForNewUser);
+        return this.fetchDashboardChanges(res, isLoggedInUserDashboard);
       })
       .then(() => {
         this.maybeShowDraftsBanner();
@@ -429,7 +425,7 @@
    */
   fetchDashboardChanges(
     res: UserDashboard | undefined,
-    checkForNewUser: boolean
+    isLoggedInUserDashboard: boolean
   ): Promise<void> {
     if (!res) {
       return Promise.resolve();
@@ -448,7 +444,8 @@
           : section.query
       );
 
-      if (checkForNewUser) {
+      if (isLoggedInUserDashboard) {
+        // The query to check if the user created any changes yet.
         queries.push('owner:self limit:1');
       }
     }
@@ -459,8 +456,9 @@
         if (!changes) {
           throw new Error('getChanges returns undefined');
         }
-        if (checkForNewUser) {
-          // Last set of results is not meant for dashboard display.
+        if (isLoggedInUserDashboard) {
+          // Last query ('owner:self limit:1') is only for evaluation if
+          // the user is "New" ie. haven't created any changes yet.
           const lastResultSet = changes.pop();
           this.showNewUserHelp = lastResultSet!.length === 0;
         }
@@ -491,7 +489,12 @@
    * And then we want to emphasize the changes where the waiting time is larger.
    */
   private maybeSortResults(name: string, results: ChangeInfo[]) {
-    const userId = this.account?._account_id;
+    // TODO: viewState?.user can be an Email Address. In this case the
+    // attention_set lookups will return undefined.
+    const userId =
+      this.viewState?.user === 'self'
+        ? this.loggedInUser?._account_id
+        : this.viewState?.user;
     const sortedResults = [...results];
     if (name === YOUR_TURN.name && userId) {
       sortedResults.sort((c1, c2) => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
index 84a3139..b05d970 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
@@ -319,7 +319,7 @@
 
   suite('selfOnly sections', () => {
     test('viewing self dashboard includes selfOnly sections', async () => {
-      element.account = undefined;
+      element.loggedInUser = undefined;
       element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'self',
@@ -334,7 +334,7 @@
     });
 
     test('viewing dashboard when logged in includes owner:self query', async () => {
-      element.account = createAccountDetailWithId(1);
+      element.loggedInUser = createAccountDetailWithId(1);
       element.viewState = {
         view: GerritView.DASHBOARD,
         user: 'self',
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 4eee3cb..e0c28bc 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -42,7 +42,6 @@
   BranchName,
   ChangeActionDialog,
   ChangeInfo,
-  ChangeViewChangeInfo,
   CherryPickInput,
   CommitId,
   InheritedBooleanInfo,
@@ -100,7 +99,7 @@
 import {changeModelToken} from '../../../models/change/change-model';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html, nothing} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {assertIsDefined, queryAll, uuid} from '../../../utils/common-util';
 import {Interaction} from '../../../constants/reporting';
@@ -113,6 +112,9 @@
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
+import {ParsedChangeInfo} from '../../../types/types';
+import {configModelToken} from '../../../models/config/config-model';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -377,76 +379,54 @@
 
   RevisionActions = RevisionActions;
 
-  @property({type: Object})
-  change?: ChangeViewChangeInfo;
+  @state() change?: ParsedChangeInfo;
 
-  @state()
-  actions: ActionNameToActionInfoMap = {};
+  @state() actions: ActionNameToActionInfoMap = {};
 
-  @property({type: Array})
-  primaryActionKeys: PrimaryActionKey[] = [
+  @state() primaryActionKeys: PrimaryActionKey[] = [
     ChangeActions.READY,
     RevisionActions.SUBMIT,
   ];
 
-  @property({type: Boolean})
-  disableEdit = false;
-
-  // private but used in test
   @state() _hideQuickApproveAction = false;
 
-  @property({type: Object})
-  account?: AccountInfo;
+  @state() account?: AccountInfo;
 
-  @property({type: String})
-  changeNum?: NumericChangeId;
+  @state() changeNum?: NumericChangeId;
 
-  @property({type: String})
-  changeStatus?: ChangeStatus;
+  @state() changeStatus?: ChangeStatus;
 
-  @property({type: String})
-  commitNum?: CommitId;
+  @state() commitNum?: CommitId;
 
   @state() latestPatchNum?: PatchSetNumber;
 
-  @property({type: String})
-  commitMessage = '';
+  @state() commitMessage = '';
 
-  @property({type: Object})
-  revisionActions: ActionNameToActionInfoMap = {};
+  @state() revisionActions: ActionNameToActionInfoMap = {};
 
-  @state() private revisionSubmitAction?: ActionInfo | null;
+  @state() revisionSubmitAction?: ActionInfo | null;
 
-  // used as a proprty type so cannot be private
   @state() revisionRebaseAction?: ActionInfo | null;
 
-  @property({type: String})
-  privateByDefault?: InheritedBooleanInfo;
+  @state() privateByDefault?: InheritedBooleanInfo;
 
-  // private but used in test
   @state() loading = true;
 
-  // private but used in test
   @state() actionLoadingMessage = '';
 
-  @state() private inProgressActionKeys = new Set<string>();
+  @state() inProgressActionKeys = new Set<string>();
 
-  // _computeAllActions always returns an array
-  // private but used in test
   @state() allActionValues: UIActionInfo[] = [];
 
-  // private but used in test
   @state() topLevelActions?: UIActionInfo[];
 
-  // private but used in test
   @state() topLevelPrimaryActions?: UIActionInfo[];
 
-  // private but used in test
   @state() topLevelSecondaryActions?: UIActionInfo[];
 
-  @state() private menuActions?: MenuAction[];
+  @state() menuActions?: MenuAction[];
 
-  @state() private overflowActions: OverflowAction[] = [
+  @state() overflowActions: OverflowAction[] = [
     {
       type: ActionType.CHANGE,
       key: ChangeActions.WIP,
@@ -493,29 +473,21 @@
     },
   ];
 
-  @state() private actionPriorityOverrides: ActionPriorityOverride[] = [];
+  @state() actionPriorityOverrides: ActionPriorityOverride[] = [];
 
-  @state() private additionalActions: UIActionInfo[] = [];
+  @state() additionalActions: UIActionInfo[] = [];
 
-  // private but used in test
   @state() hiddenActions: string[] = [];
 
-  // private but used in test
   @state() disabledMenuActions: string[] = [];
 
-  // private but used in test
-  @state()
-  editPatchsetLoaded = false;
+  @state() editPatchsetLoaded = false;
 
-  @property({type: Boolean})
-  editMode = false;
+  @state() editMode = false;
 
-  // private but used in test
-  @state()
-  editBasedOnCurrentPatchSet = true;
+  @state() editBasedOnCurrentPatchSet = true;
 
-  @property({type: Boolean})
-  loggedIn = false;
+  @state() loggedIn = false;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -523,6 +495,10 @@
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getStorage = resolve(this, storageServiceToken);
@@ -546,6 +522,51 @@
       () => this.getChangeModel().patchNum$,
       x => (this.editPatchsetLoaded = x === 'edit')
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().status$,
+      x => (this.changeStatus = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().editMode$,
+      x => (this.editMode = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revision$,
+      rev => (this.commitNum = rev?.commit?.commit)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestRevision$,
+      rev => (this.commitMessage = rev?.commit?.message ?? '')
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().repoConfig$,
+      config => (this.privateByDefault = config?.private_by_default)
+    );
   }
 
   override connectedCallback() {
@@ -865,7 +886,7 @@
 
         this.revisionActions = revisionActions;
         this.sendShowRevisionActions({
-          change,
+          change: change as ChangeInfo,
           revisionActions,
         });
         this.handleLoadingComplete();
@@ -1031,18 +1052,7 @@
   }
 
   private editStatusChanged() {
-    // Hide change edits if not logged in
-    if (this.change === undefined || !this.loggedIn) {
-      return;
-    }
-    if (this.disableEdit) {
-      delete this.actions.rebaseEdit;
-      delete this.actions.publishEdit;
-      delete this.actions.deleteEdit;
-      delete this.actions.stopEdit;
-      delete this.actions.edit;
-      return;
-    }
+    if (!this.change || !this.loggedIn) return;
     if (this.editPatchsetLoaded) {
       // Only show actions that mutate an edit if an actual edit patch set
       // is loaded.
@@ -1121,7 +1131,7 @@
     if (!this.change || !this.change.labels || !this.change.permitted_labels) {
       return null;
     }
-    if (this.change && this.change.status === ChangeStatus.MERGED) {
+    if (this.change?.status === ChangeStatus.MERGED) {
       return null;
     }
     let result;
@@ -1323,18 +1333,18 @@
 
   // private but used in test
   canSubmitChange() {
-    if (!this.change) {
-      return false;
-    }
+    if (!this.change) return false;
+    const change = this.change as ChangeInfo;
+    const revision = this.getRevision(change, this.latestPatchNum);
     return this.getPluginLoader().jsApiService.canSubmitChange(
-      this.change,
-      this.getRevision(this.change, this.latestPatchNum)
+      change,
+      revision
     );
   }
 
   // private but used in test
-  getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNumber) {
-    for (const rev of Object.values(change.revisions)) {
+  getRevision(change: ChangeInfo, patchNum?: PatchSetNumber) {
+    for (const rev of Object.values(change.revisions ?? {})) {
       if (rev._number === patchNum) {
         return rev;
       }
@@ -1805,7 +1815,7 @@
     if (dialog.init) dialog.init();
     dialog.hidden = false;
     assertIsDefined(this.actionsModal, 'actionsModal');
-    this.actionsModal.showModal();
+    if (this.actionsModal.isConnected) this.actionsModal.showModal();
     whenVisible(dialog, () => {
       if (dialog.resetFocus) {
         dialog.resetFocus();
@@ -1818,7 +1828,7 @@
   // private but used in test
   setReviewOnRevert(newChangeId: NumericChangeId) {
     const review = this.getPluginLoader().jsApiService.getReviewPostRevert(
-      this.change
+      this.change as ChangeInfo
     );
     if (!review) {
       return Promise.resolve(undefined);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 946191b..b628cc1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -129,6 +129,7 @@
       element = await fixture<GrChangeActions>(html`
         <gr-change-actions></gr-change-actions>
       `);
+      element.changeStatus = ChangeStatus.NEW;
       element.change = {
         ...createChangeViewChange(),
         actions: {
@@ -155,7 +156,7 @@
       await element.reload();
     });
 
-    test('render', () => {
+    test('render', async () => {
       assert.shadowDom.equal(
         element,
         /* HTML */ `
@@ -200,6 +201,24 @@
                   Rebase
                 </gr-button>
               </gr-tooltip-content>
+              <gr-tooltip-content
+                has-tooltip=""
+                position-below=""
+                title="Edit this change"
+              >
+                <gr-button
+                  aria-disabled="false"
+                  class="edit"
+                  data-action-key="edit"
+                  data-label="Edit"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  <gr-icon filled="" icon="edit"> </gr-icon>
+                  Edit
+                </gr-button>
+              </gr-tooltip-content>
             </section>
             <gr-button
               aria-disabled="false"
@@ -705,29 +724,6 @@
     });
 
     suite('change edits', () => {
-      test('disableEdit', async () => {
-        element.editMode = false;
-        element.editBasedOnCurrentPatchSet = false;
-        element.change = {
-          ...createChangeViewChange(),
-          status: ChangeStatus.NEW,
-        };
-        element.disableEdit = true;
-        await element.updateComplete;
-
-        assert.isNotOk(
-          query(element, 'gr-button[data-action-key="publishEdit"]')
-        );
-        assert.isNotOk(
-          query(element, 'gr-button[data-action-key="rebaseEdit"]')
-        );
-        assert.isNotOk(
-          query(element, 'gr-button[data-action-key="deleteEdit"]')
-        );
-        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
-        assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
-      });
-
       test('shows confirm dialog for delete edit', async () => {
         element.loggedIn = true;
         element.editMode = true;
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index b7851a6..1cb25ea 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -82,6 +82,12 @@
 import {createChangeUrl} from '../../../models/views/change';
 import {getChangeWeblinks} from '../../../utils/weblink-util';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -118,54 +124,90 @@
 export class GrChangeMetadata extends LitElement {
   @query('#webLinks') webLinks?: HTMLElement;
 
-  @property({type: Object}) change?: ParsedChangeInfo;
-
-  @property({type: Object}) revertedChange?: ChangeInfo;
-
-  @property({type: Object}) account?: AccountDetailInfo;
-
-  @property({type: Object}) revision?: RevisionInfo | EditRevisionInfo;
-
-  // TODO: Just use `revision.commit` instead.
-  @property({type: Object}) commitInfo?: CommitInfoWithRequiredCommit;
-
-  @property({type: Object}) serverConfig?: ServerInfo;
-
+  // TODO: Convert to @state. That requires the change model to keep track of
+  // current revision actions. Then we can also get rid of the
+  // `revision-actions-changed` event.
   @property({type: Boolean}) parentIsCurrent?: boolean;
 
-  @property({type: Object}) repoConfig?: ConfigInfo;
+  @state() change?: ParsedChangeInfo;
 
-  // private but used in test
+  @state() revertedChange?: ChangeInfo;
+
+  @state() account?: AccountDetailInfo;
+
+  @state() revision?: RevisionInfo | EditRevisionInfo;
+
+  @state() serverConfig?: ServerInfo;
+
+  @state() repoConfig?: ConfigInfo;
+
   @state() mutable = false;
 
-  @state() private readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
+  @state() readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
 
-  // private but used in test
   @state() topicReadOnly = true;
 
-  // private but used in test
   @state() hashtagReadOnly = true;
 
-  @state() private pushCertificateValidation?: PushCertificateValidationInfo;
+  @state() pushCertificateValidation?: PushCertificateValidationInfo;
 
-  // private but used in test
   @state() settingTopic = false;
 
-  // private but used in test
   @state() currentParents: ParentCommitInfo[] = [];
 
-  @state() private showAllSections = false;
+  @state() showAllSections = false;
 
-  @state() private queryTopic?: AutocompleteQuery;
+  @state() queryTopic?: AutocompleteQuery;
 
-  @state() private queryHashtag?: AutocompleteQuery;
+  @state() queryHashtag?: AutocompleteQuery;
 
   private restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getRelatedChangesModel = resolve(
+    this,
+    relatedChangesModelToken
+  );
+
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverConfig => (this.serverConfig = serverConfig)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().repoConfig$,
+      repoConfig => (this.repoConfig = repoConfig)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      account => (this.account = account)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => (this.change = change)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revision$,
+      revision => (this.revision = revision)
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().revertingChange$,
+      revertingChange => (this.revertedChange = revertingChange)
+    );
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
     this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
@@ -735,7 +777,10 @@
 
   // private but used in test
   computeWebLinks(): WebLinkInfo[] {
-    return getChangeWeblinks(this.commitInfo?.web_links, this.serverConfig);
+    return getChangeWeblinks(
+      this.revision?.commit?.web_links,
+      this.serverConfig
+    );
   }
 
   private computeStrategy() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index c46fe24..93ef3e3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -9,7 +9,6 @@
 import {ChangeRole, GrChangeMetadata} from './gr-change-metadata';
 import {
   createServerInfo,
-  createUserConfig,
   createParsedChange,
   createAccountWithId,
   createCommitInfoWithRequiredCommit,
@@ -61,18 +60,9 @@
   let element: GrChangeMetadata;
 
   setup(async () => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getConfig').returns(
-      Promise.resolve({
-        ...createServerInfo(),
-        user: {
-          ...createUserConfig(),
-          anonymouscowardname: 'test coward name',
-        },
-      })
-    );
     element = await fixture(html`<gr-change-metadata></gr-change-metadata>`);
     element.change = createParsedChange();
+    element.account = undefined;
     await element.updateComplete;
   });
 
@@ -251,16 +241,22 @@
   });
 
   test('weblinks hidden when no weblinks', async () => {
-    element.commitInfo = createCommitInfoWithRequiredCommit();
+    element.revision = {
+      ...createRevision(),
+      commit: createCommitInfoWithRequiredCommit(),
+    };
     element.serverConfig = createServerInfo();
     await element.updateComplete;
     assert.isNull(element.webLinks);
   });
 
   test('weblinks hidden when only gitiles weblink', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
+      },
     };
     element.serverConfig = createServerInfo();
     await element.updateComplete;
@@ -270,9 +266,12 @@
 
   test('weblinks hidden when sole weblink is set as primary', async () => {
     const browser = 'browser';
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: browser, url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: browser, url: '#'}],
+      },
     };
     element.serverConfig = {
       ...createServerInfo(),
@@ -286,9 +285,12 @@
   });
 
   test('weblinks are visible when other weblinks', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
+      },
     };
     await element.updateComplete;
     const webLinks = element.webLinks!;
@@ -297,12 +299,15 @@
   });
 
   test('weblinks are visible when gitiles and other weblinks', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [
-        {...createWebLinkInfo(), name: 'test', url: '#'},
-        {...createWebLinkInfo(), name: 'gitiles', url: '#'},
-      ],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [
+          {...createWebLinkInfo(), name: 'test', url: '#'},
+          {...createWebLinkInfo(), name: 'gitiles', url: '#'},
+        ],
+      },
     };
     await element.updateComplete;
     const webLinks = element.webLinks!;
@@ -468,7 +473,7 @@
     });
 
     test('Push Certificate Validation test BAD', () => {
-      change!.revisions.rev1!.push_certificate = {
+      change!.revisions.rev1.push_certificate = {
         certificate: 'Push certificate',
         key: {
           status: GpgKeyInfoStatus.BAD,
@@ -488,7 +493,7 @@
     });
 
     test('Push Certificate Validation test TRUSTED', () => {
-      change!.revisions.rev1!.push_certificate = {
+      change!.revisions.rev1.push_certificate = {
         certificate: 'Push certificate',
         key: {
           status: GpgKeyInfoStatus.TRUSTED,
@@ -526,7 +531,7 @@
     });
 
     test('isEnabledSignedPushOnRepo', () => {
-      change!.revisions.rev1!.push_certificate = {
+      change!.revisions.rev1.push_certificate = {
         certificate: 'Push certificate',
         key: {
           status: GpgKeyInfoStatus.TRUSTED,
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index abd3ff0..d4d1729 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -335,10 +335,7 @@
   @state()
   private updateCheckTimerHandle?: number | null;
 
-  // Private but used in tests.
-  getEditMode(): boolean {
-    return !!this.viewState?.edit || this.patchNum === EDIT;
-  }
+  @state() editMode = false;
 
   isSubmitEnabled(): boolean {
     return !!(
@@ -664,6 +661,11 @@
     );
     subscribe(
       this,
+      () => this.getChangeModel().editMode$,
+      editMode => (this.editMode = editMode)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().patchNum$,
       patchNum => (this.patchNum = patchNum)
     );
@@ -1134,23 +1136,16 @@
         ${this.renderTabHeaders()} ${this.renderTabContent()}
         ${this.renderChangeLog()}
       </div>
-      <gr-apply-fix-dialog
-        id="applyFixDialog"
-        .change=${this.change}
-        .changeNum=${this.changeNum}
-      ></gr-apply-fix-dialog>
+      <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
       <dialog id="downloadModal" tabindex="-1">
         <gr-download-dialog
           id="downloadDialog"
-          .change=${this.change}
-          .config=${this.serverConfig?.download}
           @close=${this.handleDownloadDialogClose}
         ></gr-download-dialog>
       </dialog>
       <dialog id="includedInModal" tabindex="-1">
         <gr-included-in-dialog
           id="includedInDialog"
-          .changeNum=${this.changeNum}
           @close=${this.handleIncludedInDialogClose}
         ></gr-included-in-dialog>
       </dialog>
@@ -1287,27 +1282,18 @@
   }
 
   private renderCommitActions() {
-    return html` <div class="commitActions">
-      <!-- always show gr-change-actions regardless if logged in or not -->
-      <gr-change-actions
-        id="actions"
-        .change=${this.change}
-        .disableEdit=${false}
-        .account=${this.account}
-        .changeNum=${this.changeNum}
-        .changeStatus=${this.change?.status}
-        .commitNum=${this.revision?.commit?.commit}
-        .commitMessage=${this.latestCommitMessage}
-        .editMode=${this.getEditMode()}
-        .privateByDefault=${this.projectConfig?.private_by_default}
-        .loggedIn=${this.loggedIn}
-        @edit-tap=${() => this.handleEditTap()}
-        @stop-edit-tap=${() => this.handleStopEditTap()}
-        @download-tap=${() => this.handleOpenDownloadDialog()}
-        @included-tap=${() => this.handleOpenIncludedInDialog()}
-        @revision-actions-changed=${this.handleRevisionActionsChanged}
-      ></gr-change-actions>
-    </div>`;
+    return html`
+      <div class="commitActions">
+        <gr-change-actions
+          id="actions"
+          @edit-tap=${() => this.handleEditTap()}
+          @stop-edit-tap=${() => this.handleStopEditTap()}
+          @download-tap=${() => this.handleOpenDownloadDialog()}
+          @included-tap=${() => this.handleOpenIncludedInDialog()}
+          @revision-actions-changed=${this.handleRevisionActionsChanged}
+        ></gr-change-actions>
+      </div>
+    `;
   }
 
   private renderChangeInfo() {
@@ -1315,20 +1301,13 @@
       this.loggedIn,
       this.editingCommitMessage,
       this.change,
-      this.getEditMode()
+      this.editMode
     );
     return html` <div class="changeInfo">
       <div class="changeInfo-column changeMetadata">
         <gr-change-metadata
           id="metadata"
-          .change=${this.change}
-          .revertedChange=${this.revertingChange}
-          .account=${this.account}
-          .revision=${this.revision}
-          .commitInfo=${this.revision?.commit}
-          .serverConfig=${this.serverConfig}
           .parentIsCurrent=${this.isParentCurrent()}
-          .repoConfig=${this.projectConfig}
           @show-reply-dialog=${this.handleShowReplyDialog}
         >
         </gr-change-metadata>
@@ -1419,7 +1398,7 @@
         )}
         ${this.pluginTabsHeaderEndpoints.map(
           tabHeader => html`
-            <paper-tab data-name=${tabHeader}>
+            <paper-tab data-name=${tabHeader} @click=${this.onPaperTabClick}>
               <gr-endpoint-decorator name=${tabHeader}>
                 <gr-endpoint-param name="change" .value=${this.change}>
                 </gr-endpoint-param>
@@ -1461,7 +1440,7 @@
           .changeNum=${this.changeNum}
           .commitInfo=${this.revision?.commit}
           .changeUrl=${this.computeChangeUrl()}
-          .editMode=${this.getEditMode()}
+          .editMode=${this.editMode}
           .loggedIn=${this.loggedIn}
           .shownFileCount=${this.shownFileCount}
           .filesExpanded=${this.fileList?.filesExpanded}
@@ -1475,7 +1454,7 @@
           id="fileList"
           .change=${this.change}
           .changeNum=${this.changeNum}
-          .editMode=${this.getEditMode()}
+          .editMode=${this.editMode}
           @files-shown-changed=${(e: CustomEvent<{length: number}>) => {
             this.shownFileCount = e.detail.length;
           }}
@@ -1727,7 +1706,6 @@
 
     const options = {
       mergeable: this.mergeable,
-      submitEnabled: !!this.isSubmitEnabled(),
       revertingChangeStatus: this.revertingChange?.status,
     };
     return changeStatuses(this.change as ChangeInfo, options);
@@ -1957,7 +1935,7 @@
       change: this.change,
       patchNum: this.patchNum,
       basePatchNum: this.basePatchNum,
-      edit: this.getEditMode(),
+      edit: this.editMode,
       messageHash: hash,
     });
     history.replaceState(null, '', url);
@@ -2409,7 +2387,7 @@
   // Private but used in tests.
   computeHeaderClass() {
     const classes = ['header'];
-    if (this.getEditMode()) {
+    if (this.editMode) {
       classes.push('editMode');
     }
     return classes.join(' ');
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 5331558..ae60449 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -73,7 +73,7 @@
 import {Modifier} from '../../../utils/dom-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
-import {ChangeChildView, ChangeViewState} from '../../../models/views/change';
+import {ChangeChildView} from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
@@ -1318,10 +1318,9 @@
     });
   });
 
-  test('header class computation', () => {
+  test('header class computation', async () => {
     assert.equal(element.computeHeaderClass(), 'header');
-    assertIsDefined(element.viewState);
-    element.viewState.edit = true;
+    element.editMode = true;
     assert.equal(element.computeHeaderClass(), 'header editMode');
   });
 
@@ -1342,38 +1341,6 @@
     assert.equal(scrollStub.lastCall.args[0], 'TEST');
   });
 
-  test('computeEditMode', async () => {
-    const callCompute = async (viewState: ChangeViewState) => {
-      element.viewState = viewState;
-      element.patchNum = viewState.patchNum;
-      element.basePatchNum = viewState.basePatchNum ?? PARENT;
-      await element.updateComplete;
-      return element.getEditMode();
-    };
-    assert.isTrue(
-      await callCompute({
-        ...createChangeViewState(),
-        edit: true,
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      })
-    );
-    assert.isFalse(
-      await callCompute({
-        ...createChangeViewState(),
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      })
-    );
-    assert.isTrue(
-      await callCompute({
-        ...createChangeViewState(),
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: EDIT,
-      })
-    );
-  });
-
   test('file-action-tap handling', async () => {
     element.patchNum = 1 as RevisionPatchSetNum;
     element.change = {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index fedc377..e421c9c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -15,6 +15,7 @@
 import {resolve} from '../../../models/dependency';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {createSearchUrl} from '../../../models/views/search';
+import {ParsedChangeInfo} from '../../../types/types';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const INSERT_REASON_STRING = '<INSERT REASONING HERE>';
@@ -170,15 +171,23 @@
     return this.revertType === RevertType.REVERT_SUBMISSION;
   }
 
-  modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
+  modifyRevertMsg(
+    change: ParsedChangeInfo,
+    commitMessage: string,
+    message: string
+  ) {
     return this.getPluginLoader().jsApiService.modifyRevertMsg(
-      change,
+      change as ChangeInfo,
       message,
       commitMessage
     );
   }
 
-  populate(change: ChangeInfo, commitMessage: string, changesCount: number) {
+  populate(
+    change: ParsedChangeInfo,
+    commitMessage: string,
+    changesCount: number
+  ) {
     this.changesCount = changesCount;
     // The option to revert a single change is always available
     this.populateRevertSingleChangeMessage(
@@ -190,7 +199,7 @@
   }
 
   populateRevertSingleChangeMessage(
-    change: ChangeInfo,
+    change: ParsedChangeInfo,
     commitMessage: string,
     commitHash?: CommitId
   ) {
@@ -215,18 +224,21 @@
   }
 
   private modifyRevertSubmissionMsg(
-    change: ChangeInfo,
+    change: ParsedChangeInfo,
     msg: string,
     commitMessage: string
   ) {
     return this.getPluginLoader().jsApiService.modifyRevertSubmissionMsg(
-      change,
+      change as ChangeInfo,
       msg,
       commitMessage
     );
   }
 
-  populateRevertSubmissionMessage(change: ChangeInfo, commitMessage: string) {
+  populateRevertSubmissionMessage(
+    change: ParsedChangeInfo,
+    commitMessage: string
+  ) {
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 904285f..8d71e15 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -5,7 +5,7 @@
  */
 import {fixture, html, assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
-import {createChange} from '../../../test/test-data-generators';
+import {createParsedChange} from '../../../test/test-data-generators';
 import {ChangeSubmissionId, CommitId} from '../../../types/common';
 import './gr-confirm-revert-dialog';
 import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
@@ -48,7 +48,7 @@
     const alertStub = sinon.stub();
     element.addEventListener('show-alert', alertStub);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'not a commitHash in sight',
       undefined
     );
@@ -58,7 +58,7 @@
   test('single line', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'one line commit\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -72,7 +72,7 @@
   test('multi line', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -86,7 +86,7 @@
   test('issue above change id', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -100,7 +100,7 @@
   test('revert a revert', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'Revert "one line commit"\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -115,7 +115,7 @@
     element.changesCount = 3;
     element.populateRevertSubmissionMessage(
       {
-        ...createChange(),
+        ...createParsedChange(),
         submission_id: '5545' as ChangeSubmissionId,
         current_revision: 'abcd123' as CommitId,
       },
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 11dc890..e1808bf 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -5,7 +5,7 @@
  */
 import '../../shared/gr-download-commands/gr-download-commands';
 import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
-import {ChangeInfo, DownloadInfo, PatchSetNum} from '../../../types/common';
+import {DownloadInfo, PatchSetNum} from '../../../types/common';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {copyToClipbard, hasOwnProperty} from '../../../utils/common-util';
@@ -13,13 +13,15 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state, query} from 'lit/decorators.js';
+import {customElement, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
+import {ParsedChangeInfo} from '../../../types/types';
+import {configModelToken} from '../../../models/config/config-model';
 
 @customElement('gr-download-dialog')
 export class GrDownloadDialog extends LitElement {
@@ -35,27 +37,37 @@
 
   @query('#closeButton') protected closeButton?: GrButton;
 
-  @property({type: Object})
-  change: ChangeInfo | undefined;
+  @state() change?: ParsedChangeInfo;
 
-  @property({type: Object})
-  config?: DownloadInfo;
+  @state() config?: DownloadInfo;
 
   @state() patchNum?: PatchSetNum;
 
-  @state() private selectedScheme?: string;
+  @state() selectedScheme?: string;
 
   private readonly shortcuts = new ShortcutController(this);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   constructor() {
     super();
     subscribe(
       this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().patchNum$,
       x => (this.patchNum = x)
     );
+    subscribe(
+      this,
+      () => this.getConfigModel().download$,
+      x => (this.config = x)
+    );
     for (const key of ['1', '2', '3', '4', '5']) {
       this.shortcuts.addLocal({key}, e => this.handleNumberKey(e));
     }
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index e5f40a6..73d7618 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -8,6 +8,7 @@
   createChange,
   createCommit,
   createDownloadInfo,
+  createParsedChange,
   createRevision,
 } from '../../../test/test-data-generators';
 import {
@@ -160,7 +161,7 @@
   suite('gr-download-dialog tests with no fetch options', () => {
     setup(async () => {
       element.change = {
-        ...createChange(),
+        ...createParsedChange(),
         revisions: {
           r1: {
             ...createRevision(),
@@ -204,7 +205,7 @@
 
     test('computed fields', () => {
       element.change = {
-        ...createChange(),
+        ...createParsedChange(),
         project: 'test/project' as RepoName,
         _number: 123 as NumericChangeId,
       };
@@ -233,7 +234,7 @@
     element.patchNum = 1 as PatchSetNum;
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {...createRevision(), commit: createCommit()},
       },
@@ -241,7 +242,7 @@
     assert.isTrue(element.computeHidePatchFile());
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {
           ...createRevision(),
@@ -255,7 +256,7 @@
     assert.isFalse(element.computeHidePatchFile());
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {
           ...createRevision(),
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index fd3ddac..adea275 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -3,7 +3,7 @@
  * Copyright 2017 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import '../../diff/gr-patch-range-select/gr-patch-range-select';
 import '../../edit/gr-edit-controls/gr-edit-controls';
 import '../../shared/gr-select/gr-select';
@@ -23,7 +23,7 @@
   PatchSetNumber,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fire, fireNoBubbleNoCompose} from '../../../utils/event-util';
 import {css, html, LitElement, nothing} from 'lit';
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index f9568f4..abb637c 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -5,7 +5,6 @@
  */
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
-import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import '../../diff/gr-diff-host/gr-diff-host';
 import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
@@ -47,7 +46,8 @@
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
 import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
-import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
+import {GrDiffCursor as GrDiffCursorNew} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
+import {GrDiffCursor} from '../../../embed/diff-old/gr-diff-cursor/gr-diff-cursor';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
@@ -86,6 +86,7 @@
 import {userModelToken} from '../../../models/user/user-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {FileMode, fileModeToString} from '../../../utils/file-util';
+import {isNewDiff} from '../../../embed/diff/gr-diff/gr-diff-utils';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -316,7 +317,8 @@
   fileCursor = new GrCursorManager();
 
   // private but used in test
-  diffCursor?: GrDiffCursor;
+  // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+  diffCursor?: GrDiffCursor | GrDiffCursorNew;
 
   static override get styles() {
     return [
@@ -904,7 +906,8 @@
           );
         }
       });
-    this.diffCursor = new GrDiffCursor();
+    // TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+    this.diffCursor = isNewDiff() ? new GrDiffCursorNew() : new GrDiffCursor();
     this.diffCursor.replaceDiffs(this.diffs);
   }
 
@@ -1812,37 +1815,6 @@
   }
 
   /**
-   * Computes a string with the number of comments and unresolved comments.
-   */
-  computeCommentsString(file?: NormalizedFileInfo) {
-    if (
-      this.changeComments === undefined ||
-      this.patchRange === undefined ||
-      file?.__path === undefined
-    ) {
-      return '';
-    }
-    return this.changeComments.computeCommentsString(
-      this.patchRange,
-      file.__path,
-      file
-    );
-  }
-
-  /**
-   * Computes a string with the number of drafts.
-   */
-  computeDraftsString(file?: NormalizedFileInfo) {
-    if (this.changeComments === undefined) return '';
-    const draftCount = this.changeComments.computeDraftCountForFile(
-      this.patchRange,
-      file
-    );
-    if (draftCount === 0) return '';
-    return pluralize(Number(draftCount), 'draft');
-  }
-
-  /**
    * Computes a shortened string with the number of drafts.
    * Private but used in tests.
    */
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 6033a25..daf0891 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -57,6 +57,8 @@
 import {Modifier} from '../../../utils/dom-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {FileMode} from '../../../utils/file-util';
+import {SinonStubbedMember} from 'sinon';
+import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -723,28 +725,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: 'unresolved.file',
-          size: 0,
-          size_delta: 0,
-        }),
-        '1 draft'
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: 'unresolved.file',
-          size: 0,
-          size_delta: 0,
-        }),
-        '1 draft'
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: 'unresolved.file',
           size: 0,
@@ -789,28 +769,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: 'myfile.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: 'myfile.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: 'myfile.txt',
           size: 0,
@@ -855,28 +813,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: 'file_added_in_rev2.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: 'file_added_in_rev2.txt',
-          size: 0,
-          size_delta: 0,
-        }),
-        ''
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: 'file_added_in_rev2.txt',
           size: 0,
@@ -921,28 +857,6 @@
       element.basePatchNum = PARENT;
       element.patchNum = 1 as RevisionPatchSetNum;
       assert.equal(
-        element.computeDraftsString({
-          __path: '/COMMIT_MSG',
-          size: 0,
-          size_delta: 0,
-        }),
-        '2 drafts'
-      );
-
-      element.basePatchNum = 1 as BasePatchSetNum;
-      element.patchNum = 2 as RevisionPatchSetNum;
-      assert.equal(
-        element.computeDraftsString({
-          __path: '/COMMIT_MSG',
-          size: 0,
-          size_delta: 0,
-        }),
-        '2 drafts'
-      );
-
-      element.basePatchNum = PARENT;
-      element.patchNum = 1 as RevisionPatchSetNum;
-      assert.equal(
         element.computeDraftsStringMobile({
           __path: '/COMMIT_MSG',
           size: 0,
@@ -2240,7 +2154,7 @@
 
     suite('n key presses', () => {
       let nextCommentStub: sinon.SinonStub;
-      let nextChunkStub: sinon.SinonStub;
+      let nextChunkStub: SinonStubbedMember<GrDiffCursor['moveToNextChunk']>;
       let fileRows: NodeListOf<HTMLDivElement>;
 
       setup(() => {
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index 33dfe82..32da400 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -10,9 +10,12 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireNoBubble} from '../../../utils/event-util';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 interface DisplayGroup {
   title: string;
@@ -27,19 +30,18 @@
    * @event close
    */
 
-  @property({type: Object})
-  changeNum?: NumericChangeId;
+  @state() changeNum?: NumericChangeId;
 
-  // private but used in test
   @state() includedIn?: IncludedInInfo;
 
   @state() private loaded = false;
 
-  // private but used in test
   @state() filterText = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   static override get styles() {
     return [
       fontStyles,
@@ -93,6 +95,15 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => (this.changeNum = changeNum)
+    );
+  }
+
   override render() {
     return html`
       <header>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index d5da9c9..3c2b792 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -21,14 +21,14 @@
   PatchSetNum,
   VotingRangeInfo,
   isRobot,
-  EDIT,
-  PARENT,
+  PatchSetNumber,
 } from '../../../types/common';
 import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
 import {getVotingRange} from '../../../utils/label-util';
 import {
   FormattedReviewerUpdateInfo,
   ParsedChangeInfo,
+  isPatchSetNumber,
 } from '../../../types/types';
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
@@ -157,27 +157,17 @@
   message: CombinedMessage,
   allMessages: CombinedMessage[]
 ): PatchSetNum | undefined {
-  if (
-    message._revision_number !== undefined &&
-    message._revision_number !== 0 &&
-    message._revision_number !== PARENT &&
-    message._revision_number !== EDIT
-  ) {
+  if (isPatchSetNumber(message._revision_number)) {
     return message._revision_number;
   }
-  let revision: PatchSetNum = 0 as PatchSetNum;
+  let revision: PatchSetNumber | undefined = undefined;
   for (const m of allMessages) {
     if (m.date > message.date) break;
-    if (
-      m._revision_number !== undefined &&
-      m._revision_number !== 0 &&
-      m._revision_number !== PARENT &&
-      m._revision_number !== EDIT
-    ) {
+    if (isPatchSetNumber(m._revision_number)) {
       revision = m._revision_number;
     }
   }
-  return revision > 0 ? revision : undefined;
+  return revision;
 }
 
 /**
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index db19329..3bc2770 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -25,6 +25,8 @@
 import {nothing} from 'lit';
 import {fire} from '../../../utils/event-util';
 import {ShowReplyDialogEvent} from '../../../types/events';
+import {repeat} from 'lit/directives/repeat.js';
+import {accountKey} from '../../../utils/account-util';
 
 @customElement('gr-reviewer-list')
 export class GrReviewerList extends LitElement {
@@ -102,8 +104,10 @@
     return html`
       <div class="container">
         <div>
-          ${this.displayedReviewers.map(reviewer =>
-            this.renderAccountChip(reviewer)
+          ${repeat(
+            this.displayedReviewers,
+            reviewer => accountKey(reviewer),
+            reviewer => this.renderAccountChip(reviewer)
           )}
           <div class="controlsContainer" ?hidden=${!this.mutable}>
             <gr-button
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 75845f6..5c40050 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -96,7 +96,7 @@
     return specialFilePathCompare(c1.path, c2.path);
   }
 
-  // Convert 'FILE' and 'LOST' to undefined.
+  // Convert FILE and LOST to undefined.
   const line1 = typeof c1.line === 'number' ? c1.line : undefined;
   const line2 = typeof c2.line === 'number' ? c2.line : undefined;
   if (line1 !== line2) {
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
index a4357bb..3a06f8d 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -41,6 +41,7 @@
 import {GrDropdownList} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {FILE} from '../../../api/diff';
 
 suite('gr-thread-list tests', () => {
   let element: GrThreadList;
@@ -665,7 +666,7 @@
 
   test('file level comment before line', () => {
     t1.line = 123;
-    t2.line = 'FILE';
+    t2.line = FILE;
     checkOrder([t2, t1]);
   });
 
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 0f3810a..b1c4c8e4 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -47,6 +47,7 @@
   otherPrimaryLinks,
   secondaryLinks,
   tooltipForLink,
+  computeIsExpandable,
 } from '../../models/checks/checks-util';
 import {assertIsDefined, assert, unique} from '../../utils/common-util';
 import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
@@ -322,20 +323,12 @@
     ];
   }
 
-  override updated(changedProperties: PropertyValues) {
+  override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('result')) {
-      this.isExpandable = this.computeIsExpandable();
+      this.isExpandable = computeIsExpandable(this.result);
     }
   }
 
-  private computeIsExpandable() {
-    const hasSummary = !!this.result?.summary;
-    const hasMessage = !!this.result?.message;
-    const hasMultipleLinks = (this.result?.links ?? []).length > 1;
-    const hasPointers = (this.result?.codePointers ?? []).length > 0;
-    return hasSummary && (hasMessage || hasMultipleLinks || hasPointers);
-  }
-
   override focus() {
     if (this.nameEl) this.nameEl.focus();
   }
@@ -717,6 +710,7 @@
           changeNum: change._number,
           repo: change.project,
           patchNum: patchset,
+          checksPatchset: patchset,
           diffView: {path, lineNum: line},
         }),
         primary: true,
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index efc6efe..1999e1f 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -9,6 +9,7 @@
 import {customElement, property, state} from 'lit/decorators.js';
 import {RunResult} from '../../models/checks/checks-model';
 import {
+  computeIsExpandable,
   createFixAction,
   createPleaseFixComment,
   iconFor,
@@ -244,9 +245,9 @@
     `;
   }
 
-  override updated(changedProperties: PropertyValues) {
+  override willUpdate(changedProperties: PropertyValues) {
     if (changedProperties.has('result')) {
-      this.isExpandable = !!this.result?.summary && !!this.result?.message;
+      this.isExpandable = computeIsExpandable(this.result);
     }
   }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index 0377e0e..51ee41d 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -45,6 +45,13 @@
             There is a lot to be said. A lot. I say, a lot.
                 So please keep reading.
           </div>
+          <div aria-checked="false"
+               aria-label="Expand result row"
+               class="show-hide"
+               role="switch"
+               tabindex="0">
+            <gr-icon icon="expand_more"></gr-icon>
+          </div>
         </div>
         <div class="details"></div>
       </div>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
index 1d2a272..264c6e0 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -4,6 +4,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
+import {sameOrigin} from '../../../utils/url-util';
+
 /**
  * This file was originally a copy of https://github.com/visionmedia/page.js.
  * It was converted to TypeScript and stripped off lots of code that we don't
@@ -252,17 +254,6 @@
   };
 }
 
-function sameOrigin(href: string) {
-  if (!href) return false;
-  const url = new URL(href, window.location.toString());
-  const loc = window.location;
-  return (
-    loc.protocol === url.protocol &&
-    loc.hostname === url.hostname &&
-    loc.port === url.port
-  );
-}
-
 function samePath(url: HTMLAnchorElement) {
   const loc = window.location;
   return url.pathname === loc.pathname && url.search === loc.search;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index aa6bb7a4..864f559 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -96,7 +96,7 @@
   getPatchRangeForCommentUrl,
   isInBaseOfPatchRange,
 } from '../../../utils/comment-util';
-import {isFileUnchanged} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {isFileUnchanged} from '../../../utils/diff-util';
 import {Route, ViewState} from '../../../models/views/base';
 import {Model} from '../../../models/model';
 import {
@@ -1446,6 +1446,10 @@
       diffView: {path: ctx.params[8]},
     };
     const queryMap = new URLSearchParams(ctx.querystring);
+    const checksPatchset = Number(queryMap.get('checksPatchset'));
+    if (Number.isInteger(checksPatchset) && checksPatchset > 0) {
+      state.checksPatchset = checksPatchset as PatchSetNumber;
+    }
     if (queryMap.has('forceReload')) state.forceReload = true;
     const address = this.parseLineAddress(ctx.hash);
     if (address) {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index b97f54f..7723c66 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -7,6 +7,7 @@
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-icon/gr-icon';
 import '../../../embed/diff/gr-diff/gr-diff';
+import '../../../embed/diff-old/gr-diff/gr-diff';
 import {navigationToken} from '../../core/gr-navigation/gr-navigation';
 import {
   NumericChangeId,
@@ -24,7 +25,7 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
 import {css, html, LitElement, nothing} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subscribe} from '../../lit/subscription-controller';
 import {assert} from '../../../utils/common-util';
@@ -35,9 +36,11 @@
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
-import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {anyLineTooLong} from '../../../utils/diff-util';
 import {fireReload} from '../../../utils/event-util';
 import {when} from 'lit/directives/when.js';
+import {Timing} from '../../../constants/reporting';
+import {changeModelToken} from '../../../models/change/change-model';
 
 interface FilePreview {
   filepath: string;
@@ -61,10 +64,10 @@
   @query('#nextFix')
   nextFix?: GrButton;
 
-  @property({type: Object})
+  @state()
   change?: ParsedChangeInfo;
 
-  @property({type: Number})
+  @state()
   changeNum?: NumericChangeId;
 
   @state()
@@ -101,8 +104,12 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly reporting = getAppContext().reportingService;
+
   private readonly syntaxLayer = new GrSyntaxLayerWorker(
     resolve(this, highlightServiceToken),
     () => getAppContext().reportingService
@@ -130,6 +137,16 @@
         this.syntaxLayer.setEnabled(!!this.diffPrefs.syntax_highlighting);
       }
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => (this.change = change)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => (this.changeNum = changeNum)
+    );
   }
 
   static override styles = [
@@ -154,6 +171,14 @@
         align-items: center;
         margin-right: var(--spacing-l);
       }
+      .info {
+        background-color: var(--info-background);
+        padding: var(--spacing-l) var(--spacing-xl);
+      }
+      .info gr-icon {
+        color: var(--selected-foreground);
+        margin-right: var(--spacing-xl);
+      }
     `,
   ];
 
@@ -246,7 +271,9 @@
 
   private renderWarning(message: string) {
     if (!message) return nothing;
-    return html`<span><gr-icon icon="info"></gr-icon>${message}</span>`;
+    return html`<span class="info"
+      ><gr-icon icon="info"></gr-icon>${message}</span
+    >`;
   }
 
   /**
@@ -266,7 +293,9 @@
   private async showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
     this.currentFix = fixSuggestion;
     this.loading = true;
+    this.reporting.time(Timing.PREVIEW_FIX_LOAD);
     await this.fetchFixPreview(fixSuggestion);
+    this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD);
     this.loading = false;
   }
 
@@ -376,6 +405,7 @@
       throw new Error('Not all required properties are set.');
     }
     this.isApplyFixLoading = true;
+    this.reporting.time(Timing.APPLY_FIX_LOAD);
     let res;
     if (this.fixSuggestions?.[0].fix_id === PROVIDED_FIX_ID) {
       res = await this.restApiService.applyFixSuggestion(
@@ -401,6 +431,7 @@
       this.close(true);
     }
     this.isApplyFixLoading = false;
+    this.reporting.timeEnd(Timing.APPLY_FIX_LOAD);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 2ccae8d..33c8c22 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -6,13 +6,12 @@
 import '../../shared/gr-comment-thread/gr-comment-thread';
 import '../../checks/gr-diff-check-result';
 import '../../../embed/diff/gr-diff/gr-diff';
+import '../../../embed/diff-old/gr-diff/gr-diff';
 import {
   anyLineTooLong,
   getDiffLength,
-  getLine,
-  getSide,
   SYNTAX_MAX_LINE_LENGTH,
-} from '../../../embed/diff/gr-diff/gr-diff-utils';
+} from '../../../utils/diff-util';
 import {getAppContext} from '../../../services/app-context';
 import {
   getParentIndex,
@@ -47,13 +46,10 @@
   IgnoreWhitespaceType,
   WebLinkInfo,
 } from '../../../types/diff';
-import {
-  CreateCommentEventDetail,
-  GrDiff,
-} from '../../../embed/diff/gr-diff/gr-diff';
+import {GrDiff as GrDiffNew} from '../../../embed/diff/gr-diff/gr-diff';
+import {GrDiff} from '../../../embed/diff-old/gr-diff/gr-diff';
 import {DiffViewMode, Side, CommentSide} from '../../../constants/constants';
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
-import {LineNumber, FILE} from '../../../embed/diff/gr-diff/gr-diff-line';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {
@@ -64,14 +60,18 @@
   waitForEventOnce,
 } from '../../../utils/event-util';
 import {assertIsDefined} from '../../../utils/common-util';
-import {DiffContextExpandedEventDetail} from '../../../embed/diff/gr-diff-builder/gr-diff-builder';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
 import {Timing} from '../../../constants/reporting';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {Subscription} from 'rxjs';
 import {
+  CreateCommentEventDetail,
+  DiffContextExpandedExternalDetail,
   DisplayLine,
+  FILE,
+  LineNumber,
   LineSelectedEventDetail,
+  LOST,
   RenderPreferences,
 } from '../../../api/diff';
 import {resolve} from '../../../models/dependency';
@@ -125,7 +125,6 @@
   interface HTMLElementEventMap {
     // prettier-ignore
     'render': CustomEvent<{}>;
-    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
     'create-comment': CustomEvent<CreateCommentEventDetail>;
     'is-blame-loaded-changed': ValueChangedEvent<boolean>;
     'diff-changed': ValueChangedEvent<DiffInfo | undefined>;
@@ -148,8 +147,9 @@
  */
 @customElement('gr-diff-host')
 export class GrDiffHost extends LitElement {
+  // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
   @query('#diff')
-  diffElement?: GrDiff;
+  diffElement?: GrDiff | GrDiffNew;
 
   @property({type: Number})
   changeNum?: NumericChangeId;
@@ -754,7 +754,7 @@
     const pointer = check.codePointers?.[0];
     assertIsDefined(pointer, 'code pointer of check result in diff');
     const line: LineNumber =
-      pointer.range?.end_line || pointer.range?.start_line || 'FILE';
+      pointer.range?.end_line || pointer.range?.start_line || FILE;
     const el = document.createElement('gr-diff-check-result');
     // This is what gr-diff expects, even though this is a check, not a comment.
     el.className = 'comment-thread';
@@ -908,11 +908,6 @@
     );
   }
 
-  addDraftAtLine(el: Element) {
-    assertIsDefined(this.diffElement);
-    this.diffElement.addDraftAtLine(el);
-  }
-
   clearDiffContent() {
     this.diffElement?.clearDiffContent();
   }
@@ -1212,53 +1207,15 @@
     threadEl.showPortedComment = !!thread.ported;
     // These attributes are the "interface" between comment threads and gr-diff.
     // <gr-comment-thread> does not care about them and is not affected by them.
-    threadEl.setAttribute('slot', `${diffSide}-${thread.line || 'LOST'}`);
+    threadEl.setAttribute('slot', `${diffSide}-${thread.line || LOST}`);
     threadEl.setAttribute('diff-side', `${diffSide}`);
-    threadEl.setAttribute('line-num', `${thread.line || 'LOST'}`);
+    threadEl.setAttribute('line-num', `${thread.line || LOST}`);
     if (thread.range) {
       threadEl.setAttribute('range', `${JSON.stringify(thread.range)}`);
     }
     return threadEl;
   }
 
-  // Private but used in tests.
-  filterThreadElsForLocation(
-    threadEls: GrCommentThread[],
-    lineInfo: LineInfo,
-    side: Side
-  ) {
-    function matchesLeftLine(threadEl: GrCommentThread) {
-      return (
-        getSide(threadEl) === Side.LEFT &&
-        getLine(threadEl) === lineInfo.beforeNumber
-      );
-    }
-    function matchesRightLine(threadEl: GrCommentThread) {
-      return (
-        getSide(threadEl) === Side.RIGHT &&
-        getLine(threadEl) === lineInfo.afterNumber
-      );
-    }
-    function matchesFileComment(threadEl: GrCommentThread) {
-      return getSide(threadEl) === side && getLine(threadEl) === FILE;
-    }
-
-    // Select the appropriate matchers for the desired side and line
-    const matchers: ((thread: GrCommentThread) => boolean)[] = [];
-    if (side === Side.LEFT) {
-      matchers.push(matchesLeftLine);
-    }
-    if (side === Side.RIGHT) {
-      matchers.push(matchesRightLine);
-    }
-    if (lineInfo.afterNumber === FILE || lineInfo.beforeNumber === FILE) {
-      matchers.push(matchesFileComment);
-    }
-    return threadEls.filter(threadEl =>
-      matchers.some(matcher => matcher(threadEl))
-    );
-  }
-
   private getIgnoreWhitespace(): IgnoreWhitespaceType {
     if (!this.prefs || !this.prefs.ignore_whitespace) {
       return 'IGNORE_NONE';
@@ -1338,7 +1295,7 @@
   }
 
   private handleDiffContextExpanded(
-    e: CustomEvent<DiffContextExpandedEventDetail>
+    e: CustomEvent<DiffContextExpandedExternalDetail>
   ) {
     this.reporting.reportInteraction('diff-context-expanded', {
       numLines: e.detail.numLines,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index 43045f7..e0d5f1d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -43,8 +43,7 @@
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {CoverageType} from '../../../types/types';
-import {GrDiffBuilderImage} from '../../../embed/diff/gr-diff-builder/gr-diff-builder-image';
-import {GrDiffHost, LineInfo} from './gr-diff-host';
+import {GrDiffHost} from './gr-diff-host';
 import {DiffInfo, DiffViewMode, IgnoreWhitespaceType} from '../../../api/diff';
 import {ErrorCallback} from '../../../api/rest';
 import {SinonStub, SinonStubbedMember} from 'sinon';
@@ -318,10 +317,6 @@
       // Recognizes that it should be an image diff.
       assert.isTrue(element.isImageDiff);
       assertIsDefined(element.diffElement);
-      assert.instanceOf(
-        element.diffElement.diffBuilder.builder,
-        GrDiffBuilderImage
-      );
 
       // Left image rendered with the parent commit's version of the file.
       assertIsDefined(element.diffElement);
@@ -393,10 +388,6 @@
       // Recognizes that it should be an image diff.
       assert.isTrue(element.isImageDiff);
       assertIsDefined(element.diffElement);
-      assert.instanceOf(
-        element.diffElement.diffBuilder.builder,
-        GrDiffBuilderImage
-      );
 
       // Left image rendered with the parent commit's version of the file.
       assertIsDefined(element.diffElement.diffTable);
@@ -464,10 +455,6 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assertIsDefined(element.diffElement);
-        assert.instanceOf(
-          element.diffElement.diffBuilder.builder,
-          GrDiffBuilderImage
-        );
         assertIsDefined(element.diffElement.diffTable);
         const diffTable = element.diffElement.diffTable;
 
@@ -512,11 +499,6 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assertIsDefined(element.diffElement);
-        assert.instanceOf(
-          element.diffElement.diffBuilder.builder,
-          GrDiffBuilderImage
-        );
-
         assertIsDefined(element.diffElement.diffTable);
         const diffTable = element.diffElement.diffTable;
 
@@ -566,10 +548,6 @@
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
         assertIsDefined(element.diffElement);
-        assert.instanceOf(
-          element.diffElement.diffBuilder.builder,
-          GrDiffBuilderImage
-        );
         assertIsDefined(element.diffElement.diffTable);
         const diffTable = element.diffElement.diffTable;
 
@@ -652,16 +630,11 @@
       element.blame = [];
       await element.updateComplete;
       assertIsDefined(element.diffElement);
-      const setBlameSpy = sinon.spy(
-        element.diffElement.diffBuilder,
-        'setBlame'
-      );
       const isBlameLoadedStub = sinon.stub();
       element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
       element.clearBlame();
       await element.updateComplete;
       assert.isNull(element.blame);
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
       assert.isTrue(isBlameLoadedStub.calledOnce);
       assert.isFalse(isBlameLoadedStub.args[0][0].detail.value);
     });
@@ -731,16 +704,6 @@
     assert.deepEqual(element.getThreadEls(), [threadEl]);
   });
 
-  test('delegates addDraftAtLine(el)', () => {
-    const param0 = document.createElement('b');
-    assertIsDefined(element.diffElement);
-    const stub = sinon.stub(element.diffElement, 'addDraftAtLine');
-    element.addDraftAtLine(param0);
-    assert.isTrue(stub.calledOnce);
-    assert.equal(stub.lastCall.args.length, 1);
-    assert.equal(stub.lastCall.args[0], param0);
-  });
-
   test('delegates clearDiffContent()', () => {
     assertIsDefined(element.diffElement);
     const stub = sinon.stub(element.diffElement, 'clearDiffContent');
@@ -1299,71 +1262,6 @@
     });
   });
 
-  test('filterThreadElsForLocation with no threads', () => {
-    const line = {beforeNumber: 3, afterNumber: 5};
-    const threads: GrCommentThread[] = [];
-    assert.deepEqual(
-      element.filterThreadElsForLocation(threads, line, Side.LEFT),
-      []
-    );
-    assert.deepEqual(
-      element.filterThreadElsForLocation(threads, line, Side.RIGHT),
-      []
-    );
-  });
-
-  test('filterThreadElsForLocation for line comments', () => {
-    const line = {beforeNumber: 3, afterNumber: 5};
-
-    const l3 = document.createElement('gr-comment-thread');
-    l3.setAttribute('line-num', '3');
-    l3.setAttribute('diff-side', Side.LEFT);
-
-    const l5 = document.createElement('gr-comment-thread');
-    l5.setAttribute('line-num', '5');
-    l5.setAttribute('diff-side', Side.LEFT);
-
-    const r3 = document.createElement('gr-comment-thread');
-    r3.setAttribute('line-num', '3');
-    r3.setAttribute('diff-side', Side.RIGHT);
-
-    const r5 = document.createElement('gr-comment-thread');
-    r5.setAttribute('line-num', '5');
-    r5.setAttribute('diff-side', Side.RIGHT);
-
-    const threadEls: GrCommentThread[] = [l3, l5, r3, r5];
-    assert.deepEqual(
-      element.filterThreadElsForLocation(threadEls, line, Side.LEFT),
-      [l3]
-    );
-    assert.deepEqual(
-      element.filterThreadElsForLocation(threadEls, line, Side.RIGHT),
-      [r5]
-    );
-  });
-
-  test('filterThreadElsForLocation for file comments', () => {
-    const line: LineInfo = {beforeNumber: 'FILE', afterNumber: 'FILE'};
-
-    const l = document.createElement('gr-comment-thread');
-    l.setAttribute('diff-side', Side.LEFT);
-    l.setAttribute('line-num', 'FILE');
-
-    const r = document.createElement('gr-comment-thread');
-    r.setAttribute('diff-side', Side.RIGHT);
-    r.setAttribute('line-num', 'FILE');
-
-    const threadEls: GrCommentThread[] = [l, r];
-    assert.deepEqual(
-      element.filterThreadElsForLocation(threadEls, line, Side.LEFT),
-      [l]
-    );
-    assert.deepEqual(
-      element.filterThreadElsForLocation(threadEls, line, Side.RIGHT),
-      [r]
-    );
-  });
-
   suite('syntax layer with syntax_highlighting on', async () => {
     setup(async () => {
       const prefs = {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
similarity index 95%
rename from polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
rename to polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index a9bdab8..1d46841 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -5,8 +5,8 @@
  */
 import {Subscription} from 'rxjs';
 import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import '../../../elements/shared/gr-button/gr-button';
-import '../../../elements/shared/gr-icon/gr-icon';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
 import {DiffViewMode} from '../../../constants/constants';
 import {customElement, property, state} from 'lit/decorators.js';
 import {fireIronAnnounce} from '../../../utils/event-util';
@@ -15,7 +15,7 @@
 import {css, html, LitElement} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {userModelToken} from '../../../models/user/user-model';
-import {ironAnnouncerRequestAvailability} from '../../../elements/polymer-util';
+import {ironAnnouncerRequestAvailability} from '../../polymer-util';
 
 @customElement('gr-diff-mode-selector')
 export class GrDiffModeSelector extends LitElement {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
similarity index 98%
rename from polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
rename to polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index d646988..0b6a5b0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -16,7 +16,7 @@
 } from '../../../models/browser/browser-model';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
 import {createPreferences} from '../../../test/test-data-generators';
-import {GrButton} from '../../../elements/shared/gr-button/gr-button';
+import {GrButton} from '../../shared/gr-button/gr-button';
 import {testResolver} from '../../../test/common-test-setup';
 
 suite('gr-diff-mode-selector tests', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index bdb634b..0b14f4f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -14,10 +14,9 @@
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-weblink/gr-weblink';
 import '../../shared/revision-info/revision-info';
-import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
 import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import '../gr-diff-host/gr-diff-host';
-import '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import '../gr-diff-mode-selector/gr-diff-mode-selector';
 import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../gr-patch-range-select/gr-patch-range-select';
 import '../../change/gr-download-dialog/gr-download-dialog';
@@ -46,7 +45,6 @@
   PreferencesInfo,
   RepoName,
   RevisionPatchSetNum,
-  ServerInfo,
   CommentMap,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo, WebLinkInfo} from '../../../types/diff';
@@ -55,7 +53,8 @@
   FilesWebLinks,
   PatchRangeChangeEvent,
 } from '../gr-patch-range-select/gr-patch-range-select';
-import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
+import {GrDiffCursor as GrDiffCursorNew} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
+import {GrDiffCursor} from '../../../embed/diff-old/gr-diff-cursor/gr-diff-cursor';
 import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
 import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
 import {OpenFixPreviewEvent, ValueChangedEvent} from '../../../types/events';
@@ -80,7 +79,6 @@
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {subscribe} from '../../lit/subscription-controller';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {configModelToken} from '../../../models/config/config-model';
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ifDefined} from 'lit/directives/if-defined.js';
@@ -98,6 +96,7 @@
   FileNameToNormalizedFileInfoMap,
   filesModelToken,
 } from '../../../models/change/files-model';
+import {isNewDiff} from '../../../embed/diff/gr-diff/gr-diff-utils';
 
 const LOADING_BLAME = 'Loading blame...';
 const LOADED_BLAME = 'Blame loaded';
@@ -194,9 +193,6 @@
   @property({type: Object})
   prefs?: DiffPreferencesInfo;
 
-  @state()
-  private serverConfig?: ServerInfo;
-
   // Private but used in tests.
   @state()
   userPrefs?: PreferencesInfo;
@@ -241,14 +237,13 @@
 
   private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
-  private readonly getConfigModel = resolve(this, configModelToken);
-
   private readonly getViewModel = resolve(this, changeViewModelToken);
 
   private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
 
+  // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
   @state()
-  cursor?: GrDiffCursor;
+  cursor?: GrDiffCursor | GrDiffCursorNew;
 
   private readonly shortcutsController = new ShortcutController(this);
 
@@ -340,13 +335,6 @@
     );
     subscribe(
       this,
-      () => this.getConfigModel().serverConfig$,
-      config => {
-        this.serverConfig = config;
-      }
-    );
-    subscribe(
-      this,
       () => this.getCommentsModel().changeComments$,
       changeComments => {
         this.changeComments = changeComments;
@@ -672,7 +660,8 @@
       this.handleToggleFileReviewed()
     );
     this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
-    this.cursor = new GrDiffCursor();
+    // TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+    this.cursor = isNewDiff() ? new GrDiffCursorNew() : new GrDiffCursor();
     if (this.diffHost) this.reInitCursor();
   }
 
@@ -966,12 +955,8 @@
   }
 
   private renderDialogs() {
-    return html` <gr-apply-fix-dialog
-        id="applyFixDialog"
-        .change=${this.change}
-        .changeNum=${this.changeNum}
-      >
-      </gr-apply-fix-dialog>
+    return html`
+      <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
       <gr-diff-preferences-dialog
         id="diffPreferencesDialog"
         @reload-diff-preference=${this.handleReloadingDiffPreference}
@@ -980,12 +965,10 @@
       <dialog id="downloadModal" tabindex="-1">
         <gr-download-dialog
           id="downloadDialog"
-          .change=${this.change}
-          .patchNum=${this.patchNum}
-          .config=${this.serverConfig?.download}
           @close=${this.handleDownloadDialogClose}
         ></gr-download-dialog>
-      </dialog>`;
+      </dialog>
+    `;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 737e964..6896ca8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -57,7 +57,7 @@
   LoadingStatus,
 } from '../../../models/change/change-model';
 import {assertIsDefined} from '../../../utils/common-util';
-import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {testResolver} from '../../../test/common-test-setup';
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 759a9cb..75bfca2 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -118,7 +118,7 @@
 
   @state() private version?: string;
 
-  @state() private view?: GerritView;
+  @state() view?: GerritView;
 
   // TODO: Introduce a wrapper element for CHANGE, DIFF, EDIT view.
   @state() private childView?: ChangeChildView;
@@ -549,8 +549,10 @@
 
   private renderPluginScreen() {
     if (this.view !== GerritView.PLUGIN_SCREEN) return nothing;
+    if (!this.params) return nothing;
     const pluginViewState = this.params as PluginViewState;
     const pluginScreenName = this.computePluginScreenName();
+
     return keyed(
       pluginScreenName,
       html`
diff --git a/polygerrit-ui/app/elements/gr-app-element_test.ts b/polygerrit-ui/app/elements/gr-app-element_test.ts
new file mode 100644
index 0000000..ec415ff
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element_test.ts
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-app';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrAppElement} from './gr-app-element';
+import {queryAndAssert} from '../utils/common-util';
+import {GerritView} from '../services/router/router-model';
+import {PluginViewState} from '../models/views/plugin';
+import {GrEndpointDecorator} from './plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+
+suite('gr-app-element tests', () => {
+  let element: GrAppElement;
+
+  setup(async () => {
+    element = await fixture<GrAppElement>(
+      html`<gr-app-element></gr-app-element>`
+    );
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `
+        <gr-css-mixins> </gr-css-mixins>
+        <gr-endpoint-decorator name="banner"> </gr-endpoint-decorator>
+        <gr-main-header loggedin id="mainHeader" role="banner">
+        </gr-main-header>
+        <main>
+          <div class="errorView" id="errorView">
+            <div class="errorEmoji"></div>
+            <div class="errorText"></div>
+            <div class="errorMoreInfo"></div>
+          </div>
+        </main>
+        <footer>
+          <div>
+            Powered by
+            <a
+              href="https://www.gerritcodereview.com/"
+              rel="noopener"
+              target="_blank"
+            >
+              Gerrit Code Review
+            </a>
+            ()
+            <gr-endpoint-decorator name="footer-left"> </gr-endpoint-decorator>
+          </div>
+          <div>
+            Press “?” for keyboard shortcuts
+            <gr-endpoint-decorator name="footer-right"> </gr-endpoint-decorator>
+          </div>
+        </footer>
+        <gr-notifications-prompt> </gr-notifications-prompt>
+        <gr-endpoint-decorator name="plugin-overlay"> </gr-endpoint-decorator>
+        <gr-error-manager id="errorManager"> </gr-error-manager>
+        <gr-plugin-host id="plugins"> </gr-plugin-host>
+      `
+    );
+  });
+
+  test('renders plugin screen, changes endpoint instance', async () => {
+    element.view = GerritView.PLUGIN_SCREEN;
+    element.params = {
+      view: GerritView.PLUGIN_SCREEN,
+      screen: 'test-screen-1',
+      plugin: 'test-plugin',
+    } as PluginViewState;
+    await element.updateComplete;
+
+    const main1 = queryAndAssert(element, 'main');
+    const endpoint1 = queryAndAssert<GrEndpointDecorator>(
+      main1,
+      'gr-endpoint-decorator'
+    );
+    assert.equal(endpoint1.name, 'test-plugin-screen-test-screen-1');
+
+    element.params = {
+      view: GerritView.PLUGIN_SCREEN,
+      screen: 'test-screen-2',
+      plugin: 'test-plugin',
+    } as PluginViewState;
+    await element.updateComplete;
+
+    const main2 = queryAndAssert(element, 'main');
+    const endpoint2 = queryAndAssert<GrEndpointDecorator>(
+      main2,
+      'gr-endpoint-decorator'
+    );
+    assert.equal(endpoint2.name, 'test-plugin-screen-test-screen-2');
+
+    // Plugin screen endpoints have a variable name. Lit must not re-use the
+    // same element instance. (Issue 16884)
+    assert.isFalse(endpoint1 === endpoint2);
+  });
+});
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index d6a14ed..6589ee8 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -21,6 +21,7 @@
   initErrorReporter,
   initWebVitals,
   initClickReporter,
+  initInteractionReporter,
 } from '../services/gr-reporting/gr-reporting_impl';
 import {Finalizable} from '../services/registry';
 
@@ -36,6 +37,7 @@
     initWebVitals(reportingService);
     initErrorReporter(reportingService);
     initClickReporter(reportingService);
+    initInteractionReporter(reportingService);
   }
   window.GrAnnotation = GrAnnotation;
   window.GrPluginActionContext = GrPluginActionContext;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index c76f04c..348ea3b 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -8,6 +8,7 @@
 import '../gr-comment/gr-comment';
 import '../gr-icon/gr-icon';
 import '../../../embed/diff/gr-diff/gr-diff';
+import '../../../embed/diff-old/gr-diff/gr-diff';
 import '../gr-copy-clipboard/gr-copy-clipboard';
 import {css, html, nothing, LitElement, PropertyValues} from 'lit';
 import {
@@ -44,10 +45,9 @@
   UrlEncodedCommentId,
 } from '../../../types/common';
 import {CommentEditingChangedDetail, GrComment} from '../gr-comment/gr-comment';
-import {FILE} from '../../../embed/diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {DiffLayer, RenderPreferences} from '../../../api/diff';
+import {DiffLayer, FILE, RenderPreferences} from '../../../api/diff';
 import {
   assert,
   assertIsDefined,
@@ -56,7 +56,7 @@
 import {fire} from '../../../utils/event-util';
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
-import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {anyLineTooLong} from '../../../utils/diff-util';
 import {getUserName} from '../../../utils/display-name-util';
 import {generateAbsoluteUrl} from '../../../utils/url-util';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -74,6 +74,7 @@
 import {createChangeUrl, createDiffUrl} from '../../../models/views/change';
 import {userModelToken} from '../../../models/user/user-model';
 import {highlightServiceToken} from '../../../services/highlight/highlight-service';
+import {waitUntil} from '../../../utils/async-util';
 
 declare global {
   interface HTMLElementEventMap {
@@ -112,6 +113,9 @@
   @query('.comment-box')
   commentBox?: HTMLElement;
 
+  @query('gr-comment.draft')
+  draftElement?: GrComment;
+
   @queryAll('gr-comment')
   commentElements?: NodeList;
 
@@ -495,6 +499,7 @@
         : !this.unresolved);
     return html`
       <gr-comment
+        class=${classMap({draft: isDraft(comment)})}
         .comment=${comment}
         .comments=${this.thread!.comments}
         ?initially-collapsed=${initiallyCollapsed}
@@ -646,6 +651,15 @@
         }, 500);
       });
     }
+    if (this.thread && isDraft(this.getFirstComment())) {
+      const msg = this.getFirstComment()?.message ?? '';
+      if (msg.length === 0) this.editDraft();
+    }
+  }
+
+  private async editDraft() {
+    await waitUntil(() => !!this.draftElement);
+    this.draftElement!.edit();
   }
 
   private isDraft() {
@@ -797,6 +811,7 @@
     const newReply = createNewReply(replyingTo, content, unresolved);
     if (userWantsToEdit) {
       this.getCommentsModel().addNewDraft(newReply);
+      this.editDraft();
     } else {
       try {
         this.saving = true;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 1027a62..14ed24f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -120,7 +120,11 @@
               robot-button-disabled=""
               show-patchset=""
             ></gr-comment>
-            <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+            <gr-comment
+              class="draft"
+              robot-button-disabled=""
+              show-patchset=""
+            ></gr-comment>
           </div>
         </div>
       `
@@ -145,7 +149,11 @@
         <div id="container">
           <h3 class="assistive-tech-only">Draft Comment thread by Yoda</h3>
           <div class="comment-box" tabindex="0">
-            <gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
+            <gr-comment
+              class="draft"
+              robot-button-disabled=""
+              show-patchset=""
+            ></gr-comment>
           </div>
         </div>
       `
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 27a5590..158799d 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -55,7 +55,7 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {classMap} from 'lit/directives/class-map.js';
-import {LineNumber} from '../../../api/diff';
+import {FILE, LineNumber} from '../../../api/diff';
 import {CommentSide, SpecialFilePath} from '../../../constants/constants';
 import {Subject} from 'rxjs';
 import {debounceTime} from 'rxjs/operators';
@@ -66,8 +66,6 @@
 import {userModelToken} from '../../../models/user/user-model';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 
-const FILE = 'FILE';
-
 // visible for testing
 export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
 
@@ -926,13 +924,6 @@
     if (this.permanentEditingMode) {
       this.edit();
     }
-    if (
-      isDraft(this.comment) &&
-      isNew(this.comment) &&
-      !isSaving(this.comment)
-    ) {
-      this.edit();
-    }
     if (isDraft(this.comment)) {
       this.collapsed = false;
     } else {
@@ -943,6 +934,10 @@
   override updated(changed: PropertyValues) {
     if (changed.has('editing')) {
       if (this.editing && !this.permanentEditingMode) {
+        // Note that this is a bit fragile, because we are relying on the
+        // comment to become visible soonish. If that does not happen, then we
+        // will be waiting indefinitely and grab focus at some point in the
+        // distant future.
         whenVisible(this, () => this.textarea?.putCursorAtEnd());
       }
     }
@@ -983,7 +978,7 @@
   }
 
   /** Enter editing mode. */
-  private edit() {
+  edit() {
     assert(isDraft(this.comment), 'only drafts are editable');
     if (this.editing) return;
     this.editing = true;
@@ -1067,6 +1062,8 @@
   }
 
   override focus() {
+    // Note that this may not work as intended, because the textarea is not
+    // rendered yet.
     this.textarea?.focus();
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 5b26ede..292da58 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -17,6 +17,8 @@
 import {customElement, property, query} from 'lit/decorators.js';
 import {GrButton} from '../gr-button/gr-button';
 import {GrIcon} from '../gr-icon/gr-icon';
+import {getAppContext} from '../../../services/app-context';
+import {Timing} from '../../../constants/reporting';
 
 const COPY_TIMEOUT_MS = 1000;
 
@@ -46,6 +48,8 @@
   @query('#icon')
   iconEl!: GrIcon;
 
+  private readonly reporting = getAppContext().reportingService;
+
   static override get styles() {
     return [
       css`
@@ -141,7 +145,12 @@
     this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
     assertIsDefined(this.text, 'text');
     this.iconEl.icon = 'check';
-    copyToClipbard(this.text, this.copyTargetName ?? 'Link');
+    this.reporting.time(Timing.COPY_TO_CLIPBOARD);
+    copyToClipbard(this.text, this.copyTargetName ?? 'Link').finally(() => {
+      this.reporting.timeEnd(Timing.COPY_TO_CLIPBOARD, {
+        copyTargetName: this.copyTargetName,
+      });
+    });
     setTimeout(() => (this.iconEl.icon = 'content_copy'), COPY_TIMEOUT_MS);
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 6e0a11f..e880531 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -25,6 +25,7 @@
   getUserSuggestionFromString,
   USER_SUGGESTION_INFO_STRING,
 } from '../../../utils/comment-util';
+import {sameOrigin} from '../../../utils/url-util';
 
 /**
  * This element optionally renders markdown and also applies some regex
@@ -203,9 +204,8 @@
         /* HTML */
         `<a
           href="${href}"
-          target="_blank"
+          ${sameOrigin(href) ? '' : 'target="_blank" rel="noopener"'}
           ${title ? `title="${title}"` : ''}
-          rel="noopener"
           >${text}</a
         >`;
       renderer['image'] = (href: string, _title: string, text: string) =>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 81048a3..9d7f06e 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -502,7 +502,11 @@
     });
 
     test('renders inline links into <a> tags', async () => {
-      element.content = '[myLink](https://www.google.com)';
+      const origin = window.location.origin;
+      element.content = `[myLink1](https://www.google.com)
+        [myLink2](/destiny)
+        [myLink3](${origin}/destiny)
+      `;
       await element.updateComplete;
 
       assert.shadowDom.equal(
@@ -512,8 +516,12 @@
             <div slot="markdown-html" class="markdown-html">
               <p>
                 <a href="https://www.google.com" rel="noopener" target="_blank"
-                  >myLink</a
+                  >myLink1</a
                 >
+                <br />
+                <a href="/destiny">myLink2</a>
+                <br />
+                <a href="${origin}/destiny">myLink3</a>
               </p>
             </div>
           </marked-element>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
index 4977ec5..70e653a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -10,6 +10,7 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {PluginApi} from '../../../api/plugin';
 import {
+  ActionPriority,
   ActionType,
   ChangeActionsPluginApi,
   PrimaryActionKey,
@@ -169,7 +170,11 @@
       let buttons = queryAll<GrButton>(element, '[data-action-key]');
       assert.equal(buttons[0].getAttribute('data-action-key'), key1);
       assert.equal(buttons[1].getAttribute('data-action-key'), key2);
-      changeActions.setActionPriority(ActionType.REVISION, key1, 10);
+      changeActions.setActionPriority(
+        ActionType.REVISION,
+        key1,
+        ActionPriority.PRIMARY
+      );
       await element.updateComplete;
       buttons = queryAll<GrButton>(element, '[data-action-key]');
       assert.equal(buttons[0].getAttribute('data-action-key'), key2);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 02f830d..f9289f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -66,8 +66,9 @@
   mutable = false;
 
   /**
-   * if true - show all reviewers that can vote on label
-   * if false - show only reviewers that voted on label
+   * if true - show all CC and reviewers who already voted and reviewers who can
+   * vote on label.
+   * if false - show only all CC and reviewers who already voted
    */
   @property({type: Boolean})
   showAllReviewers = true;
@@ -139,23 +140,10 @@
   override render() {
     const labelInfo = this.labelInfo;
     if (!labelInfo) return;
-    const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
-      .filter(reviewer => {
-        if (this.showAllReviewers) {
-          if (isDetailedLabelInfo(labelInfo)) {
-            return canReviewerVote(labelInfo, reviewer);
-          } else {
-            // isQuickLabelInfo
-            return hasVoted(labelInfo, reviewer);
-          }
-        } else {
-          // !showAllReviewers
-          return hasVoted(labelInfo, reviewer);
-        }
-      })
-      .sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
     return html`<div>
-      ${reviewers.map(reviewer => this.renderReviewerVote(reviewer))}
+      ${this.computeVoters(labelInfo).map(reviewer =>
+        this.renderReviewerVote(reviewer)
+      )}
     </div>`;
   }
 
@@ -221,6 +209,40 @@
   }
 
   /**
+   * if showAllReviewers = true  @return all CC and reviewers who already voted
+   * and reviewers who can vote on label
+   * Btw. if label is QuickLabelInfo we cannot provide list of reviewers who can
+   * vote on label
+   *
+   * if showAllReviewers = false @return just all CC and reviewers who already
+   * voted
+   *
+   * private but used in test
+   */
+  computeVoters(labelInfo: LabelInfo) {
+    const allReviewers = this.change?.reviewers['REVIEWER'] ?? [];
+    return allReviewers
+      .concat(this.change?.reviewers['CC'] ?? [])
+      .filter(account => {
+        if (this.showAllReviewers) {
+          if (
+            isDetailedLabelInfo(labelInfo) &&
+            allReviewers.includes(account)
+          ) {
+            return canReviewerVote(labelInfo, account);
+          } else {
+            // labelInfo is QuickLabelInfo or account is from CC
+            return hasVoted(labelInfo, account);
+          }
+        } else {
+          // !showAllReviewers
+          return hasVoted(labelInfo, account);
+        }
+      })
+      .sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
+  }
+
+  /**
    * A user is able to delete a vote iff the mutable property is true and the
    * reviewer that left the vote exists in the list of removable_reviewers
    * received from the backend.
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
index 67af61f..dad056b 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
@@ -153,4 +153,103 @@
     score = '0';
     assert.equal(element._computeValueTooltip(labelInfo, score), '');
   });
+
+  suite('computeVoters', () => {
+    const account2 = createAccountWithIdNameAndEmail(7);
+    test('show reviewer who voted', () => {
+      element.change = {
+        ...createParsedChange(),
+        labels: {},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [account2],
+        },
+      };
+      const approval: ApprovalInfo = {
+        value: 2,
+        _account_id: account._account_id,
+      };
+      const labelInfo = {
+        ...createDetailedLabelInfo(),
+        all: [approval],
+      };
+
+      assert.deepEqual(element.computeVoters(labelInfo), [account]);
+    });
+
+    test('show CC who voted', () => {
+      element.change = {
+        ...createParsedChange(),
+        labels: {},
+        reviewers: {
+          REVIEWER: [account2],
+          CC: [account],
+        },
+      };
+      const approval: ApprovalInfo = {
+        value: 2,
+        _account_id: account._account_id,
+      };
+      const labelInfo = {
+        ...createDetailedLabelInfo(),
+        all: [approval],
+      };
+
+      assert.deepEqual(element.computeVoters(labelInfo), [account]);
+    });
+
+    test('show all reviewers who can vote, we ignore CC who can vote', () => {
+      element.change = {
+        ...createParsedChange(),
+        labels: {},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [account2],
+        },
+      };
+      element.showAllReviewers = true;
+      const approval: ApprovalInfo = {
+        value: 0,
+        _account_id: account._account_id,
+      };
+      // do not show CC who can vote
+      const approval2: ApprovalInfo = {
+        value: 0,
+        _account_id: account2._account_id,
+      };
+      const labelInfo = {
+        ...createDetailedLabelInfo(),
+        all: [approval, approval2],
+      };
+
+      assert.deepEqual(element.computeVoters(labelInfo), [account]);
+    });
+
+    test('show all reviewers who can vote and CC who voted', () => {
+      element.change = {
+        ...createParsedChange(),
+        labels: {},
+        reviewers: {
+          REVIEWER: [account],
+          CC: [account2],
+        },
+      };
+      element.showAllReviewers = true;
+      const approval: ApprovalInfo = {
+        value: 0,
+        _account_id: account._account_id,
+      };
+      // do not show CC who can vote
+      const approval2: ApprovalInfo = {
+        value: 1,
+        _account_id: account2._account_id,
+      };
+      const labelInfo = {
+        ...createDetailedLabelInfo(),
+        all: [approval, approval2],
+      };
+
+      assert.deepEqual(element.computeVoters(labelInfo), [account, account2]);
+    });
+  });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 7779fff..95f1b8a 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -295,6 +295,8 @@
   }
 
   override focus() {
+    // Note that this may not work as intended, because the textarea is not
+    // rendered yet.
     this.textarea?.textarea.focus();
   }
 
@@ -627,7 +629,7 @@
     this.currentSearchString = '';
     this.closeDropdown();
     this.specialCharIndex = -1;
-    this.textarea?.textarea.focus();
+    this.focus();
   }
 
   private fireChangedEvents() {
diff --git a/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls-section.ts b/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls-section.ts
new file mode 100644
index 0000000..e558295
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls-section.ts
@@ -0,0 +1,138 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../elements/shared/gr-button/gr-button';
+import {html, LitElement} from 'lit';
+import {property, state} from 'lit/decorators.js';
+import {DiffInfo, DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {diffClasses, isNewDiff} from '../../diff/gr-diff/gr-diff-utils';
+import {getShowConfig} from './gr-context-controls';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+
+export class GrContextControlsSection extends LitElement {
+  /** Should context controls be rendered for expanding above the section? */
+  @property({type: Boolean}) showAbove = false;
+
+  /** Should context controls be rendered for expanding below the section? */
+  @property({type: Boolean}) showBelow = false;
+
+  /** Must be of type GrDiffGroupType.CONTEXT_CONTROL. */
+  @property({type: Object})
+  group?: GrDiffGroup;
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @property({type: Object})
+  renderPrefs?: RenderPreferences;
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  private renderPaddingRow(whereClass: 'above' | 'below') {
+    if (!this.showAbove && whereClass === 'above') return;
+    if (!this.showBelow && whereClass === 'below') return;
+    const modeClass = this.isSideBySide() ? 'side-by-side' : 'unified';
+    const type = this.isSideBySide()
+      ? GrDiffGroupType.CONTEXT_CONTROL
+      : undefined;
+    return html`
+      <tr
+        class=${diffClasses('contextBackground', modeClass, whereClass)}
+        left-type=${ifDefined(type)}
+        right-type=${ifDefined(type)}
+      >
+        <td class=${diffClasses('blame')} data-line-number="0"></td>
+        <td class=${diffClasses('contextLineNum')}></td>
+        ${when(
+          this.isSideBySide(),
+          () => html`
+            <td class=${diffClasses('sign')}></td>
+            <td class=${diffClasses()}></td>
+          `
+        )}
+        <td class=${diffClasses('contextLineNum')}></td>
+        ${when(
+          this.isSideBySide(),
+          () => html`<td class=${diffClasses('sign')}></td>`
+        )}
+        <td class=${diffClasses()}></td>
+      </tr>
+    `;
+  }
+
+  private isSideBySide() {
+    return this.renderPrefs?.view_mode !== DiffViewMode.UNIFIED;
+  }
+
+  private createContextControlRow() {
+    // Note that <td> table cells that have `display: none` don't count!
+    const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
+    const showConfig = getShowConfig(this.showAbove, this.showBelow);
+    return html`
+      <tr class=${diffClasses('dividerRow', `show-${showConfig}`)}>
+        <td class=${diffClasses('blame')} data-line-number="0"></td>
+        ${when(
+          this.isSideBySide(),
+          () => html`<td class=${diffClasses()}></td>`
+        )}
+        <td class=${diffClasses('dividerCell')} colspan=${colspan}>
+          <gr-context-controls
+            class=${diffClasses()}
+            .diff=${this.diff}
+            .renderPreferences=${this.renderPrefs}
+            .group=${this.group}
+            .showConfig=${showConfig}
+          >
+          </gr-context-controls>
+        </td>
+      </tr>
+    `;
+  }
+
+  override render() {
+    const rows = html`
+      ${this.renderPaddingRow('above')} ${this.createContextControlRow()}
+      ${this.renderPaddingRow('below')}
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${rows}
+      </table>`;
+    }
+    return rows;
+  }
+}
+
+if (!isNewDiff()) {
+  customElements.define(
+    'gr-context-controls-section',
+    GrContextControlsSection
+  );
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-context-controls-section': LitElement;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls-section_test.ts b/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls-section_test.ts
new file mode 100644
index 0000000..6a557fc
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls-section_test.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-context-controls-section';
+import {GrContextControlsSection} from './gr-context-controls-section';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-context-controls-section test', () => {
+  let element: GrContextControlsSection;
+
+  setup(async () => {
+    element = await fixture<GrContextControlsSection>(
+      html`<gr-context-controls-section></gr-context-controls-section>`
+    );
+    element.addTableWrapperForTesting = true;
+    await element.updateComplete;
+  });
+
+  test('render: normal with showAbove and showBelow', async () => {
+    element.showAbove = true;
+    element.showBelow = true;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              class="above contextBackground gr-diff side-by-side"
+              left-type="contextControl"
+              right-type="contextControl"
+            >
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+            </tr>
+            <tr class="dividerRow gr-diff show-both">
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="gr-diff"></td>
+              <td class="dividerCell gr-diff" colspan="3">
+                <gr-context-controls class="gr-diff" showconfig="both">
+                </gr-context-controls>
+              </td>
+            </tr>
+            <tr
+              class="below contextBackground gr-diff side-by-side"
+              left-type="contextControl"
+              right-type="contextControl"
+            >
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+              <td class="contextLineNum gr-diff"></td>
+              <td class="gr-diff sign"></td>
+              <td class="gr-diff"></td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls.ts
new file mode 100644
index 0000000..5695f4d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls.ts
@@ -0,0 +1,526 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '@polymer/paper-button/paper-button';
+import '@polymer/paper-card/paper-card';
+import '@polymer/paper-checkbox/paper-checkbox';
+import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
+import '@polymer/paper-icon-button/paper-icon-button';
+import '@polymer/paper-item/paper-item';
+import '@polymer/paper-listbox/paper-listbox';
+import '@polymer/paper-tooltip/paper-tooltip';
+import {of, EMPTY, Subject} from 'rxjs';
+import {switchMap, delay} from 'rxjs/operators';
+
+import '../../../elements/shared/gr-button/gr-button';
+import {pluralize} from '../../../utils/string-util';
+import {fire} from '../../../utils/event-util';
+import {DiffInfo} from '../../../types/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {css, html, LitElement, TemplateResult} from 'lit';
+import {property} from 'lit/decorators.js';
+import {subscribe} from '../../../elements/lit/subscription-controller';
+
+import {
+  ContextButtonType,
+  DiffContextButtonHoveredDetail,
+  RenderPreferences,
+  SyntaxBlock,
+} from '../../../api/diff';
+
+import {GrDiffGroup, hideInContextControl} from '../gr-diff/gr-diff-group';
+import {isNewDiff} from '../../diff/gr-diff/gr-diff-utils';
+
+declare global {
+  interface HTMLElementEventMap {
+    'diff-context-button-hovered': CustomEvent<DiffContextButtonHoveredDetail>;
+  }
+}
+
+const PARTIAL_CONTEXT_AMOUNT = 10;
+
+/**
+ * Traverses a hierarchical structure of syntax blocks and
+ * finds the most local/nested block that can be associated line.
+ * It finds the closest block that contains the whole line and
+ * returns the whole path from the syntax layer (blocks) sent as parameter
+ * to the most nested block - the complete path from the top to bottom layer of
+ * a syntax tree. Example: [myNamespace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
+ *
+ * @param lineNum line number for the targeted line.
+ * @param blocks Blocks for a specific syntax level in the file (to allow recursive calls)
+ */
+function findBlockTreePathForLine(
+  lineNum: number,
+  blocks?: SyntaxBlock[]
+): SyntaxBlock[] {
+  const containingBlock = blocks?.find(
+    ({range}) => range.start_line < lineNum && range.end_line > lineNum
+  );
+  if (!containingBlock) return [];
+  const innerPathInChild = findBlockTreePathForLine(
+    lineNum,
+    containingBlock?.children
+  );
+  return [containingBlock].concat(innerPathInChild);
+}
+
+export type GrContextControlsShowConfig = 'above' | 'below' | 'both';
+
+export function getShowConfig(
+  showAbove: boolean,
+  showBelow: boolean
+): GrContextControlsShowConfig {
+  if (showAbove && !showBelow) return 'above';
+  if (!showAbove && showBelow) return 'below';
+
+  // Note that !showAbove && !showBelow also intentionally returns 'both'.
+  // This means the file is completely collapsed, which is unusual, but at least
+  // happens in one test.
+  return 'both';
+}
+
+export class GrContextControls extends LitElement {
+  @property({type: Object}) renderPreferences?: RenderPreferences;
+
+  @property({type: Object}) diff?: DiffInfo;
+
+  @property({type: Object}) group?: GrDiffGroup;
+
+  @property({type: String, reflect: true})
+  showConfig: GrContextControlsShowConfig = 'both';
+
+  private expandButtonsHover = new Subject<{
+    eventType: 'enter' | 'leave';
+    buttonType: ContextButtonType;
+    linesToExpand: number;
+  }>();
+
+  static override styles = css`
+    :host {
+      display: flex;
+      justify-content: center;
+      flex-direction: column;
+      position: relative;
+    }
+
+    :host([showConfig='above']) {
+      justify-content: flex-end;
+      margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
+      margin-bottom: var(--gr-context-controls-margin-bottom);
+      height: calc(var(--line-height-normal) + var(--spacing-s));
+      .horizontalFlex {
+        align-items: end;
+      }
+    }
+
+    :host([showConfig='below']) {
+      justify-content: flex-start;
+      margin-top: 1px;
+      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      .horizontalFlex {
+        align-items: start;
+      }
+    }
+
+    :host([showConfig='both']) {
+      margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      height: calc(
+        2 * var(--line-height-normal) + 2 * var(--spacing-s) +
+          var(--divider-height)
+      );
+      .horizontalFlex {
+        align-items: center;
+      }
+    }
+
+    .contextControlButton {
+      background-color: var(--default-button-background-color);
+      font: var(--context-control-button-font, inherit);
+    }
+
+    paper-button {
+      text-transform: none;
+      align-items: center;
+      background-color: var(--background-color);
+      font-family: inherit;
+      margin: var(--margin, 0);
+      min-width: var(--border, 0);
+      color: var(--diff-context-control-color);
+      border: solid var(--border-color);
+      border-width: 1px;
+      border-radius: var(--border-radius);
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+
+    paper-button:hover {
+      /* same as defined in gr-button */
+      background: rgba(0, 0, 0, 0.12);
+    }
+    paper-button:focus-visible {
+      /* paper-button sets this to 0, thus preventing focus-based styling. */
+      outline-width: 1px;
+    }
+
+    .aboveBelowButtons {
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      margin-left: var(--spacing-m);
+      position: relative;
+    }
+    .aboveBelowButtons:first-child {
+      margin-left: 0;
+      /* Places a default background layer behind the "all button" that can have opacity */
+      background-color: var(--default-button-background-color);
+    }
+
+    .horizontalFlex {
+      display: flex;
+      justify-content: center;
+      align-items: var(--gr-context-controls-horizontal-align-items, center);
+    }
+
+    .aboveButton {
+      border-bottom-width: 0;
+      border-bottom-right-radius: 0;
+      border-bottom-left-radius: 0;
+      padding: var(--spacing-xxs) var(--spacing-l);
+    }
+    .belowButton {
+      border-top-width: 0;
+      border-top-left-radius: 0;
+      border-top-right-radius: 0;
+      padding: var(--spacing-xxs) var(--spacing-l);
+      margin-top: calc(var(--divider-height) + 2 * var(--spacing-xxs));
+    }
+    .belowButton:first-child {
+      margin-top: 0;
+    }
+    .breadcrumbTooltip {
+      white-space: nowrap;
+    }
+  `;
+
+  constructor() {
+    super();
+    this.setupButtonHoverHandler();
+  }
+
+  private showBoth() {
+    return this.showConfig === 'both';
+  }
+
+  private showAbove() {
+    return this.showBoth() || this.showConfig === 'above';
+  }
+
+  private showBelow() {
+    return this.showBoth() || this.showConfig === 'below';
+  }
+
+  private setupButtonHoverHandler() {
+    subscribe(
+      this,
+      () =>
+        this.expandButtonsHover.pipe(
+          switchMap(e => {
+            if (e.eventType === 'leave') {
+              // cancel any previous delay
+              // for mouse enter
+              return EMPTY;
+            }
+            return of(e).pipe(delay(500));
+          })
+        ),
+      ({buttonType, linesToExpand}) => {
+        fire(this, 'diff-context-button-hovered', {
+          buttonType,
+          linesToExpand,
+        });
+      }
+    );
+  }
+
+  private numLines() {
+    assertIsDefined(this.group);
+    // In context groups, there is the same number of lines left and right
+    const left = this.group.lineRange.left;
+    // Both start and end inclusive, so we need to add 1.
+    return left.end_line - left.start_line + 1;
+  }
+
+  private createExpandAllButtonContainer() {
+    return html` <div class="gr-diff aboveBelowButtons fullExpansion">
+      ${this.createContextButton(ContextButtonType.ALL, this.numLines())}
+    </div>`;
+  }
+
+  /**
+   * Creates a specific expansion button (e.g. +X common lines, +10, +Block).
+   */
+  private createContextButton(
+    type: ContextButtonType,
+    linesToExpand: number,
+    tooltip?: TemplateResult
+  ) {
+    if (!this.group) return;
+    let text = '';
+    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+    let ariaLabel = '';
+    let classes = 'contextControlButton showContext ';
+
+    if (type === ContextButtonType.ALL) {
+      text = `+${pluralize(linesToExpand, 'common line')}`;
+      ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
+      classes += this.showBoth()
+        ? 'centeredButton'
+        : this.showAbove()
+        ? 'aboveButton'
+        : 'belowButton';
+      if (this.group?.hasSkipGroup()) {
+        // Expanding content would require load of more data
+        text += ' (too large)';
+      }
+      groups.push(...this.group.contextGroups);
+    } else if (type === ContextButtonType.ABOVE) {
+      groups = hideInContextControl(
+        this.group.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = `+${linesToExpand}`;
+      classes += 'aboveButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} above`;
+    } else if (type === ContextButtonType.BELOW) {
+      groups = hideInContextControl(
+        this.group.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = `+${linesToExpand}`;
+      classes += 'belowButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} below`;
+    } else if (type === ContextButtonType.BLOCK_ABOVE) {
+      groups = hideInContextControl(
+        this.group.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = '+Block';
+      classes += 'aboveButton';
+      ariaLabel = 'Show block above';
+    } else if (type === ContextButtonType.BLOCK_BELOW) {
+      groups = hideInContextControl(
+        this.group.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = '+Block';
+      classes += 'belowButton';
+      ariaLabel = 'Show block below';
+    }
+    const expandHandler = this.createExpansionHandler(
+      linesToExpand,
+      type,
+      groups
+    );
+
+    const mouseHandler = (eventType: 'enter' | 'leave') => {
+      this.expandButtonsHover.next({
+        eventType,
+        buttonType: type,
+        linesToExpand,
+      });
+    };
+
+    const button = html` <paper-button
+      class=${classes}
+      aria-label=${ariaLabel}
+      @click=${expandHandler}
+      @mouseenter=${() => mouseHandler('enter')}
+      @mouseleave=${() => mouseHandler('leave')}
+    >
+      <span class="showContext">${text}</span>
+      ${tooltip}
+    </paper-button>`;
+    return button;
+  }
+
+  private createExpansionHandler(
+    linesToExpand: number,
+    type: ContextButtonType,
+    groups: GrDiffGroup[]
+  ) {
+    return (e: Event) => {
+      assertIsDefined(this.group);
+      e.stopPropagation();
+      if (type === ContextButtonType.ALL && this.group?.hasSkipGroup()) {
+        fire(this, 'content-load-needed', {
+          lineRange: this.group.lineRange,
+        });
+      } else {
+        fire(this, 'diff-context-expanded', {
+          numLines: this.numLines(),
+          buttonType: type,
+          expandedLines: linesToExpand,
+        });
+        fire(this, 'diff-context-expanded-internal', {
+          contextGroup: this.group,
+          groups,
+          numLines: this.numLines(),
+          buttonType: type,
+          expandedLines: linesToExpand,
+        });
+      }
+    };
+  }
+
+  private showPartialLinks() {
+    return this.numLines() > PARTIAL_CONTEXT_AMOUNT;
+  }
+
+  /**
+   * Creates a container div with partial (+10) expansion buttons (above and/or below).
+   */
+  private createPartialExpansionButtons() {
+    if (!this.showPartialLinks()) {
+      return undefined;
+    }
+    let aboveButton;
+    let belowButton;
+    if (this.showAbove()) {
+      aboveButton = this.createContextButton(
+        ContextButtonType.ABOVE,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    if (this.showBelow()) {
+      belowButton = this.createContextButton(
+        ContextButtonType.BELOW,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    return aboveButton || belowButton
+      ? html` <div class="aboveBelowButtons partialExpansion">
+          ${aboveButton} ${belowButton}
+        </div>`
+      : undefined;
+  }
+
+  /**
+   * Creates a container div with block expansion buttons (above and/or below).
+   */
+  private createBlockExpansionButtons() {
+    assertIsDefined(this.group, 'group');
+    if (
+      !this.showPartialLinks() ||
+      !this.renderPreferences?.use_block_expansion ||
+      this.group?.hasSkipGroup()
+    ) {
+      return undefined;
+    }
+    let aboveBlockButton;
+    let belowBlockButton;
+    if (this.showAbove()) {
+      aboveBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_ABOVE,
+        this.numLines(),
+        this.group.lineRange.right.start_line - 1
+      );
+    }
+    if (this.showBelow()) {
+      belowBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_BELOW,
+        this.numLines(),
+        this.group.lineRange.right.end_line + 1
+      );
+    }
+    if (aboveBlockButton || belowBlockButton) {
+      return html` <div class="aboveBelowButtons blockExpansion">
+        ${aboveBlockButton} ${belowBlockButton}
+      </div>`;
+    }
+    return undefined;
+  }
+
+  private createBlockButtonTooltip(
+    buttonType: ContextButtonType,
+    syntaxPath: SyntaxBlock[],
+    linesToExpand: number
+  ) {
+    // Create breadcrumb string:
+    // myNamespace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
+    const tooltipText = syntaxPath.length
+      ? syntaxPath.map(b => b.name || '(anonymous)').join(' > ')
+      : `${linesToExpand} common lines`;
+
+    const position =
+      buttonType === ContextButtonType.BLOCK_ABOVE ? 'top' : 'bottom';
+    return html`<paper-tooltip offset="10" position=${position}
+      ><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
+    >`;
+  }
+
+  private createBlockButton(
+    buttonType: ContextButtonType,
+    numLines: number,
+    referenceLine: number
+  ) {
+    if (!this.diff?.meta_b) return;
+    const syntaxTree = this.diff.meta_b.syntax_tree;
+    const outlineSyntaxPath = findBlockTreePathForLine(
+      referenceLine,
+      syntaxTree
+    );
+    let linesToExpand = numLines;
+    if (outlineSyntaxPath.length) {
+      const {range} = outlineSyntaxPath[outlineSyntaxPath.length - 1];
+      const targetLine =
+        buttonType === ContextButtonType.BLOCK_ABOVE
+          ? range.end_line
+          : range.start_line;
+      const distanceToTargetLine = Math.abs(targetLine - referenceLine);
+      if (distanceToTargetLine < numLines) {
+        linesToExpand = distanceToTargetLine;
+      }
+    }
+    const tooltip = this.createBlockButtonTooltip(
+      buttonType,
+      outlineSyntaxPath,
+      linesToExpand
+    );
+    return this.createContextButton(buttonType, linesToExpand, tooltip);
+  }
+
+  private hasValidProperties() {
+    return !!(this.diff && this.group?.contextGroups?.length);
+  }
+
+  override render() {
+    if (!this.hasValidProperties()) {
+      console.error('Invalid properties for gr-context-controls!');
+      return html`<p>invalid properties</p>`;
+    }
+    return html`
+      <div class="horizontalFlex">
+        ${this.createExpandAllButtonContainer()}
+        ${this.createPartialExpansionButtons()}
+        ${this.createBlockExpansionButtons()}
+      </div>
+    `;
+  }
+}
+if (!isNewDiff()) {
+  customElements.define('gr-context-controls', GrContextControls);
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-context-controls': LitElement;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls_test.ts
new file mode 100644
index 0000000..196afe5
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-context-controls/gr-context-controls_test.ts
@@ -0,0 +1,374 @@
+/**
+ * @license
+ * Copyright 2021 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../gr-diff/gr-diff-group';
+import './gr-context-controls';
+import {GrContextControls} from './gr-context-controls';
+
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {
+  DiffFileMetaInfo,
+  DiffInfo,
+  GrDiffLineType,
+  SyntaxBlock,
+} from '../../../api/diff';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitEventLoop} from '../../../test/test-utils';
+
+suite('gr-context-control tests', () => {
+  let element: GrContextControls;
+
+  setup(async () => {
+    element = document.createElement(
+      'gr-context-controls'
+    ) as GrContextControls;
+    element.diff = {content: []} as any as DiffInfo;
+    element.renderPreferences = {};
+    const div = await fixture(html`<div></div>`);
+    div.appendChild(element);
+    await waitEventLoop();
+  });
+
+  function createContextGroup(options: {offset?: number; count?: number}) {
+    const offset = options.offset || 0;
+    const numLines = options.count || 10;
+    const lines = [];
+    for (let i = 0; i < numLines; i++) {
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = offset + i + 1;
+      line.afterNumber = offset + i + 1;
+      line.text = 'lorem upsum';
+      lines.push(line);
+    }
+    return new GrDiffGroup({
+      type: GrDiffGroupType.CONTEXT_CONTROL,
+      contextGroups: [new GrDiffGroup({type: GrDiffGroupType.BOTH, lines})],
+    });
+  }
+
+  test('no +10 buttons for 10 or less lines', async () => {
+    element.group = createContextGroup({count: 10});
+
+    await waitEventLoop();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+    assert.equal(buttons.length, 1);
+    assert.equal(buttons[0].textContent!.trim(), '+10 common lines');
+  });
+
+  test('context control at the top', async () => {
+    element.group = createContextGroup({offset: 0, count: 20});
+    element.showConfig = 'below';
+
+    await waitEventLoop();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'belowButton');
+    assert.include([...buttons[1].classList.values()], 'belowButton');
+  });
+
+  test('context control in the middle', async () => {
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await waitEventLoop();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 3);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+    assert.equal(buttons[2].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'centeredButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+    assert.include([...buttons[2].classList.values()], 'belowButton');
+  });
+
+  test('context control at the bottom', async () => {
+    element.group = createContextGroup({offset: 30, count: 20});
+    element.showConfig = 'above';
+
+    await waitEventLoop();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'aboveButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+  });
+
+  function prepareForBlockExpansion(syntaxTree: SyntaxBlock[]) {
+    element.renderPreferences!.use_block_expansion = true;
+    element.diff!.meta_b = {
+      syntax_tree: syntaxTree,
+    } as any as DiffFileMetaInfo;
+  }
+
+  test('context control with block expansion at the top', async () => {
+    prepareForBlockExpansion([]);
+    element.group = createContextGroup({offset: 0, count: 20});
+    element.showConfig = 'below';
+
+    await waitEventLoop();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion in the middle', async () => {
+    prepareForBlockExpansion([]);
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await waitEventLoop();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 2);
+    assert.equal(blockExpansionButtons.length, 2);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.equal(
+      blockExpansionButtons[1].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+    assert.include(
+      [...blockExpansionButtons[1].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion at the bottom', async () => {
+    prepareForBlockExpansion([]);
+    element.group = createContextGroup({offset: 30, count: 20});
+    element.showConfig = 'above';
+
+    await waitEventLoop();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+  });
+
+  test('+ Block tooltip tooltip shows syntax block containing the target lines above and below', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificFunction',
+        range: {start_line: 1, start_column: 0, end_line: 25, end_column: 0},
+        children: [],
+      },
+      {
+        name: 'anotherFunction',
+        range: {start_line: 26, start_column: 0, end_line: 50, end_column: 0},
+        children: [],
+      },
+    ]);
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await waitEventLoop();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificFunction'
+    );
+    assert.equal(
+      blockExpansionButtons[1]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'anotherFunction'
+    );
+  });
+
+  test('+Block tooltip shows nested syntax blocks as breadcrumbs', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: 'MyClass',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await waitEventLoop();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > MyClass > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows (anonymous) for empty blocks', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: '',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+    await waitEventLoop();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > (anonymous) > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows "all common lines" for empty syntax tree', async () => {
+    prepareForBlockExpansion([]);
+
+    element.group = createContextGroup({offset: 10, count: 20});
+    element.showConfig = 'both';
+    await waitEventLoop();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    const tooltipAbove =
+      blockExpansionButtons[0].querySelector('paper-tooltip')!;
+    const tooltipBelow =
+      blockExpansionButtons[1].querySelector('paper-tooltip')!;
+    assert.equal(
+      tooltipAbove.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+      '20 common lines'
+    );
+    assert.equal(
+      tooltipBelow.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+      '20 common lines'
+    );
+    assert.equal(tooltipAbove.getAttribute('position'), 'top');
+    assert.equal(tooltipBelow.getAttribute('position'), 'bottom');
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-binary.ts
new file mode 100644
index 0000000..9467654
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-binary.ts
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrDiffBuilder} from './gr-diff-builder';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {createElementDiff} from '../../diff/gr-diff/gr-diff-utils';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {html, render} from 'lit';
+import {FILE} from '../../../api/diff';
+
+export class GrDiffBuilderBinary extends GrDiffBuilder {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement
+  ) {
+    super(diff, prefs, outputEl);
+  }
+
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
+    const section = createElementDiff('tbody', 'binary-diff');
+    // Do not create a diff row for LOST.
+    if (group.lines[0].beforeNumber !== FILE) return section;
+    return super.buildSectionElement(group);
+  }
+
+  public renderBinaryDiff() {
+    render(
+      html`
+        <tbody class="gr-diff binary-diff">
+          <tr class="gr-diff">
+            <td colspan="5" class="gr-diff">
+              <span>Difference in binary files</span>
+            </td>
+          </tr>
+        </tbody>
+      `,
+      this.outputEl
+    );
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-element.ts
similarity index 97%
rename from polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
rename to polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-element.ts
index 328b577..e6de72d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-element.ts
@@ -25,17 +25,17 @@
 import {
   CommentRangeLayer,
   GrRangedCommentLayer,
-} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
-import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
-import {DiffViewMode, RenderPreferences} from '../../../api/diff';
+} from '../../diff/gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {GrCoverageLayer} from '../../diff/gr-coverage-layer/gr-coverage-layer';
+import {DiffViewMode, LineNumber, RenderPreferences} from '../../../api/diff';
 import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
-import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {
   GrDiffGroup,
   GrDiffGroupType,
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
-import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
+import {getLineNumber, getSideByLineEl} from '../../diff/gr-diff/gr-diff-utils';
 import {fireAlert, fire} from '../../../utils/event-util';
 import {assertIsDefined} from '../../../utils/common-util';
 
@@ -350,7 +350,7 @@
   init() {
     this.cleanup();
     this.diffElement?.addEventListener(
-      'diff-context-expanded',
+      'diff-context-expanded-internal',
       this.onDiffContextExpanded
     );
     this.builder?.init();
@@ -367,7 +367,7 @@
     this.processor?.cancel();
     this.builder?.cleanup();
     this.diffElement?.removeEventListener(
-      'diff-context-expanded',
+      'diff-context-expanded-internal',
       this.onDiffContextExpanded
     );
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-element_test.ts
similarity index 99%
rename from polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
rename to polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-element_test.ts
index da2e9f1..f6f0cb3 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -11,12 +11,13 @@
 import './gr-diff-builder-element';
 import {stubBaseUrl, waitUntil} from '../../../test/test-utils';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {
   DiffContent,
   DiffLayer,
   DiffPreferencesInfo,
   DiffViewMode,
+  GrDiffLineType,
   Side,
 } from '../../../api/diff';
 import {stubRestApi} from '../../../test/test-utils';
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-image.ts
new file mode 100644
index 0000000..d71d52a
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder-image.ts
@@ -0,0 +1,279 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {FILE, RenderPreferences, Side} from '../../../api/diff';
+import '../../diff/gr-diff-image-viewer/gr-image-viewer';
+import {html, LitElement, nothing} from 'lit';
+import {property, query, state} from 'lit/decorators.js';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {createElementDiff, isNewDiff} from '../../diff/gr-diff/gr-diff-utils';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+
+// MIME types for images we allow showing. Do not include SVG, it can contain
+// arbitrary JavaScript.
+const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
+
+export class GrDiffBuilderImage extends GrDiffBuilder {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    private readonly baseImage: ImageInfo | null,
+    private readonly revisionImage: ImageInfo | null,
+    renderPrefs?: RenderPreferences,
+    private readonly useNewImageDiffUi: boolean = false
+  ) {
+    super(diff, prefs, outputEl, [], renderPrefs);
+  }
+
+  override buildSectionElement(group: GrDiffGroup): HTMLElement {
+    const section = createElementDiff('tbody');
+    // Do not create a diff row for LOST.
+    if (group.lines[0].beforeNumber !== FILE) return section;
+    return super.buildSectionElement(group);
+  }
+
+  public renderImageDiff() {
+    const imageDiff = this.useNewImageDiffUi
+      ? this.createImageDiffNew()
+      : this.createImageDiffOld();
+    this.outputEl.appendChild(imageDiff);
+  }
+
+  private createImageDiffNew() {
+    const imageDiff = document.createElement(
+      'gr-diff-image-new'
+    ) as GrDiffImageNew;
+    imageDiff.automaticBlink = this.autoBlink();
+    imageDiff.baseImage = this.baseImage ?? undefined;
+    imageDiff.revisionImage = this.revisionImage ?? undefined;
+    return imageDiff;
+  }
+
+  private createImageDiffOld() {
+    const imageDiff = document.createElement(
+      'gr-diff-image-old'
+    ) as GrDiffImageOld;
+    imageDiff.baseImage = this.baseImage ?? undefined;
+    imageDiff.revisionImage = this.revisionImage ?? undefined;
+    return imageDiff;
+  }
+
+  private autoBlink(): boolean {
+    return !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
+  }
+
+  override updateRenderPrefs(renderPrefs: RenderPreferences) {
+    this.renderPrefs = renderPrefs;
+
+    // We have to update `imageDiff.automaticBlink` manually, because `this` is
+    // not a LitElement.
+    const imageDiff = this.outputEl.querySelector(
+      'gr-diff-image-new'
+    ) as GrDiffImageNew;
+    if (imageDiff) imageDiff.automaticBlink = this.autoBlink();
+  }
+}
+
+class GrDiffImageNew extends LitElement {
+  @property() baseImage?: ImageInfo;
+
+  @property() revisionImage?: ImageInfo;
+
+  @property() automaticBlink = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override render() {
+    return html`
+      <tbody class="gr-diff image-diff">
+        <tr class="gr-diff">
+          <td class="gr-diff" colspan="4">
+            <gr-image-viewer
+              class="gr-diff"
+              .baseUrl=${imageSrc(this.baseImage)}
+              .revisionUrl=${imageSrc(this.revisionImage)}
+              .automaticBlink=${this.automaticBlink}
+            >
+            </gr-image-viewer>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+}
+
+class GrDiffImageOld extends LitElement {
+  @property() baseImage?: ImageInfo;
+
+  @property() revisionImage?: ImageInfo;
+
+  @query('img.left') baseImageEl?: HTMLImageElement;
+
+  @query('img.right') revisionImageEl?: HTMLImageElement;
+
+  @state() baseError?: string;
+
+  @state() revisionError?: string;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override render() {
+    return html`
+      <tbody class="gr-diff image-diff">
+        ${this.renderImagePairRow()} ${this.renderImageLabelRow()}
+      </tbody>
+      ${this.renderEndpoint()}
+    `;
+  }
+
+  private renderEndpoint() {
+    return html`
+      <tbody class="gr-diff endpoint">
+        <tr class="gr-diff">
+          <td class="gr-diff" colspan="4">
+            <gr-endpoint-decorator class="gr-diff" name="image-diff">
+              ${this.renderEndpointParam('baseImage', this.baseImage)}
+              ${this.renderEndpointParam('revisionImage', this.revisionImage)}
+            </gr-endpoint-decorator>
+          </td>
+        </tr>
+      </tbody>
+    `;
+  }
+
+  private renderEndpointParam(name: string, value: unknown) {
+    if (!value) return nothing;
+    return html`
+      <gr-endpoint-param class="gr-diff" name=${name} .value=${value}>
+      </gr-endpoint-param>
+    `;
+  }
+
+  private renderImagePairRow() {
+    return html`
+      <tr class="gr-diff">
+        <td class="gr-diff left lineNum blank"></td>
+        <td class="gr-diff left">${this.renderImage(Side.LEFT)}</td>
+        <td class="gr-diff right lineNum blank"></td>
+        <td class="gr-diff right">${this.renderImage(Side.RIGHT)}</td>
+      </tr>
+    `;
+  }
+
+  private renderImage(side: Side) {
+    const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+    if (!image) return nothing;
+    const error = side === Side.LEFT ? this.baseError : this.revisionError;
+    if (error) return error;
+    const src = imageSrc(image);
+    if (!src) return nothing;
+
+    return html`
+      <img
+        class="gr-diff ${side}"
+        src=${src}
+        @load=${this.handleLoad}
+        @error=${(e: Event) => this.handleError(e, side)}
+      >
+      </img>
+    `;
+  }
+
+  private handleLoad() {
+    this.requestUpdate();
+  }
+
+  private handleError(e: Event, side: Side) {
+    const msg = `[Image failed to load] ${e.type}`;
+    if (side === Side.LEFT) this.baseError = msg;
+    if (side === Side.RIGHT) this.revisionError = msg;
+  }
+
+  private renderImageLabelRow() {
+    return html`
+      <tr class="gr-diff">
+        <td class="gr-diff left lineNum blank"></td>
+        <td class="gr-diff left">
+          <label class="gr-diff">
+            ${this.renderName(this.baseImage?._name ?? '')}
+            <span class="gr-diff label">${this.imageLabel(Side.LEFT)}</span>
+          </label>
+        </td>
+        <td class="gr-diff right lineNum blank"></td>
+        <td class="gr-diff right">
+          <label class="gr-diff">
+            ${this.renderName(this.revisionImage?._name ?? '')}
+            <span class="gr-diff label"> ${this.imageLabel(Side.RIGHT)} </span>
+          </label>
+        </td>
+      </tr>
+    `;
+  }
+
+  private renderName(name?: string) {
+    const addNamesInLabel =
+      this.baseImage &&
+      this.revisionImage &&
+      this.baseImage._name !== this.revisionImage._name;
+    if (!addNamesInLabel) return nothing;
+    return html`
+      <span class="gr-diff name">${name}</span><br class="gr-diff" />
+    `;
+  }
+
+  private imageLabel(side: Side) {
+    const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+    const imageEl =
+      side === Side.LEFT ? this.baseImageEl : this.revisionImageEl;
+    if (image) {
+      const type = image.type ?? image._expectedType;
+      if (imageEl?.naturalWidth && imageEl.naturalHeight) {
+        return `${imageEl?.naturalWidth}×${imageEl.naturalHeight} ${type}`;
+      } else {
+        return type;
+      }
+    }
+    return 'No image';
+  }
+}
+
+function imageSrc(image?: ImageInfo): string {
+  return image && IMAGE_MIME_PATTERN.test(image.type)
+    ? `data:${image.type};base64,${image.body}`
+    : '';
+}
+
+if (!isNewDiff()) {
+  customElements.define('gr-diff-image-new', GrDiffImageNew);
+  customElements.define('gr-diff-image-old', GrDiffImageOld);
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-image-new': LitElement;
+    'gr-diff-image-old': LitElement;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder.ts
new file mode 100644
index 0000000..951466f
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-builder.ts
@@ -0,0 +1,352 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import './gr-diff-section';
+import '../gr-context-controls/gr-context-controls';
+import {
+  ContentLoadNeededEventDetail,
+  DiffContextExpandedExternalDetail,
+  DiffViewMode,
+  LineNumber,
+  RenderPreferences,
+} from '../../../api/diff';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {BlameInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {DiffLayer, isDefined} from '../../../types/types';
+import {GrDiffRow} from './gr-diff-row';
+import {GrDiffSection} from './gr-diff-section';
+import {html, render} from 'lit';
+import {diffClasses} from '../../diff/gr-diff/gr-diff-utils';
+import {when} from 'lit/directives/when.js';
+import {GrDiffBuilderImage} from './gr-diff-builder-image';
+import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
+
+export interface DiffContextExpandedEventDetail
+  extends DiffContextExpandedExternalDetail {
+  /** The context control group that should be replaced by `groups`. */
+  contextGroup: GrDiffGroup;
+  groups: GrDiffGroup[];
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    'diff-context-expanded-internal': CustomEvent<DiffContextExpandedEventDetail>;
+    'diff-context-expanded': CustomEvent<DiffContextExpandedExternalDetail>;
+    'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
+  }
+}
+
+export function isImageDiffBuilder<T extends GrDiffBuilder>(
+  x: T | GrDiffBuilderImage | undefined
+): x is GrDiffBuilderImage {
+  return !!x && !!(x as GrDiffBuilderImage).renderImageDiff;
+}
+
+export function isBinaryDiffBuilder<T extends GrDiffBuilder>(
+  x: T | GrDiffBuilderBinary | undefined
+): x is GrDiffBuilderBinary {
+  return !!x && !!(x as GrDiffBuilderBinary).renderBinaryDiff;
+}
+
+/**
+ * The builder takes GrDiffGroups, and builds the corresponding DOM elements,
+ * called sections. Only the builder should add or remove sections from the
+ * DOM. Callers can use the ...group() methods to modify groups and thus cause
+ * rendering changes.
+ */
+export class GrDiffBuilder {
+  private readonly diff: DiffInfo;
+
+  readonly prefs: DiffPreferencesInfo;
+
+  renderPrefs?: RenderPreferences;
+
+  readonly outputEl: HTMLElement;
+
+  private groups: GrDiffGroup[];
+
+  private readonly layerUpdateListener: (
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ) => void;
+
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    readonly layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
+  ) {
+    this.diff = diff;
+    this.prefs = prefs;
+    this.renderPrefs = renderPrefs;
+    this.outputEl = outputEl;
+    this.groups = [];
+
+    if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+      throw Error('Invalid tab size from preferences.');
+    }
+
+    if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+      throw Error('Invalid line length from preferences.');
+    }
+
+    this.layerUpdateListener = (
+      start: LineNumber,
+      end: LineNumber,
+      side: Side
+    ) => this.renderContentByRange(start, end, side);
+    this.init();
+  }
+
+  getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | undefined {
+    if (!side) return undefined;
+    const row = this.findRow(lineNumber, side);
+    return row?.getContentCell(side);
+  }
+
+  getLineElByNumber(
+    lineNumber: LineNumber,
+    side?: Side
+  ): HTMLTableCellElement | undefined {
+    if (!side) return undefined;
+    const row = this.findRow(lineNumber, side);
+    return row?.getLineNumberCell(side);
+  }
+
+  private findRow(lineNumber?: LineNumber, side?: Side): GrDiffRow | undefined {
+    if (!side || !lineNumber) return undefined;
+    const group = this.findGroup(side, lineNumber);
+    if (!group) return undefined;
+    const section = this.findSection(group);
+    if (!section) return undefined;
+    return section.findRow(side, lineNumber);
+  }
+
+  private getDiffRows() {
+    const sections = [
+      ...this.outputEl.querySelectorAll<GrDiffSection>('gr-diff-section'),
+    ];
+    return sections.map(s => s.getDiffRows()).flat();
+  }
+
+  getLineNumberRows(): HTMLTableRowElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getTableRow()).filter(isDefined);
+  }
+
+  getLineNumEls(side: Side): HTMLTableCellElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
+  }
+
+  /** This is used when layers initiate an update. */
+  renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
+    const groups = this.getGroupsByLineRange(start, end, side);
+    for (const group of groups) {
+      const section = this.findSection(group);
+      for (const row of section?.getDiffRows() ?? []) {
+        row.requestUpdate();
+      }
+    }
+  }
+
+  private findSection(group: GrDiffGroup): GrDiffSection | undefined {
+    const leftClass = `left-${group.startLine(Side.LEFT)}`;
+    const rightClass = `right-${group.startLine(Side.RIGHT)}`;
+    return (
+      this.outputEl.querySelector<GrDiffSection>(
+        `gr-diff-section.${leftClass}.${rightClass}`
+      ) ?? undefined
+    );
+  }
+
+  buildSectionElement(group: GrDiffGroup): HTMLElement {
+    const leftCl = `left-${group.startLine(Side.LEFT)}`;
+    const rightCl = `right-${group.startLine(Side.RIGHT)}`;
+    const section = html`
+      <gr-diff-section
+        class="${leftCl} ${rightCl}"
+        .group=${group}
+        .diff=${this.diff}
+        .layers=${this.layers}
+        .diffPrefs=${this.prefs}
+        .renderPrefs=${this.renderPrefs}
+      ></gr-diff-section>
+    `;
+    // When using Lit's `render()` method it wants to be in full control of the
+    // element that it renders into, so we let it render into a temp element.
+    // Rendering into the diff table directly would interfere with
+    // `clearDiffContent()`for example.
+    // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
+    // method into Lit's `render()` cycle.
+    const tempEl = document.createElement('div');
+    render(section, tempEl);
+    const sectionEl = tempEl.firstElementChild as GrDiffSection;
+    return sectionEl;
+  }
+
+  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+    const colgroup = html`
+      <colgroup>
+        <col class=${diffClasses('blame')}></col>
+        ${when(
+          this.renderPrefs?.view_mode === DiffViewMode.UNIFIED,
+          () => html` ${this.renderUnifiedColumns(lineNumberWidth)} `,
+          () => html`
+            ${this.renderSideBySideColumns(Side.LEFT, lineNumberWidth)}
+            ${this.renderSideBySideColumns(Side.RIGHT, lineNumberWidth)}
+          `
+        )}
+      </colgroup>
+    `;
+    // When using Lit's `render()` method it wants to be in full control of the
+    // element that it renders into, so we let it render into a temp element.
+    // Rendering into the diff table directly would interfere with
+    // `clearDiffContent()`for example.
+    // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
+    // method into Lit's `render()` cycle.
+    const tempEl = document.createElement('div');
+    render(colgroup, tempEl);
+    const colgroupEl = tempEl.firstElementChild as HTMLElement;
+    outputEl.appendChild(colgroupEl);
+  }
+
+  private renderUnifiedColumns(lineNumberWidth: number) {
+    return html`
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()} width=${lineNumberWidth}></col>
+      <col class=${diffClasses()}></col>
+    `;
+  }
+
+  private renderSideBySideColumns(side: Side, lineNumberWidth: number) {
+    return html`
+      <col class=${diffClasses(side)} width=${lineNumberWidth}></col>
+      <col class=${diffClasses(side, 'sign')}></col>
+      <col class=${diffClasses(side)}></col>
+    `;
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component re-connects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with cleanup(), which is called
+   * when gr-diff disconnects.
+   */
+  init() {
+    this.cleanup();
+    for (const layer of this.layers) {
+      if (layer.addListener) {
+        layer.addListener(this.layerUpdateListener);
+      }
+    }
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component disconnects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with init(), which is called when
+   * gr-diff re-connects.
+   */
+  cleanup() {
+    for (const layer of this.layers) {
+      if (layer.removeListener) {
+        layer.removeListener(this.layerUpdateListener);
+      }
+    }
+  }
+
+  addGroups(groups: readonly GrDiffGroup[]) {
+    for (const group of groups) {
+      this.groups.push(group);
+      this.emitGroup(group);
+    }
+  }
+
+  clearGroups() {
+    for (const deletedGroup of this.groups) {
+      deletedGroup.element?.remove();
+    }
+    this.groups = [];
+  }
+
+  replaceGroup(contextControl: GrDiffGroup, groups: readonly GrDiffGroup[]) {
+    const i = this.groups.indexOf(contextControl);
+    if (i === -1) throw new Error('cannot find context control group');
+
+    const contextControlSection = this.groups[i].element;
+    if (!contextControlSection) throw new Error('diff group element not set');
+
+    this.groups.splice(i, 1, ...groups);
+    for (const group of groups) {
+      this.emitGroup(group, contextControlSection);
+    }
+    if (contextControlSection) contextControlSection.remove();
+  }
+
+  findGroup(side: Side, line: LineNumber) {
+    return this.groups.find(group => group.containsLine(side, line));
+  }
+
+  private emitGroup(group: GrDiffGroup, beforeSection?: HTMLElement) {
+    const element = this.buildSectionElement(group);
+    this.outputEl.insertBefore(element, beforeSection ?? null);
+    group.element = element;
+  }
+
+  // visible for testing
+  getGroupsByLineRange(
+    startLine: LineNumber,
+    endLine: LineNumber,
+    side: Side
+  ): GrDiffGroup[] {
+    const startIndex = this.groups.findIndex(group =>
+      group.containsLine(side, startLine)
+    );
+    if (startIndex === -1) return [];
+    let endIndex = this.groups.findIndex(group =>
+      group.containsLine(side, endLine)
+    );
+    // Not all groups may have been processed yet (i.e. this.groups is still
+    // incomplete). In that case let's just return *all* groups until the end
+    // of the array.
+    if (endIndex === -1) endIndex = this.groups.length - 1;
+    // The filter preserves the legacy behavior to only return non-context
+    // groups
+    return this.groups
+      .slice(startIndex, endIndex + 1)
+      .filter(group => group.lines.length > 0);
+  }
+
+  /**
+   * Set the blame information for the diff. For any already-rendered line,
+   * re-render its blame cell content.
+   */
+  setBlame(blame: BlameInfo[]) {
+    for (const blameInfo of blame) {
+      for (const range of blameInfo.ranges) {
+        for (let line = range.start; line <= range.end; line++) {
+          const row = this.findRow(line, Side.LEFT);
+          if (row) row.blameInfo = blameInfo;
+        }
+      }
+    }
+  }
+
+  /**
+   * Only special builders need to implement this. The default is to
+   * just ignore it.
+   */
+  updateRenderPrefs(_: RenderPreferences) {}
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-row.ts
new file mode 100644
index 0000000..f2742c7
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-row.ts
@@ -0,0 +1,484 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement, nothing, TemplateResult} from 'lit';
+import {property, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {createRef, Ref, ref} from 'lit/directives/ref.js';
+import {
+  DiffResponsiveMode,
+  Side,
+  LineNumber,
+  DiffLayer,
+  GrDiffLineType,
+  LOST,
+  FILE,
+} from '../../../api/diff';
+import {BlameInfo} from '../../../types/common';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
+import {getBaseUrl} from '../../../utils/url-util';
+import './gr-diff-text';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {
+  diffClasses,
+  isNewDiff,
+  isResponsive,
+} from '../../diff/gr-diff/gr-diff-utils';
+
+export class GrDiffRow extends LitElement {
+  contentLeftRef: Ref<LitElement> = createRef();
+
+  contentRightRef: Ref<LitElement> = createRef();
+
+  contentCellLeftRef: Ref<HTMLTableCellElement> = createRef();
+
+  contentCellRightRef: Ref<HTMLTableCellElement> = createRef();
+
+  lineNumberLeftRef: Ref<HTMLTableCellElement> = createRef();
+
+  lineNumberRightRef: Ref<HTMLTableCellElement> = createRef();
+
+  blameCellRef: Ref<HTMLTableCellElement> = createRef();
+
+  tableRowRef: Ref<HTMLTableRowElement> = createRef();
+
+  @property({type: Object})
+  left?: GrDiffLine;
+
+  @property({type: Object})
+  right?: GrDiffLine;
+
+  @property({type: Object})
+  blameInfo?: BlameInfo;
+
+  @property({type: Object})
+  responsiveMode?: DiffResponsiveMode;
+
+  /**
+   * true: side-by-side diff
+   * false: unified diff
+   */
+  @property({type: Boolean})
+  unifiedDiff = false;
+
+  @property({type: Number})
+  tabSize = 2;
+
+  @property({type: Number})
+  lineLength = 80;
+
+  @property({type: Boolean})
+  hideFileCommentButton = false;
+
+  @property({type: Object})
+  layers: DiffLayer[] = [];
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * Keeps track of whether diff layers have already been applied to the diff
+   * row. That happens after the DOM has been created in the `updated()`
+   * lifecycle callback.
+   *
+   * Once layers are applied, the diff row requires two rendering passes for an
+   * update: 1. Remove all <gr-diff-text> elements and their layer manipulated
+   * DOMs. 2. Add fresh <gr-diff-text> elements and let layers re-apply in
+   * `updated()`.
+   */
+  private layersApplied = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override updated() {
+    if (this.layersApplied) {
+      // <gr-diff-text> elements have been removed during rendering. Let's start
+      // another rendering cycle with freshly created <gr-diff-text> elements.
+      this.updateComplete.then(() => {
+        this.layersApplied = false;
+        this.requestUpdate();
+      });
+    } else {
+      this.updateLayers(Side.LEFT);
+      this.updateLayers(Side.RIGHT);
+    }
+  }
+
+  /**
+   * The diff layers API is designed to let layers manipulate the DOM. So we
+   * have to apply them after the rendering cycle is done (`updated()`). But
+   * when re-rendering a row that already has layers applied, then we have to
+   * first wipe away <gr-diff-text>. This is achieved by
+   * `this.layersApplied = true`.
+   */
+  private async updateLayers(side: Side) {
+    const line = this.line(side);
+    const contentEl = this.contentRef(side).value;
+    const lineNumberEl = this.lineNumberRef(side).value;
+    if (!line || !contentEl || !lineNumberEl) return;
+
+    // We have to wait for the <gr-diff-text> child component to finish
+    // rendering before we can apply layers, which will re-write the HTML.
+    await contentEl?.updateComplete;
+    for (const layer of this.layers) {
+      if (typeof layer.annotate === 'function') {
+        layer.annotate(contentEl, lineNumberEl, line, side);
+      }
+    }
+    // At this point we consider layers applied. So as soon as <gr-diff-row>
+    // enters a new rendering cycle <gr-diff-text> elements will be removed.
+    this.layersApplied = true;
+  }
+
+  override render() {
+    if (!this.left || !this.right) return;
+    const classes = this.unifiedDiff ? ['unified'] : ['side-by-side'];
+    const unifiedType = this.unifiedType();
+    if (this.unifiedDiff && unifiedType) classes.push(unifiedType);
+    const row = html`
+      <tr
+        ${ref(this.tableRowRef)}
+        class=${diffClasses('diff-row', ...classes)}
+        left-type=${ifDefined(this.getType(Side.LEFT))}
+        right-type=${ifDefined(this.getType(Side.RIGHT))}
+        tabindex="-1"
+        aria-labelledby=${this.ariaLabelIds()}
+      >
+        ${this.renderBlameCell()} ${this.renderLineNumberCell(Side.LEFT)}
+        ${this.renderSignCell(Side.LEFT)} ${this.renderContentCell(Side.LEFT)}
+        ${this.renderLineNumberCell(Side.RIGHT)}
+        ${this.renderSignCell(Side.RIGHT)} ${this.renderContentCell(Side.RIGHT)}
+      </tr>
+      ${this.renderPostLineSlot(Side.LEFT)}
+      ${this.renderPostLineSlot(Side.RIGHT)}
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${row}
+      </table>`;
+    }
+    return row;
+  }
+
+  private ariaLabelIds() {
+    const ids: string[] = [];
+    ids.push(this.lineNumberId(Side.LEFT));
+    if (!this.unifiedDiff) ids.push(this.contentId(Side.LEFT));
+    ids.push(this.lineNumberId(Side.RIGHT));
+    if (!this.unifiedDiff) ids.push(this.contentId(Side.RIGHT));
+    if (this.unifiedDiff) ids.push(this.contentId(this.unifiedSide()));
+    return ids.filter(id => !!id).join(' ');
+  }
+
+  private lineNumberId(side: Side): string {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return '';
+    return `${side}-button-${lineNumber}`;
+  }
+
+  private unifiedSide() {
+    const isLeft = this.line(Side.RIGHT)?.type === GrDiffLineType.BLANK;
+    return isLeft ? Side.LEFT : Side.RIGHT;
+  }
+
+  private contentId(side: Side): string {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return '';
+    return `${side}-content-${lineNumber}`;
+  }
+
+  getTableRow(): HTMLTableRowElement | undefined {
+    return this.tableRowRef.value;
+  }
+
+  getLineNumberCell(side: Side): HTMLTableCellElement | undefined {
+    return this.lineNumberRef(side).value;
+  }
+
+  getContentCell(side: Side) {
+    return this.contentCellRef(side)?.value;
+  }
+
+  getBlameCell() {
+    return this.blameCellRef.value;
+  }
+
+  private renderBlameCell() {
+    // td.blame has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <td
+        ${ref(this.blameCellRef)}
+        class=${diffClasses('blame')}
+        data-line-number=${this.left?.beforeNumber ?? 0}
+      >${this.renderBlameElement()}</td>
+    `;
+  }
+
+  private renderBlameElement() {
+    const lineNum = this.left?.beforeNumber;
+    const commit = this.blameInfo;
+    if (!lineNum || !commit) return;
+
+    const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+    const extras: string[] = [];
+    if (isStartOfRange) extras.push('startOfRange');
+    const date = new Date(commit.time * 1000).toLocaleDateString();
+    const shortName = commit.author.split(' ')[0];
+    const url = `${getBaseUrl()}/q/${commit.id}`;
+
+    // td.blame has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`<span class=${diffClasses(...extras)}
+        ><a href=${url} class=${diffClasses('blameDate')}>${date}</a
+        ><span class=${diffClasses('blameAuthor')}> ${shortName}</span
+        ><gr-hovercard class=${diffClasses()}>
+          <span class=${diffClasses('blameHoverCard')}>
+            Commit ${commit.id}<br />
+            Author: ${commit.author}<br />
+            Date: ${date}<br />
+            <br />
+            ${commit.commit_msg}
+          </span>
+        </gr-hovercard
+      ></span>`;
+  }
+
+  private renderLineNumberCell(side: Side): TemplateResult {
+    const line = this.line(side);
+    const lineNumber = this.lineNumber(side);
+    const isBlank = line?.type === GrDiffLineType.BLANK;
+    if (!line || !lineNumber || isBlank || this.layersApplied) {
+      const blankClass = isBlank && !this.unifiedDiff ? 'blankLineNum' : '';
+      return html`<td
+        ${ref(this.lineNumberRef(side))}
+        class=${diffClasses(side, blankClass)}
+      ></td>`;
+    }
+
+    return html`<td
+      ${ref(this.lineNumberRef(side))}
+      class=${diffClasses(side, 'lineNum')}
+      data-value=${lineNumber}
+    >
+      ${this.renderLineNumberButton(line, lineNumber, side)}
+    </td>`;
+  }
+
+  private renderLineNumberButton(
+    line: GrDiffLine,
+    lineNumber: LineNumber,
+    side: Side
+  ) {
+    if (this.hideFileCommentButton && lineNumber === FILE) return;
+    if (lineNumber === LOST) return;
+    // .lineNumButton has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <button
+        id=${this.lineNumberId(side)}
+        class=${diffClasses('lineNumButton', side)}
+        tabindex="-1"
+        data-value=${lineNumber}
+        aria-label=${ifDefined(
+          this.computeLineNumberAriaLabel(line, lineNumber)
+        )}
+        @mouseenter=${() =>
+          fire(this, 'line-mouse-enter', {lineNum: lineNumber, side})}
+        @mouseleave=${() =>
+          fire(this, 'line-mouse-leave', {lineNum: lineNumber, side})}
+      >${lineNumber === FILE ? 'File' : lineNumber.toString()}</button>
+    `;
+  }
+
+  private computeLineNumberAriaLabel(line: GrDiffLine, lineNumber: LineNumber) {
+    if (lineNumber === FILE) return 'Add file comment';
+
+    // Add aria-labels for valid line numbers.
+    // For unified diff, this method will be called with number set to 0 for
+    // the empty line number column for added/removed lines. This should not
+    // be announced to the screenreader.
+    if (lineNumber === LOST || lineNumber <= 0) return undefined;
+
+    switch (line.type) {
+      case GrDiffLineType.REMOVE:
+        return `${lineNumber} removed`;
+      case GrDiffLineType.ADD:
+        return `${lineNumber} added`;
+      case GrDiffLineType.BOTH:
+      case GrDiffLineType.BLANK:
+        return `${lineNumber} unmodified`;
+    }
+  }
+
+  private renderContentCell(side: Side) {
+    let line = this.line(side);
+    if (this.unifiedDiff) {
+      if (side === Side.LEFT) return nothing;
+      if (line?.type === GrDiffLineType.BLANK) {
+        side = Side.LEFT;
+        line = this.line(Side.LEFT);
+      }
+    }
+    const lineNumber = this.lineNumber(side);
+    assertIsDefined(line, 'line');
+    const extras: string[] = [line.type, side];
+    if (line.type !== GrDiffLineType.BLANK) extras.push('content');
+    if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+    if (line.beforeNumber === FILE) extras.push('file');
+    if (line.beforeNumber === LOST) extras.push('lost');
+
+    // .content has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <td
+        ${ref(this.contentCellRef(side))}
+        class=${diffClasses(...extras)}
+        @mouseenter=${() => {
+          if (lineNumber)
+            fire(this, 'line-mouse-enter', {lineNum: lineNumber, side});
+        }}
+        @mouseleave=${() => {
+          if (lineNumber)
+            fire(this, 'line-mouse-leave', {lineNum: lineNumber, side});
+        }}
+      >${this.renderText(side)}${this.renderThreadGroup(side)}</td>
+    `;
+  }
+
+  private renderSignCell(side: Side) {
+    if (this.unifiedDiff) return nothing;
+    const line = this.line(side);
+    assertIsDefined(line, 'line');
+    const isBlank = line.type === GrDiffLineType.BLANK;
+    const isAdd = line.type === GrDiffLineType.ADD && side === Side.RIGHT;
+    const isRemove = line.type === GrDiffLineType.REMOVE && side === Side.LEFT;
+    const extras: string[] = ['sign', side];
+    if (isBlank) extras.push('blank');
+    if (isAdd) extras.push('add');
+    if (isRemove) extras.push('remove');
+    if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+
+    const sign = isAdd ? '+' : isRemove ? '-' : '';
+    return html`<td class=${diffClasses(...extras)}>${sign}</td>`;
+  }
+
+  private renderThreadGroup(side: Side) {
+    const lineNumber = this.lineNumber(side);
+    if (!lineNumber) return nothing;
+    return html`<div class="thread-group" data-side=${side}>
+      <slot name="${side}-${lineNumber}"></slot>
+      ${this.renderSecondSlot()}
+    </div>`;
+  }
+
+  private renderSecondSlot() {
+    if (!this.unifiedDiff) return nothing;
+    if (this.line(Side.LEFT)?.type !== GrDiffLineType.BOTH) return nothing;
+    return html`<slot
+      name="${Side.LEFT}-${this.lineNumber(Side.LEFT)}"
+    ></slot>`;
+  }
+
+  private contentRef(side: Side) {
+    return side === Side.LEFT ? this.contentLeftRef : this.contentRightRef;
+  }
+
+  private contentCellRef(side: Side) {
+    return side === Side.LEFT
+      ? this.contentCellLeftRef
+      : this.contentCellRightRef;
+  }
+
+  private lineNumberRef(side: Side) {
+    return side === Side.LEFT
+      ? this.lineNumberLeftRef
+      : this.lineNumberRightRef;
+  }
+
+  private lineNumber(side: Side) {
+    return this.line(side)?.lineNumber(side);
+  }
+
+  private line(side: Side) {
+    return side === Side.LEFT ? this.left : this.right;
+  }
+
+  private getType(side?: Side): string | undefined {
+    if (this.unifiedDiff) return undefined;
+    if (side === Side.LEFT) return this.left?.type;
+    if (side === Side.RIGHT) return this.right?.type;
+    return undefined;
+  }
+
+  private unifiedType() {
+    return this.left?.type === GrDiffLineType.BLANK
+      ? this.right?.type
+      : this.left?.type;
+  }
+
+  /**
+   * Returns a 'div' element containing the supplied |text| as its innerText,
+   * with '\t' characters expanded to a width determined by |tabSize|, and the
+   * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+   * desired.
+   */
+  private renderText(side: Side) {
+    const line = this.line(side);
+    const lineNumber = this.lineNumber(side);
+    if (typeof lineNumber !== 'number') return;
+
+    // Note that `this.layersApplied` will wipe away the <gr-diff-text>, and
+    // another rendering cycle will be initiated in `updated()`.
+    // prettier-ignore
+    const textElement = line?.text && !this.layersApplied
+      ? html`<gr-diff-text
+          ${ref(this.contentRef(side))}
+          .text=${line?.text}
+          .tabSize=${this.tabSize}
+          .lineLimit=${this.lineLength}
+          .isResponsive=${isResponsive(this.responsiveMode)}
+        ></gr-diff-text>` : '';
+    // .content has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`<div
+        class=${diffClasses('contentText')}
+        data-side=${ifDefined(side)}
+        id=${this.contentId(side)}
+      >${textElement}</div>`;
+  }
+
+  private renderPostLineSlot(side: Side) {
+    const lineNumber = this.lineNumber(side);
+    return lineNumber && Number.isInteger(lineNumber)
+      ? html`<slot name="post-${side}-line-${lineNumber}"></slot>`
+      : nothing;
+  }
+}
+
+if (!isNewDiff()) {
+  customElements.define('gr-diff-row', GrDiffRow);
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-row': LitElement;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-row_test.ts
new file mode 100644
index 0000000..42d30aa
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-row_test.ts
@@ -0,0 +1,271 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-row';
+import {GrDiffRow} from './gr-diff-row';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType} from '../../../api/diff';
+
+suite('gr-diff-row test', () => {
+  let element: GrDiffRow;
+
+  setup(async () => {
+    element = await fixture<GrDiffRow>(html`<gr-diff-row></gr-diff-row>`);
+    element.addTableWrapperForTesting = true;
+    await element.updateComplete;
+  });
+
+  test('both', async () => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = line;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <slot name="post-left-line-1"></slot>
+            <slot name="post-right-line-1"></slot>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('both unified', async () => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = line;
+    element.unifiedDiff = true;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="left-button-1 right-button-1 right-content-1"
+              class="both diff-row gr-diff unified"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <slot name="post-left-line-1"></slot>
+            <slot name="post-right-line-1"></slot>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('add', async () => {
+    const line = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+    line.text = 'lorem ipsum';
+    element.left = new GrDiffLine(GrDiffLineType.BLANK);
+    element.right = line;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="blank"
+              right-type="add"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="0"></td>
+              <td class="blankLineNum gr-diff left"></td>
+              <td class="blank gr-diff left no-intraline-info sign"></td>
+              <td class="blank gr-diff left no-intraline-info">
+                <div class="contentText gr-diff" data-side="left"></div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 added"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="add gr-diff no-intraline-info right sign">+</td>
+              <td class="add content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+              <slot name="post-right-line-1"></slot>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('remove', async () => {
+    const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = new GrDiffLine(GrDiffLineType.BLANK);
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              aria-labelledby="left-button-1 left-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="remove"
+              right-type="blank"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 removed"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info remove sign">-</td>
+              <td class="content gr-diff left no-intraline-info remove">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> lorem ipsum </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="blankLineNum gr-diff right"></td>
+              <td class="blank gr-diff no-intraline-info right sign"></td>
+              <td class="blank gr-diff no-intraline-info right">
+                <div class="contentText gr-diff" data-side="right"></div>
+              </td>
+            </tr>
+            <slot name="post-left-line-1"></slot>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-section.ts
new file mode 100644
index 0000000..28919e8
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-section.ts
@@ -0,0 +1,254 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement} from 'lit';
+import {property, state} from 'lit/decorators.js';
+import {
+  DiffInfo,
+  DiffLayer,
+  DiffViewMode,
+  RenderPreferences,
+  Side,
+  LineNumber,
+  DiffPreferencesInfo,
+} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {
+  diffClasses,
+  getResponsiveMode,
+  isNewDiff,
+} from '../../diff/gr-diff/gr-diff-utils';
+import {GrDiffRow} from './gr-diff-row';
+import '../gr-context-controls/gr-context-controls-section';
+import '../gr-context-controls/gr-context-controls';
+import '../../diff/gr-range-header/gr-range-header';
+import './gr-diff-row';
+import {when} from 'lit/directives/when.js';
+import {fire} from '../../../utils/event-util';
+import {countLines} from '../../../utils/diff-util';
+
+export class GrDiffSection extends LitElement {
+  @property({type: Object})
+  group?: GrDiffGroup;
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @property({type: Object})
+  renderPrefs?: RenderPreferences;
+
+  @property({type: Object})
+  diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  layers: DiffLayer[] = [];
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override render() {
+    if (!this.group) return;
+    const extras: string[] = [];
+    extras.push('section');
+    extras.push(this.group.type);
+    if (this.group.isTotal()) extras.push('total');
+    if (this.group.dueToRebase) extras.push('dueToRebase');
+    if (this.group.moveDetails) extras.push('dueToMove');
+    if (this.group.moveDetails?.changed) extras.push('changed');
+    if (this.group.ignoredWhitespaceOnly) extras.push('ignoredWhitespaceOnly');
+
+    const pairs = this.getLinePairs();
+    const responsiveMode = getResponsiveMode(this.diffPrefs, this.renderPrefs);
+    const hideFileCommentButton =
+      this.diffPrefs?.show_file_comment_button === false ||
+      this.renderPrefs?.show_file_comment_button === false;
+    const body = html`
+      <tbody class=${diffClasses(...extras)}>
+        ${this.renderContextControls()} ${this.renderMoveControls()}
+        ${pairs.map(pair => {
+          const leftCl = `left-${pair.left.lineNumber(Side.LEFT)}`;
+          const rightCl = `right-${pair.right.lineNumber(Side.RIGHT)}`;
+          return html`
+            <gr-diff-row
+              class="${leftCl} ${rightCl}"
+              .left=${pair.left}
+              .right=${pair.right}
+              .layers=${this.layers}
+              .lineLength=${this.diffPrefs?.line_length ?? 80}
+              .tabSize=${this.diffPrefs?.tab_size ?? 2}
+              .unifiedDiff=${this.isUnifiedDiff()}
+              .responsiveMode=${responsiveMode}
+              .hideFileCommentButton=${hideFileCommentButton}
+            >
+            </gr-diff-row>
+          `;
+        })}
+      </tbody>
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${body}
+      </table>`;
+    }
+    return body;
+  }
+
+  private isUnifiedDiff() {
+    return this.renderPrefs?.view_mode === DiffViewMode.UNIFIED;
+  }
+
+  getLinePairs() {
+    if (!this.group) return [];
+    const isControl = this.group.type === GrDiffGroupType.CONTEXT_CONTROL;
+    if (isControl) return [];
+    return this.isUnifiedDiff()
+      ? this.group.getUnifiedPairs()
+      : this.group.getSideBySidePairs();
+  }
+
+  getDiffRows(): GrDiffRow[] {
+    return [...this.querySelectorAll<GrDiffRow>('gr-diff-row')];
+  }
+
+  private renderContextControls() {
+    if (this.group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+
+    const leftStart = this.group.lineRange.left.start_line;
+    const leftEnd = this.group.lineRange.left.end_line;
+    const firstGroupIsSkipped = !!this.group.contextGroups[0].skip;
+    const lastGroupIsSkipped =
+      !!this.group.contextGroups[this.group.contextGroups.length - 1].skip;
+    const lineCountLeft = countLines(this.diff, Side.LEFT);
+    const containsWholeFile = lineCountLeft === leftEnd - leftStart + 1;
+    const showAbove =
+      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
+    const showBelow = leftEnd < lineCountLeft && !lastGroupIsSkipped;
+
+    return html`
+      <gr-context-controls-section
+        .showAbove=${showAbove}
+        .showBelow=${showBelow}
+        .group=${this.group}
+        .diff=${this.diff}
+        .renderPrefs=${this.renderPrefs}
+      >
+      </gr-context-controls-section>
+    `;
+  }
+
+  findRow(side: Side, lineNumber: LineNumber): GrDiffRow | undefined {
+    return (
+      this.querySelector<GrDiffRow>(`gr-diff-row.${side}-${lineNumber}`) ??
+      undefined
+    );
+  }
+
+  private renderMoveControls() {
+    if (!this.group?.moveDetails) return;
+    const movedIn = this.group.adds.length > 0;
+    const plainCell = html`<td class=${diffClasses()}></td>`;
+    const signCell = html`<td class=${diffClasses('sign')}></td>`;
+    const lineNumberCell = html`
+      <td class=${diffClasses('moveControlsLineNumCol')}></td>
+    `;
+    const moveCell = html`
+      <td class=${diffClasses('moveHeader')}>
+        <gr-range-header class=${diffClasses()} icon="move_item">
+          ${this.renderMoveDescription(movedIn)}
+        </gr-range-header>
+      </td>
+    `;
+    return html`
+      <tr
+        class=${diffClasses('moveControls', movedIn ? 'movedIn' : 'movedOut')}
+      >
+        ${when(
+          this.isUnifiedDiff(),
+          () => html`${lineNumberCell} ${lineNumberCell} ${moveCell}`,
+          () => html`${lineNumberCell} ${signCell}
+          ${movedIn ? plainCell : moveCell} ${lineNumberCell} ${signCell}
+          ${movedIn ? moveCell : plainCell}`
+        )}
+      </tr>
+    `;
+  }
+
+  private renderMoveDescription(movedIn: boolean) {
+    if (this.group?.moveDetails?.range) {
+      const {changed, range} = this.group.moveDetails;
+      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
+      const andChangedLabel = changed ? 'and changed ' : '';
+      const direction = movedIn ? 'from' : 'to';
+      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
+      return html`
+        <div class=${diffClasses()}>
+          <span class=${diffClasses()}>${textLabel}</span>
+          ${this.renderMovedLineAnchor(range.start, otherSide)}
+          <span class=${diffClasses()}> - </span>
+          ${this.renderMovedLineAnchor(range.end, otherSide)}
+        </div>
+      `;
+    }
+
+    return html`
+      <div class=${diffClasses()}>
+        <span class=${diffClasses()}
+          >${movedIn ? 'Moved in' : 'Moved out'}</span
+        >
+      </div>
+    `;
+  }
+
+  private renderMovedLineAnchor(line: number, side: Side) {
+    const listener = (e: MouseEvent) => {
+      e.preventDefault();
+      this.handleMovedLineAnchorClick(e.target, side, line);
+    };
+    // `href` is not actually used but important for Screen Readers
+    return html`
+      <a class=${diffClasses()} href=${`#${line}`} @click=${listener}
+        >${line}</a
+      >
+    `;
+  }
+
+  private handleMovedLineAnchorClick(
+    anchor: EventTarget | null,
+    side: Side,
+    line: number
+  ) {
+    if (!anchor) return;
+    fire(anchor, 'moved-link-clicked', {
+      lineNum: line,
+      side,
+    });
+  }
+}
+
+if (!isNewDiff()) {
+  customElements.define('gr-diff-section', GrDiffSection);
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-section': LitElement;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-section_test.ts
new file mode 100644
index 0000000..381f9b2
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-section_test.ts
@@ -0,0 +1,315 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-section';
+import {GrDiffSection} from './gr-diff-section';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {DiffViewMode, GrDiffLineType} from '../../../api/diff';
+import {waitQueryAndAssert} from '../../../test/test-utils';
+
+suite('gr-diff-section test', () => {
+  let element: GrDiffSection;
+
+  setup(async () => {
+    element = await fixture<GrDiffSection>(
+      html`<gr-diff-section></gr-diff-section>`
+    );
+    element.addTableWrapperForTesting = true;
+    await element.updateComplete;
+  });
+
+  suite('move controls', async () => {
+    setup(async () => {
+      const lines = [new GrDiffLine(GrDiffLineType.BOTH, 1, 1)];
+      lines[0].text = 'asdf';
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        lines,
+        moveDetails: {changed: false, range: {start: 1, end: 2}},
+      });
+      element.group = group;
+      await element.updateComplete;
+    });
+
+    test('side-by-side', async () => {
+      const row = await waitQueryAndAssert(element, 'tr.moveControls');
+      // Semantic dom diff has a problem with just comparing table rows or
+      // cells directly. So as a workaround put the row into an empty test
+      // table.
+      const testTable = document.createElement('table');
+      testTable.appendChild(row);
+      assert.dom.equal(
+        testTable,
+        /* HTML */ `
+          <table>
+            <tbody>
+              <tr class="gr-diff moveControls movedOut">
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff sign"></td>
+                <td class="gr-diff moveHeader">
+                  <gr-range-header class="gr-diff" icon="move_item">
+                    <div class="gr-diff">
+                      <span class="gr-diff"> Moved to lines </span>
+                      <a class="gr-diff" href="#1"> 1 </a>
+                      <span class="gr-diff"> - </span>
+                      <a class="gr-diff" href="#2"> 2 </a>
+                    </div>
+                  </gr-range-header>
+                </td>
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff sign"></td>
+                <td class="gr-diff"></td>
+              </tr>
+            </tbody>
+          </table>
+        `,
+        {}
+      );
+    });
+
+    test('unified', async () => {
+      element.renderPrefs = {
+        ...element.renderPrefs,
+        view_mode: DiffViewMode.UNIFIED,
+      };
+      const row = await waitQueryAndAssert(element, 'tr.moveControls');
+      // Semantic dom diff has a problem with just comparing table rows or
+      // cells directly. So as a workaround put the row into an empty test
+      // table.
+      const testTable = document.createElement('table');
+      testTable.appendChild(row);
+      assert.dom.equal(
+        testTable,
+        /* HTML */ `
+          <table>
+            <tbody>
+              <tr class="gr-diff moveControls movedOut">
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff moveControlsLineNumCol"></td>
+                <td class="gr-diff moveHeader">
+                  <gr-range-header class="gr-diff" icon="move_item">
+                    <div class="gr-diff">
+                      <span class="gr-diff"> Moved to lines </span>
+                      <a class="gr-diff" href="#1"> 1 </a>
+                      <span class="gr-diff"> - </span>
+                      <a class="gr-diff" href="#2"> 2 </a>
+                    </div>
+                  </gr-range-header>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        `,
+        {}
+      );
+    });
+  });
+
+  test('3 normal unchanged rows', async () => {
+    const lines = [
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+    ];
+    lines[0].text = 'asdf';
+    lines[1].text = 'qwer';
+    lines[2].text = 'zxcv';
+    const group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+    element.group = group;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <slot name="post-left-line-1"></slot>
+        <slot name="post-right-line-1"></slot>
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <slot name="post-left-line-1"></slot>
+        <slot name="post-right-line-1"></slot>
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <slot name="post-left-line-1"></slot>
+        <slot name="post-right-line-1"></slot>
+        <table>
+          <tbody class="both gr-diff section">
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <tr
+              aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+              class="diff-row gr-diff side-by-side"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff" data-line-number="1"></td>
+              <td class="gr-diff left lineNum" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff left lineNumButton"
+                  data-value="1"
+                  id="left-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff left no-intraline-info sign"></td>
+              <td class="both content gr-diff left no-intraline-info">
+                <div
+                  class="contentText gr-diff"
+                  data-side="left"
+                  id="left-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right" data-value="1">
+                <button
+                  aria-label="1 unmodified"
+                  class="gr-diff lineNumButton right"
+                  data-value="1"
+                  id="right-button-1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td class="gr-diff no-intraline-info right sign"></td>
+              <td class="both content gr-diff no-intraline-info right">
+                <div
+                  class="contentText gr-diff"
+                  data-side="right"
+                  id="right-content-1"
+                >
+                  <gr-diff-text> </gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-text.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-text.ts
new file mode 100644
index 0000000..3878402
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-text.ts
@@ -0,0 +1,155 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, TemplateResult} from 'lit';
+import {property} from 'lit/decorators.js';
+import {styleMap} from 'lit/directives/style-map.js';
+import {diffClasses, isNewDiff} from '../../diff/gr-diff/gr-diff-utils';
+
+const SURROGATE_PAIR = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+const TAB = '\t';
+
+/**
+ * Renders one line of code on one side of the diff. It takes care of:
+ * - Tabs, see `tabSize` property.
+ * - Line Breaks, see `lineLimit` property.
+ * - Surrogate Character Pairs.
+ *
+ * Note that other modifications to the code in a gr-diff is done via diff
+ * layers, which manipulate the DOM directly. So `gr-diff-text` is thrown
+ * away and re-rendered every time something changes by its parent
+ * `gr-diff-row`. So don't bother to optimize this component for re-rendering
+ * performance. And be aware that building longer lived local state is not
+ * useful here.
+ */
+export class GrDiffText extends LitElement {
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  @property({type: String})
+  text = '';
+
+  @property({type: Boolean})
+  isResponsive = false;
+
+  @property({type: Number})
+  tabSize = 2;
+
+  @property({type: Number})
+  lineLimit = 80;
+
+  /** Temporary state while rendering. */
+  private textOffset = 0;
+
+  /** Temporary state while rendering. */
+  private columnPos = 0;
+
+  /** Temporary state while rendering. */
+  private pieces: (string | TemplateResult)[] = [];
+
+  /** Split up the string into tabs, surrogate pairs and regular segments. */
+  override render() {
+    this.textOffset = 0;
+    this.columnPos = 0;
+    this.pieces = [];
+    const splitByTab = this.text.split('\t');
+    for (let i = 0; i < splitByTab.length; i++) {
+      const splitBySurrogate = splitByTab[i].split(SURROGATE_PAIR);
+      for (let j = 0; j < splitBySurrogate.length; j++) {
+        this.renderSegment(splitBySurrogate[j]);
+        if (j < splitBySurrogate.length - 1) {
+          this.renderSurrogatePair();
+        }
+      }
+      if (i < splitByTab.length - 1) {
+        this.renderTab();
+      }
+    }
+    if (this.textOffset !== this.text.length) throw new Error('unfinished');
+    return this.pieces;
+  }
+
+  /** Render regular characters, but insert line breaks appropriately. */
+  private renderSegment(segment: string) {
+    let segmentOffset = 0;
+    while (segmentOffset < segment.length) {
+      const newOffset = Math.min(
+        segment.length,
+        segmentOffset + this.lineLimit - this.columnPos
+      );
+      this.renderString(segment.substring(segmentOffset, newOffset));
+      segmentOffset = newOffset;
+      if (segmentOffset < segment.length && this.columnPos === this.lineLimit) {
+        this.renderLineBreak();
+      }
+    }
+  }
+
+  /** Render regular characters. */
+  private renderString(s: string) {
+    if (s.length === 0) return;
+    this.pieces.push(s);
+    this.textOffset += s.length;
+    this.columnPos += s.length;
+    if (this.columnPos > this.lineLimit) throw new Error('over line limit');
+  }
+
+  /** Render a tab character. */
+  private renderTab() {
+    let tabSize = this.tabSize - (this.columnPos % this.tabSize);
+    if (this.columnPos + tabSize > this.lineLimit) {
+      this.renderLineBreak();
+      tabSize = this.tabSize;
+    }
+    const piece = html`<span
+      class=${diffClasses('tab')}
+      style=${styleMap({'tab-size': `${tabSize}`})}
+      >${TAB}</span
+    >`;
+    this.pieces.push(piece);
+    this.textOffset += 1;
+    this.columnPos += tabSize;
+  }
+
+  /** Render a surrogate pair: string length is 2, but is just 1 char. */
+  private renderSurrogatePair() {
+    if (this.columnPos === this.lineLimit) {
+      this.renderLineBreak();
+    }
+    this.pieces.push(this.text.substring(this.textOffset, this.textOffset + 2));
+    this.textOffset += 2;
+    this.columnPos += 1;
+  }
+
+  /** Render a line break, don't advance text offset, reset col position. */
+  private renderLineBreak() {
+    if (this.isResponsive) {
+      this.pieces.push(html`<wbr class=${diffClasses()}></wbr>`);
+    } else {
+      this.pieces.push(html`<span class=${diffClasses('br')}></span>`);
+    }
+    // this.textOffset += 0;
+    this.columnPos = 0;
+  }
+}
+
+if (!isNewDiff()) {
+  customElements.define('gr-diff-text', GrDiffText);
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-text': LitElement;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-text_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-text_test.ts
new file mode 100644
index 0000000..3858bed
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-builder/gr-diff-text_test.ts
@@ -0,0 +1,166 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-text';
+import {GrDiffText} from './gr-diff-text';
+import {fixture, html, assert} from '@open-wc/testing';
+
+const LINE_BREAK = '<span class="gr-diff br"></span>';
+
+const LINE_BREAK_WBR = '<wbr class="gr-diff"></wbr>';
+
+const TAB = '<span class="" style=""></span>';
+
+const TAB_IGNORE = ['class', 'style'];
+
+suite('gr-diff-text test', () => {
+  let element: GrDiffText;
+
+  setup(async () => {
+    element = await fixture<GrDiffText>(
+      html`<gr-diff-text tabsize="4" linelimit="10"></gr-diff-text>`
+    );
+  });
+
+  const check = async (
+    text: string,
+    html: string,
+    ignoreAttributes: string[] = []
+  ) => {
+    element.text = text;
+    await element.updateComplete;
+    assert.lightDom.equal(element, html, {ignoreAttributes});
+  };
+
+  suite('lit rendering', () => {
+    test('renderText newlines 1', async () => {
+      await check('abcdef', 'abcdef');
+      await check('a'.repeat(20), `aaaaaaaaaa${LINE_BREAK}aaaaaaaaaa`);
+    });
+
+    test('renderText newlines 1 responsive', async () => {
+      element.isResponsive = true;
+      await check('abcdef', 'abcdef');
+      await check('a'.repeat(20), `aaaaaaaaaa${LINE_BREAK_WBR}aaaaaaaaaa`);
+    });
+
+    test('renderText newlines 2', async () => {
+      await check(
+        '<span class="thumbsup">👍</span>',
+        '&lt;span clas' +
+          LINE_BREAK +
+          's="thumbsu' +
+          LINE_BREAK +
+          'p"&gt;👍&lt;/span' +
+          LINE_BREAK +
+          '&gt;'
+      );
+    });
+
+    test('renderText newlines 3', async () => {
+      await check(
+        '01234\t56789',
+        '01234' + TAB + '56' + LINE_BREAK + '789',
+        TAB_IGNORE
+      );
+    });
+
+    test('renderText newlines 4', async () => {
+      element.lineLimit = 20;
+      await element.updateComplete;
+      await check(
+        '👍'.repeat(58),
+        '👍'.repeat(20) +
+          LINE_BREAK +
+          '👍'.repeat(20) +
+          LINE_BREAK +
+          '👍'.repeat(18)
+      );
+    });
+
+    test('tab wrapper style', async () => {
+      element.lineLimit = 100;
+      element.tabSize = 4;
+      await check(
+        '\t',
+        /* HTML */ '<span class="gr-diff tab" style="tab-size:4;"></span>'
+      );
+      await check(
+        'abc\t',
+        /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:1;"></span>'
+      );
+
+      element.tabSize = 8;
+      await check(
+        '\t',
+        /* HTML */ '<span class="gr-diff tab" style="tab-size:8;"></span>'
+      );
+      await check(
+        'abc\t',
+        /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:5;"></span>'
+      );
+    });
+
+    test('tab wrapper insertion', async () => {
+      await check('abc\tdef', 'abc' + TAB + 'def', TAB_IGNORE);
+    });
+
+    test('escaping HTML', async () => {
+      element.lineLimit = 100;
+      await element.updateComplete;
+      await check(
+        '<script>alert("XSS");<' + '/script>',
+        '&lt;script&gt;alert("XSS");&lt;/script&gt;'
+      );
+      await check('& < > " \' / `', '&amp; &lt; &gt; " \' / `');
+    });
+
+    test('text length with tabs and unicode', async () => {
+      async function expectTextLength(
+        text: string,
+        tabSize: number,
+        expected: number
+      ) {
+        element.text = text;
+        element.tabSize = tabSize;
+        element.lineLimit = expected;
+        await element.updateComplete;
+        const result = element.innerHTML;
+
+        // Must not contain a line break.
+        assert.isNotOk(element.querySelector('span.br'));
+
+        // Increasing the line limit by 1 should not change anything.
+        element.lineLimit = expected + 1;
+        await element.updateComplete;
+        const resultPlusOne = element.innerHTML;
+        assert.equal(resultPlusOne, result);
+
+        // Increasing the line limit to infinity should not change anything.
+        element.lineLimit = Infinity;
+        await element.updateComplete;
+        const resultInf = element.innerHTML;
+        assert.equal(resultInf, result);
+
+        // Decreasing the line limit by 1 should introduce a line break.
+        element.lineLimit = expected + 1;
+        await element.updateComplete;
+        assert.isNotOk(element.querySelector('span.br'));
+      }
+      expectTextLength('12345', 4, 5);
+      expectTextLength('\t\t12', 4, 10);
+      expectTextLength('abc💢123', 4, 7);
+      expectTextLength('abc\t', 8, 8);
+      expectTextLength('abc\t\t', 10, 20);
+      expectTextLength('', 10, 0);
+      // 17 Thai combining chars.
+      expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+      expectTextLength('abc\tde', 10, 12);
+      expectTextLength('abc\tde\t', 10, 20);
+      expectTextLength('\t\t\t\t\t', 20, 100);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-cursor/gr-diff-cursor.ts
new file mode 100644
index 0000000..6a32afb
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-cursor/gr-diff-cursor.ts
@@ -0,0 +1,593 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Subscription} from 'rxjs';
+import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
+import {
+  DiffViewMode,
+  GrDiffCursor as GrDiffCursorApi,
+  GrDiffLineType,
+  LineNumber,
+  LineSelectedEventDetail,
+} from '../../../api/diff';
+import {ScrollMode, Side} from '../../../constants/constants';
+import {toggleClass} from '../../../utils/dom-util';
+import {
+  GrCursorManager,
+  isTargetable,
+} from '../../../elements/shared/gr-cursor-manager/gr-cursor-manager';
+import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {fire} from '../../../utils/event-util';
+
+type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
+
+const LEFT_SIDE_CLASS = 'target-side-left';
+const RIGHT_SIDE_CLASS = 'target-side-right';
+
+interface Address {
+  leftSide: boolean;
+  number: number;
+}
+
+/**
+ * From <tr> diff row go up to <tbody> diff chunk.
+ *
+ * In Lit based diff there is a <gr-diff-row> element in between the two.
+ */
+export function fromRowToChunk(
+  rowEl: HTMLElement
+): HTMLTableSectionElement | undefined {
+  const parent = rowEl.parentElement;
+  if (!parent) return undefined;
+  if (parent.tagName === 'TBODY') {
+    return parent as HTMLTableSectionElement;
+  }
+
+  const grandParent = parent.parentElement;
+  if (!grandParent) return undefined;
+  if (grandParent.tagName === 'TBODY') {
+    return grandParent as HTMLTableSectionElement;
+  }
+
+  return undefined;
+}
+
+/** A subset of the GrDiff API that the cursor is using. */
+export interface GrDiffCursorable extends HTMLElement {
+  isRangeSelected(): boolean;
+  createRangeComment(): void;
+  getCursorStops(): Stop[];
+  path?: string;
+}
+
+export class GrDiffCursor implements GrDiffCursorApi {
+  private preventAutoScrollOnManualScroll = false;
+
+  set side(side: Side) {
+    if (this.sideInternal === side) {
+      return;
+    }
+    if (this.sideInternal && this.diffRow) {
+      this.fireCursorMoved(
+        'line-cursor-moved-out',
+        this.diffRow,
+        this.sideInternal
+      );
+    }
+    this.sideInternal = side;
+    this.updateSideClass();
+    if (this.diffRow) {
+      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    }
+  }
+
+  get side(): Side {
+    return this.sideInternal;
+  }
+
+  private sideInternal = Side.RIGHT;
+
+  set diffRow(diffRow: HTMLElement | undefined) {
+    if (this.diffRowInternal) {
+      this.diffRowInternal.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+      this.fireCursorMoved(
+        'line-cursor-moved-out',
+        this.diffRowInternal,
+        this.side
+      );
+    }
+    this.diffRowInternal = diffRow;
+
+    this.updateSideClass();
+    if (this.diffRow) {
+      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    }
+  }
+
+  get diffRow(): HTMLElement | undefined {
+    return this.diffRowInternal;
+  }
+
+  private diffRowInternal?: HTMLElement;
+
+  private diffs: GrDiffCursorable[] = [];
+
+  /**
+   * If set, the cursor will attempt to move to the line number (instead of
+   * the first chunk) the next time the diff renders. It is set back to null
+   * when used. It should be only used if you want the line to be focused
+   * after initialization of the component and page should scroll
+   * to that position. This parameter should be set at most for one gr-diff
+   * element in the page.
+   */
+  initialLineNumber: number | null = null;
+
+  // visible for testing
+  cursorManager = new GrCursorManager();
+
+  private targetSubscription?: Subscription;
+
+  constructor() {
+    this.cursorManager.cursorTargetClass = 'target-row';
+    this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
+    this.cursorManager.focusOnMove = true;
+
+    window.addEventListener('scroll', this._boundHandleWindowScroll);
+    this.targetSubscription = this.cursorManager.target$.subscribe(target => {
+      this.diffRow = target || undefined;
+    });
+  }
+
+  dispose() {
+    this.cursorManager.unsetCursor();
+    if (this.targetSubscription) this.targetSubscription.unsubscribe();
+    window.removeEventListener('scroll', this._boundHandleWindowScroll);
+  }
+
+  // Don't remove - used by clients embedding gr-diff outside of Gerrit.
+  isAtStart() {
+    return this.cursorManager.isAtStart();
+  }
+
+  // Don't remove - used by clients embedding gr-diff outside of Gerrit.
+  isAtEnd() {
+    return this.cursorManager.isAtEnd();
+  }
+
+  moveLeft() {
+    this.side = Side.LEFT;
+    if (this._isTargetBlank()) {
+      this.moveUp();
+    }
+  }
+
+  moveRight() {
+    this.side = Side.RIGHT;
+    if (this._isTargetBlank()) {
+      this.moveUp();
+    }
+  }
+
+  moveDown() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      return this.cursorManager.next({
+        filter: (row: Element) => this._rowHasSide(row),
+      });
+    } else {
+      return this.cursorManager.next();
+    }
+  }
+
+  moveUp() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      return this.cursorManager.previous({
+        filter: (row: Element) => this._rowHasSide(row),
+      });
+    } else {
+      return this.cursorManager.previous();
+    }
+  }
+
+  moveToVisibleArea() {
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.cursorManager.moveToVisibleArea((row: Element) =>
+        this._rowHasSide(row)
+      );
+    } else {
+      this.cursorManager.moveToVisibleArea();
+    }
+  }
+
+  moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
+    const result = this.cursorManager.next({
+      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+      getTargetHeight: target => fromRowToChunk(target)?.scrollHeight || 0,
+      clipToTop,
+    });
+    this._fixSide();
+    return result;
+  }
+
+  moveToPreviousChunk(): CursorMoveResult {
+    const result = this.cursorManager.previous({
+      filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+    });
+    this._fixSide();
+    return result;
+  }
+
+  moveToNextCommentThread(): CursorMoveResult {
+    if (this.isAtEnd()) {
+      return CursorMoveResult.CLIPPED;
+    }
+    const result = this.cursorManager.next({
+      filter: (row: HTMLElement) => this._rowHasThread(row),
+    });
+    this._fixSide();
+    return result;
+  }
+
+  moveToPreviousCommentThread(): CursorMoveResult {
+    const result = this.cursorManager.previous({
+      filter: (row: HTMLElement) => this._rowHasThread(row),
+    });
+    this._fixSide();
+    return result;
+  }
+
+  moveToLineNumber(
+    number: LineNumber,
+    side: Side,
+    path?: string,
+    intentionalMove?: boolean
+  ) {
+    const row = this._findRowByNumberAndFile(number, side, path);
+    if (row) {
+      this.side = side;
+      this.cursorManager.setCursor(row, undefined, intentionalMove);
+    }
+  }
+
+  /**
+   * Get the line number element targeted by the cursor row and side.
+   */
+  getTargetLineElement(): HTMLElement | null {
+    let lineElSelector = '.lineNum';
+
+    if (!this.diffRow) {
+      return null;
+    }
+
+    if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      lineElSelector += this.side === Side.LEFT ? '.left' : '.right';
+    }
+
+    return this.diffRow.querySelector(lineElSelector);
+  }
+
+  getTargetDiffElement(): GrDiff | null {
+    if (!this.diffRow) return null;
+
+    const hostOwner = this.diffRow.getRootNode() as ShadowRoot;
+    if (hostOwner?.host?.tagName === 'GR-DIFF') {
+      return hostOwner.host as GrDiff;
+    }
+    return null;
+  }
+
+  moveToFirstChunk() {
+    this.cursorManager.moveToStart();
+    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+      this.moveToNextChunk(true);
+    } else {
+      this._fixSide();
+    }
+  }
+
+  moveToLastChunk() {
+    this.cursorManager.moveToEnd();
+    if (this.diffRow && !this._isFirstRowOfChunk(this.diffRow)) {
+      this.moveToPreviousChunk();
+    } else {
+      this._fixSide();
+    }
+  }
+
+  /**
+   * Move the cursor either to initialLineNumber or the first chunk and
+   * reset scroll behavior.
+   *
+   * This may grab the focus from the app.
+   *
+   * If you do not want to move the cursor or grab focus, and just want to
+   * reset the scroll behavior, use reInitAndUpdateStops() instead.
+   */
+  reInitCursor() {
+    this._updateStops();
+    if (!this.diffRow) {
+      // does not scroll during init unless requested
+      this.cursorManager.scrollMode = this.initialLineNumber
+        ? ScrollMode.KEEP_VISIBLE
+        : ScrollMode.NEVER;
+      if (this.initialLineNumber) {
+        this.moveToLineNumber(this.initialLineNumber, this.side);
+        this.initialLineNumber = null;
+      } else {
+        this.moveToFirstChunk();
+      }
+    }
+    this.resetScrollMode();
+  }
+
+  resetScrollMode() {
+    this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
+  }
+
+  private _boundHandleWindowScroll = () => {
+    if (this.preventAutoScrollOnManualScroll) {
+      this.cursorManager.scrollMode = ScrollMode.NEVER;
+      this.cursorManager.focusOnMove = false;
+      this.preventAutoScrollOnManualScroll = false;
+    }
+  };
+
+  reInitAndUpdateStops() {
+    this.resetScrollMode();
+    this._updateStops();
+  }
+
+  private boundHandleDiffLoadingChanged = () => {
+    this._updateStops();
+  };
+
+  private _boundHandleDiffRenderStart = () => {
+    this.preventAutoScrollOnManualScroll = true;
+  };
+
+  private _boundHandleDiffRenderContent = () => {
+    this._updateStops();
+    // When done rendering, turn focus on move and automatic scrolling back on
+    this.cursorManager.focusOnMove = true;
+    this.preventAutoScrollOnManualScroll = false;
+  };
+
+  private _boundHandleDiffLineSelected = (
+    e: CustomEvent<LineSelectedEventDetail>
+  ) => {
+    this.moveToLineNumber(e.detail.number, e.detail.side, e.detail.path);
+  };
+
+  createCommentInPlace() {
+    const diffWithRangeSelected = this.diffs.find(diff =>
+      diff.isRangeSelected()
+    );
+    if (diffWithRangeSelected) {
+      diffWithRangeSelected.createRangeComment();
+    } else {
+      const line = this.getTargetLineElement();
+      const diff = this.getTargetDiffElement();
+      if (diff && line) {
+        diff.addDraftAtLine(line);
+      }
+    }
+  }
+
+  /**
+   * Get an object describing the location of the cursor. Such as
+   * {leftSide: false, number: 123} for line 123 of the revision, or
+   * {leftSide: true, number: 321} for line 321 of the base patch.
+   * Returns null if an address is not available.
+   */
+  getAddress(): Address | null {
+    if (!this.diffRow) {
+      return null;
+    }
+    // Get the line-number cell targeted by the cursor. If the mode is unified
+    // then prefer the revision cell if available.
+    return this.getAddressFor(this.diffRow, this.side);
+  }
+
+  private getAddressFor(diffRow: HTMLElement, side: Side): Address | null {
+    let cell;
+    if (this._getViewMode() === DiffViewMode.UNIFIED) {
+      cell = diffRow.querySelector('.lineNum.right');
+      if (!cell) {
+        cell = diffRow.querySelector('.lineNum.left');
+      }
+    } else {
+      cell = diffRow.querySelector('.lineNum.' + side);
+    }
+    if (!cell) {
+      return null;
+    }
+
+    const number = cell.getAttribute('data-value');
+    if (!number || number === 'FILE') {
+      return null;
+    }
+
+    return {
+      leftSide: cell.matches('.left'),
+      number: Number(number),
+    };
+  }
+
+  _getViewMode() {
+    if (!this.diffRow) {
+      return null;
+    }
+
+    if (this.diffRow.classList.contains('side-by-side')) {
+      return DiffViewMode.SIDE_BY_SIDE;
+    } else {
+      return DiffViewMode.UNIFIED;
+    }
+  }
+
+  _rowHasSide(row: Element) {
+    const selector =
+      (this.side === Side.LEFT ? '.left' : '.right') + ' + .content';
+    return !!row.querySelector(selector);
+  }
+
+  _isFirstRowOfChunk(row: HTMLElement) {
+    const chunk = fromRowToChunk(row);
+    if (!chunk) return false;
+
+    const isInDeltaChunk = chunk.classList.contains('delta');
+    if (!isInDeltaChunk) return false;
+
+    const firstRow = chunk.querySelector('tr:not(.moveControls)');
+    return firstRow === row;
+  }
+
+  _rowHasThread(row: HTMLElement): boolean {
+    const slots = [
+      ...row.querySelectorAll<HTMLSlotElement>('.thread-group > slot'),
+    ];
+    return slots.some(slot => slot.assignedElements().length > 0);
+  }
+
+  /**
+   * If we jumped to a row where there is no content on the current side then
+   * switch to the alternate side.
+   */
+  _fixSide() {
+    if (
+      this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
+      this._isTargetBlank()
+    ) {
+      this.side = this.side === Side.LEFT ? Side.RIGHT : Side.LEFT;
+    }
+  }
+
+  _isTargetBlank() {
+    if (!this.diffRow) {
+      return false;
+    }
+
+    const actions = this._getActionsForRow();
+    return (
+      (this.side === Side.LEFT && !actions.left) ||
+      (this.side === Side.RIGHT && !actions.right)
+    );
+  }
+
+  private fireCursorMoved(
+    event: 'line-cursor-moved-out' | 'line-cursor-moved-in',
+    row: HTMLElement,
+    side: Side
+  ) {
+    const address = this.getAddressFor(row, side);
+    if (address) {
+      const {leftSide, number} = address;
+      fire(row, event, {
+        lineNum: number,
+        side: leftSide ? Side.LEFT : Side.RIGHT,
+      });
+    }
+  }
+
+  private updateSideClass() {
+    if (!this.diffRow) {
+      return;
+    }
+    toggleClass(this.diffRow, LEFT_SIDE_CLASS, this.side === Side.LEFT);
+    toggleClass(this.diffRow, RIGHT_SIDE_CLASS, this.side === Side.RIGHT);
+  }
+
+  _isActionType(type: GrDiffRowType) {
+    return (
+      type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL
+    );
+  }
+
+  _getActionsForRow() {
+    const actions = {left: false, right: false};
+    if (this.diffRow) {
+      actions.left = this._isActionType(
+        this.diffRow.getAttribute('left-type') as GrDiffRowType
+      );
+      actions.right = this._isActionType(
+        this.diffRow.getAttribute('right-type') as GrDiffRowType
+      );
+    }
+    return actions;
+  }
+
+  _updateStops() {
+    this.cursorManager.stops = this.diffs.reduce(
+      (stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
+      []
+    );
+  }
+
+  replaceDiffs(diffs: GrDiffCursorable[]) {
+    for (const diff of this.diffs) {
+      this.removeEventListeners(diff);
+    }
+    this.diffs = [];
+    for (const diff of diffs) {
+      this.addEventListeners(diff);
+    }
+    this.diffs.push(...diffs);
+    this._updateStops();
+  }
+
+  unregisterDiff(diff: GrDiffCursorable) {
+    // This can happen during destruction - just don't unregister then.
+    if (!this.diffs) return;
+    const i = this.diffs.indexOf(diff);
+    if (i !== -1) {
+      this.diffs.splice(i, 1);
+    }
+  }
+
+  private removeEventListeners(diff: GrDiffCursorable) {
+    diff.removeEventListener(
+      'loading-changed',
+      this.boundHandleDiffLoadingChanged
+    );
+    diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.removeEventListener(
+      'render-content',
+      this._boundHandleDiffRenderContent
+    );
+    diff.removeEventListener(
+      'line-selected',
+      this._boundHandleDiffLineSelected
+    );
+  }
+
+  private addEventListeners(diff: GrDiffCursorable) {
+    diff.addEventListener(
+      'loading-changed',
+      this.boundHandleDiffLoadingChanged
+    );
+    diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
+    diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
+  }
+
+  _findRowByNumberAndFile(
+    targetNumber: LineNumber,
+    side: Side,
+    path?: string
+  ): HTMLElement | undefined {
+    let stops: Array<HTMLElement | AbortStop>;
+    if (path) {
+      const diff = this.diffs.filter(diff => diff.path === path)[0];
+      stops = diff.getCursorStops();
+    } else {
+      stops = this.cursorManager.stops;
+    }
+    // Sadly needed for type narrowing to understand that the result is always
+    // targetable.
+    const targetableStops: HTMLElement[] = stops.filter(isTargetable);
+    const selector = `.lineNum.${side}[data-value="${targetNumber}"]`;
+    return targetableStops.find(stop => stop.querySelector(selector));
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-cursor/gr-diff-cursor_test.ts
new file mode 100644
index 0000000..61f8551
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -0,0 +1,694 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../gr-diff/gr-diff';
+import './gr-diff-cursor';
+import {fixture, html, assert} from '@open-wc/testing';
+import {
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
+import {createDiff} from '../../../test/test-data-generators';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffCursor} from './gr-diff-cursor';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {DiffInfo, DiffViewMode, Side} from '../../../api/diff';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {assertIsDefined} from '../../../utils/common-util';
+
+suite('gr-diff-cursor tests', () => {
+  let cursor: GrDiffCursor;
+  let diffElement: GrDiff;
+  let diff: DiffInfo;
+
+  setup(async () => {
+    diffElement = await fixture(html`<gr-diff></gr-diff>`);
+    cursor = new GrDiffCursor();
+
+    // Register the diff with the cursor.
+    cursor.replaceDiffs([diffElement]);
+
+    diffElement.loggedIn = false;
+    diffElement.path = 'some/path.ts';
+    const promise = mockPromise();
+    const setupDone = () => {
+      cursor._updateStops();
+      cursor.moveToFirstChunk();
+      diffElement.removeEventListener('render', setupDone);
+      promise.resolve();
+    };
+    diffElement.addEventListener('render', setupDone);
+
+    diff = createDiff();
+    diffElement.prefs = createDefaultDiffPrefs();
+    diffElement.diff = diff;
+    await promise;
+  });
+
+  test('diff cursor functionality (side-by-side)', () => {
+    assert.isOk(cursor.diffRow);
+
+    const deltaRows = queryAll<HTMLTableRowElement>(
+      diffElement,
+      '.section.delta tr.diff-row'
+    );
+    assert.equal(cursor.diffRow, deltaRows[0]);
+
+    cursor.moveDown();
+
+    assert.notEqual(cursor.diffRow, deltaRows[0]);
+    assert.equal(cursor.diffRow, deltaRows[1]);
+
+    cursor.moveUp();
+
+    assert.notEqual(cursor.diffRow, deltaRows[1]);
+    assert.equal(cursor.diffRow, deltaRows[0]);
+  });
+
+  test('moveToFirstChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {b: ['new line 1']},
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    // The file comment button, if present, is a cursor stop. Ensure
+    // moveToFirstChunk() works correctly even if the button is not shown.
+    diffElement.prefs!.show_file_comment_button = false;
+    await waitForEventOnce(diffElement, 'render');
+
+    cursor._updateStops();
+
+    const chunks = [
+      ...queryAll(diffElement, '.section.delta'),
+    ] as HTMLElement[];
+    assert.equal(chunks.length, 2);
+
+    const rows = [
+      ...queryAll(diffElement, '.section.delta tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(rows.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[0]);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToNextChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[1]);
+    assert.equal(cursor.side, Side.LEFT);
+
+    cursor.moveToFirstChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[0]);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('moveToLastChunk', async () => {
+    const diff: DiffInfo = {
+      meta_a: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      meta_b: {
+        name: 'lorem-ipsum.txt',
+        content_type: 'text/plain',
+        lines: 3,
+      },
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
+        'index b2adcf4..554ae49 100644',
+        '--- a/lorem-ipsum.txt',
+        '+++ b/lorem-ipsum.txt',
+      ],
+      content: [
+        {ab: ['unchanged line']},
+        {a: ['old line 2']},
+        {ab: ['more unchanged lines']},
+        {b: ['new line 3']},
+      ],
+    };
+
+    diffElement.diff = diff;
+    await waitForEventOnce(diffElement, 'render');
+    cursor._updateStops();
+
+    const chunks = [
+      ...queryAll(diffElement, '.section.delta'),
+    ] as HTMLElement[];
+    assert.equal(chunks.length, 2);
+
+    const rows = [
+      ...queryAll(diffElement, '.section.delta tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(rows.length, 2);
+
+    // Verify it works on fresh diff.
+    cursor.moveToLastChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[1]);
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Verify it works from other cursor positions.
+    cursor.moveToPreviousChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[0]);
+    assert.equal(cursor.side, Side.LEFT);
+
+    cursor.moveToLastChunk();
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[1]);
+    assert.equal(cursor.side, Side.RIGHT);
+  });
+
+  test('cursor scroll behavior', () => {
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+
+    diffElement.dispatchEvent(new Event('render-start'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    window.dispatchEvent(new Event('scroll'));
+    assert.equal(cursor.cursorManager.scrollMode, 'never');
+    assert.isFalse(cursor.cursorManager.focusOnMove);
+
+    diffElement.dispatchEvent(new Event('render-content'));
+    assert.isTrue(cursor.cursorManager.focusOnMove);
+
+    cursor.reInitCursor();
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('moves to selected line', () => {
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+
+    diffElement.dispatchEvent(
+      new CustomEvent('line-selected', {
+        detail: {number: '123', side: Side.RIGHT, path: 'some/file'},
+      })
+    );
+
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 123);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(moveToNumStub.lastCall.args[2], 'some/file');
+  });
+
+  suite('unified diff', () => {
+    setup(async () => {
+      diffElement.viewMode = DiffViewMode.UNIFIED;
+      await waitForEventOnce(diffElement, 'render');
+      cursor.reInitCursor();
+    });
+
+    test('diff cursor functionality (unified)', () => {
+      assert.isOk(cursor.diffRow);
+
+      const rows = [
+        ...queryAll(diffElement, '.section.delta tr.diff-row'),
+      ] as HTMLTableRowElement[];
+      assert.equal(cursor.diffRow, rows[0]);
+
+      cursor.moveDown();
+
+      assert.notEqual(cursor.diffRow, rows[0]);
+      assert.equal(cursor.diffRow, rows[1]);
+
+      cursor.moveUp();
+
+      assert.notEqual(cursor.diffRow, rows[1]);
+      assert.equal(cursor.diffRow, rows[0]);
+    });
+  });
+
+  test('cursor side functionality', () => {
+    // The side only applies to side-by-side mode, which should be the default
+    // mode.
+    assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+    const rows = [
+      ...queryAll(diffElement, '.section tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(rows.length, 50);
+    const deltaRows = [
+      ...queryAll(diffElement, '.section.delta tr.diff-row'),
+    ] as HTMLTableRowElement[];
+    assert.equal(deltaRows.length, 14);
+    const indexFirstDelta = rows.indexOf(deltaRows[0]);
+    const rowBeforeFirstDelta = rows[indexFirstDelta - 1];
+
+    // Because the first delta in this diff is on the right, it should be set
+    // to the right side.
+    assert.equal(cursor.side, Side.RIGHT);
+    assert.equal(cursor.diffRow, deltaRows[0]);
+    const firstIndex = cursor.cursorManager.index;
+
+    // Move the side to the left. Because this delta only has a right side, we
+    // should be moved up to the previous line where there is content on the
+    // right. The previous row is part of the previous section.
+    cursor.moveLeft();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, rows[0]);
+    assert.equal(cursor.diffRow, rowBeforeFirstDelta);
+    assert.equal(cursor.cursorManager.index, firstIndex - 1);
+
+    // If we move down, we should skip everything in the first delta because
+    // we are on the left side and the first delta has no content on the left.
+    cursor.moveDown();
+
+    assert.equal(cursor.side, Side.LEFT);
+    assert.notEqual(cursor.diffRow, rowBeforeFirstDelta);
+    assert.notEqual(cursor.diffRow, rows[0]);
+    assert.isTrue(cursor.cursorManager.index > firstIndex);
+  });
+
+  test('chunk skip functionality', () => {
+    const deltaChunks = [...queryAll(diffElement, 'tbody.section.delta')];
+
+    // We should be initialized to the first chunk. Since this chunk only has
+    // content on the right side, our side should be right.
+    assert.equal(cursor.diffRow, deltaChunks[0].querySelector('tr'));
+    assert.equal(cursor.side, Side.RIGHT);
+
+    // Move to the next chunk.
+    cursor.moveToNextChunk();
+
+    // Since this chunk only has content on the left side. we should have been
+    // automatically moved over.
+    assert.equal(cursor.diffRow, deltaChunks[1].querySelector('tr'));
+    assert.equal(cursor.side, Side.LEFT);
+  });
+
+  suite('moved chunks without line range)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
+      ];
+      assert.include(movedIn.innerText, 'Moved in');
+      assert.include(movedOut.innerText, 'Moved out');
+    });
+  });
+
+  suite('moved chunks (moveDetails)', () => {
+    setup(async () => {
+      const promise = mockPromise();
+      const renderHandler = function () {
+        diffElement.removeEventListener('render', renderHandler);
+        cursor.reInitCursor();
+        promise.resolve();
+      };
+      diffElement.addEventListener('render', renderHandler);
+      diffElement.diff = {
+        ...diff,
+        content: [
+          {
+            ab: ['Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, '],
+          },
+          {
+            b: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 4, end: 6}},
+          },
+          {
+            ab: ['Sem nascetur, erat ut, non in.'],
+          },
+          {
+            a: [
+              'Nullam neque, ligula ac, id blandit.',
+              'Sagittis tincidunt torquent, tempor nunc amet.',
+              'At rhoncus id.',
+            ],
+            move_details: {changed: false, range: {start: 2, end: 4}},
+          },
+          {
+            ab: ['Arcu eget, rhoncus amet cursus, ipsum elementum.'],
+          },
+        ],
+      };
+      await promise;
+    });
+
+    test('renders moveControls with simple descriptions', () => {
+      const [movedIn, movedOut] = [
+        ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
+      ];
+      assert.include(movedIn.innerText, 'Moved from lines 4 - 6');
+      assert.include(movedOut.innerText, 'Moved to lines 2 - 4');
+    });
+
+    test('startLineAnchor of movedIn chunk fires events', async () => {
+      const [movedIn] = [...queryAll(diffElement, '.dueToMove .moveControls')];
+      const [startLineAnchor] = movedIn.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.LEFT});
+        promise.resolve();
+      };
+      assert.equal(startLineAnchor.textContent, '4');
+      startLineAnchor.addEventListener(
+        'moved-link-clicked',
+        onMovedLinkClicked
+      );
+      startLineAnchor.click();
+      await promise;
+    });
+
+    test('endLineAnchor of movedOut fires events', async () => {
+      const [, movedOut] = [
+        ...queryAll(diffElement, '.dueToMove .moveControls'),
+      ];
+      const [, endLineAnchor] = movedOut.querySelectorAll('a');
+
+      const promise = mockPromise();
+      const onMovedLinkClicked = (e: CustomEvent) => {
+        assert.deepEqual(e.detail, {lineNum: 4, side: Side.RIGHT});
+        promise.resolve();
+      };
+      assert.equal(endLineAnchor.textContent, '4');
+      endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
+      endLineAnchor.click();
+      await promise;
+    });
+  });
+
+  test('initialLineNumber not provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+    const moveToChunkStub = sinon
+      .stub(cursor, 'moveToFirstChunk')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+    diffElement.diff = createDiff();
+    await diffElement.updateComplete;
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToNumStub.called);
+    assert.isTrue(moveToChunkStub.called);
+    assert.equal(scrollBehaviorDuringMove, 'never');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('initialLineNumber provided', async () => {
+    let scrollBehaviorDuringMove;
+    const moveToNumStub = sinon
+      .stub(cursor, 'moveToLineNumber')
+      .callsFake(() => {
+        scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
+      });
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
+    cursor.initialLineNumber = 10;
+    cursor.side = Side.RIGHT;
+
+    diffElement.diff = createDiff();
+    await diffElement.updateComplete;
+    await waitForEventOnce(diffElement, 'render');
+    cursor.reInitCursor();
+    assert.isFalse(moveToChunkStub.called);
+    assert.isTrue(moveToNumStub.called);
+    assert.equal(moveToNumStub.lastCall.args[0], 10);
+    assert.equal(moveToNumStub.lastCall.args[1], Side.RIGHT);
+    assert.equal(scrollBehaviorDuringMove, 'keep-visible');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+  });
+
+  test('getTargetDiffElement', () => {
+    cursor.initialLineNumber = 1;
+    assert.isTrue(!!cursor.diffRow);
+    assert.equal(cursor.getTargetDiffElement(), diffElement);
+  });
+
+  suite('createCommentInPlace', () => {
+    setup(() => {
+      diffElement.loggedIn = true;
+    });
+
+    test('adds new draft for selected line on the left', async () => {
+      cursor.moveToLineNumber(2, Side.LEFT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 2);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.LEFT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('adds draft for selected line on the right', async () => {
+      cursor.moveToLineNumber(4, Side.RIGHT);
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 4);
+        assert.equal(range, undefined);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('creates comment for range if selected', async () => {
+      const someRange = {
+        start_line: 2,
+        start_character: 3,
+        end_line: 6,
+        end_character: 1,
+      };
+      diffElement.highlights.selectedRange = {
+        side: Side.RIGHT,
+        range: someRange,
+      };
+      const promise = mockPromise();
+      diffElement.addEventListener('create-comment', e => {
+        const {lineNum, range, side} = e.detail;
+        assert.equal(lineNum, 6);
+        assert.equal(range, someRange);
+        assert.equal(side, Side.RIGHT);
+        promise.resolve();
+      });
+      cursor.createCommentInPlace();
+      await promise;
+    });
+
+    test('ignores call if nothing is selected', () => {
+      const createRangeCommentStub = sinon.stub(
+        diffElement,
+        'createRangeComment'
+      );
+      const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
+      cursor.diffRow = undefined;
+      cursor.createCommentInPlace();
+      assert.isFalse(createRangeCommentStub.called);
+      assert.isFalse(addDraftAtLineStub.called);
+    });
+  });
+
+  test('getAddress', () => {
+    // It should initialize to the first chunk: line 5 of the revision.
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // Revision line 4 is up.
+    cursor.moveUp();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 4});
+
+    // Base line 4 is left.
+    cursor.moveLeft();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
+
+    // Moving to the next chunk takes it back to the start.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: false, number: 5});
+
+    // The following chunk is a removal starting on line 10 of the base.
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 10});
+
+    // Should be null if there is no selection.
+    cursor.cursorManager.unsetCursor();
+    assert.isNotOk(cursor.getAddress());
+  });
+
+  test('_findRowByNumberAndFile', () => {
+    // Get the first ab row after the first chunk.
+    const rows = [...queryAll<HTMLTableRowElement>(diffElement, 'tr')];
+    const row = rows[9];
+    assert.ok(row);
+
+    // It should be line 8 on the right, but line 5 on the left.
+    assert.equal(cursor._findRowByNumberAndFile(8, Side.RIGHT), row);
+    assert.equal(cursor._findRowByNumberAndFile(5, Side.LEFT), row);
+  });
+
+  test('expand context updates stops', async () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    const controls = queryAndAssert(diffElement, 'gr-context-controls');
+    const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
+    showContext.click();
+    await waitForEventOnce(diffElement, 'render');
+    await waitUntil(() => spy.called);
+    assert.isTrue(spy.called);
+  });
+
+  test('updates stops when loading changes', () => {
+    const spy = sinon.spy(cursor, '_updateStops');
+    diffElement.dispatchEvent(new Event('loading-changed'));
+    assert.isTrue(spy.called);
+  });
+
+  suite('multi diff', () => {
+    let diffElements: GrDiff[];
+
+    setup(async () => {
+      diffElements = [
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+        await fixture(html`<gr-diff></gr-diff>`),
+      ];
+      cursor = new GrDiffCursor();
+
+      // Register the diff with the cursor.
+      cursor.replaceDiffs(diffElements);
+
+      for (const el of diffElements) {
+        el.prefs = createDefaultDiffPrefs();
+      }
+    });
+
+    function getTargetDiffIndex() {
+      // Mocha has a bug where when `assert.equals` fails, it will try to
+      // JSON.stringify the operands, which fails when they are cyclic structures
+      // like GrDiffElement. The failure is difficult to attribute to a specific
+      // assertion because of the async nature assertion errors are handled and
+      // can cause the test simply timing out, causing a lot of debugging headache.
+      // Working with indices circumvents the problem.
+      const target = cursor.getTargetDiffElement();
+      assertIsDefined(target);
+      return diffElements.indexOf(target);
+    }
+
+    test('do not skip loading diffs', async () => {
+      diffElements[0].diff = createDiff();
+      diffElements[2].diff = createDiff();
+      await waitForEventOnce(diffElements[0], 'render');
+      await waitForEventOnce(diffElements[2], 'render');
+
+      const lastLine = diffElements[0].diff.meta_b?.lines;
+      assertIsDefined(lastLine);
+
+      // Goto second last line of the first diff
+      cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent?.trim(),
+        `${lastLine - 1}`
+      );
+
+      // Can move down until we reach the loading file
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent?.trim(),
+        lastLine.toString()
+      );
+
+      // Cannot move down while still loading the diff we would switch to
+      cursor.moveDown();
+      assert.equal(getTargetDiffIndex(), 0);
+      assert.equal(
+        cursor.getTargetLineElement()!.textContent?.trim(),
+        lastLine.toString()
+      );
+
+      // Diff 1 finishing to load
+      diffElements[1].diff = createDiff();
+      await waitForEventOnce(diffElements[1], 'render');
+
+      // Now we can go down
+      cursor.moveDown(); // LOST
+      cursor.moveDown(); // FILE
+      assert.equal(getTargetDiffIndex(), 1);
+      assert.equal(cursor.getTargetLineElement()!.textContent?.trim(), 'File');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-annotation.ts
new file mode 100644
index 0000000..38bd707
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-annotation.ts
@@ -0,0 +1,284 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {getSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings';
+
+// TODO(wyatta): refactor this to be <MARK> rather than <HL>.
+const ANNOTATION_TAG = 'HL';
+
+// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+export const GrAnnotation = {
+  /**
+   * The DOM API textContent.length calculation is broken when the text
+   * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+   *
+   */
+  getLength(node: Node) {
+    if (node instanceof Comment) return 0;
+    return this.getStringLength(node.textContent || '');
+  },
+
+  /**
+   * Returns the number of Unicode code points in the given string
+   *
+   * This is not necessarily the same as the number of visible symbols.
+   * See https://mathiasbynens.be/notes/javascript-unicode for more details.
+   */
+  getStringLength(str: string) {
+    return [...str].length;
+  },
+
+  /**
+   * Annotates the [offset, offset+length) text segment in the parent with the
+   * element definition provided as arguments.
+   *
+   * @param parent the node whose contents will be annotated.
+   * If parent is Text then parent.parentNode must not be null
+   * @param offset the 0-based offset from which the annotation will
+   * start.
+   * @param length of the annotated text.
+   * @param elementSpec the spec to create the
+   * annotating element.
+   */
+  annotateWithElement(
+    parent: Node,
+    offset: number,
+    length: number,
+    elSpec: ElementSpec
+  ) {
+    const tagName = elSpec.tagName;
+    const attributes = elSpec.attributes || {};
+    let childNodes: Node[];
+
+    if (parent instanceof Element) {
+      childNodes = Array.from(parent.childNodes);
+    } else if (parent instanceof Text) {
+      childNodes = [parent];
+      parent = parent.parentNode!;
+    } else {
+      return;
+    }
+
+    const nestedNodes: Node[] = [];
+    for (let node of childNodes) {
+      const initialNodeLength = this.getLength(node);
+      // If the current node is completely before the offset.
+      if (offset > 0 && initialNodeLength <= offset) {
+        offset -= initialNodeLength;
+        continue;
+      }
+
+      if (offset > 0) {
+        node = this.splitNode(node, offset);
+        offset = 0;
+      }
+      if (this.getLength(node) > length) {
+        this.splitNode(node, length);
+      }
+      nestedNodes.push(node);
+
+      length -= this.getLength(node);
+      if (!length) break;
+    }
+
+    const wrapper = document.createElement(tagName);
+    const sanitizer = getSanitizeDOMValue();
+    for (let [name, value] of Object.entries(attributes)) {
+      if (!value) continue;
+      if (sanitizer) {
+        value = sanitizer(value, name, 'attribute', wrapper) as string;
+      }
+      wrapper.setAttribute(name, value);
+    }
+    for (const inner of nestedNodes) {
+      parent.replaceChild(wrapper, inner);
+      wrapper.appendChild(inner);
+    }
+  },
+
+  /**
+   * Surrounds the element's text at specified range in an ANNOTATION_TAG
+   * element. If the element has child elements, the range is split and
+   * applied as deeply as possible.
+   */
+  annotateElement(
+    parent: HTMLElement,
+    offset: number,
+    length: number,
+    cssClass: string
+  ) {
+    const nodes: Array<HTMLElement | Text> = [].slice.apply(parent.childNodes);
+    let nodeLength;
+    let subLength;
+
+    for (const node of nodes) {
+      nodeLength = this.getLength(node);
+
+      // If the current node is completely before the offset.
+      if (nodeLength <= offset) {
+        offset -= nodeLength;
+        continue;
+      }
+
+      // Sublength is the annotation length for the current node.
+      subLength = Math.min(length, nodeLength - offset);
+
+      if (node instanceof Text) {
+        this._annotateText(node, offset, subLength, cssClass);
+      } else if (node instanceof Element) {
+        this.annotateElement(node, offset, subLength, cssClass);
+      }
+
+      // If there is still more to annotate, then shift the indices, otherwise
+      // work is done, so break the loop.
+      if (subLength < length) {
+        length -= subLength;
+        offset = 0;
+      } else {
+        break;
+      }
+    }
+  },
+
+  /**
+   * Wraps node in annotation tag with cssClass, replacing the node in DOM.
+   */
+  wrapInHighlight(node: Element | Text, cssClass: string) {
+    let hl;
+    if (!(node instanceof Text) && node.tagName === ANNOTATION_TAG) {
+      hl = node;
+      hl.classList.add(cssClass);
+    } else {
+      hl = document.createElement(ANNOTATION_TAG);
+      hl.className = cssClass;
+      if (node.parentElement) node.parentElement.replaceChild(hl, node);
+      hl.appendChild(node);
+    }
+    return hl;
+  },
+
+  /**
+   * Splits Text Node and wraps it in hl with cssClass.
+   * Wraps trailing part after split, tailing one if firstPart is true.
+   */
+  splitAndWrapInHighlight(
+    node: Text,
+    offset: number,
+    cssClass: string,
+    firstPart?: boolean
+  ) {
+    if (
+      (this.getLength(node) === offset && firstPart) ||
+      (offset === 0 && !firstPart)
+    ) {
+      return this.wrapInHighlight(node, cssClass);
+    }
+    if (firstPart) {
+      this.splitNode(node, offset);
+      // Node points to first part of the Text, second one is sibling.
+    } else {
+      // if node is Text then splitNode will return a Text
+      node = this.splitNode(node, offset) as Text;
+    }
+    return this.wrapInHighlight(node, cssClass);
+  },
+
+  /**
+   * Splits Node at offset.
+   * If Node is Element, it's cloned and the node at offset is split too.
+   */
+  splitNode(element: Node, offset: number) {
+    if (element instanceof Text) {
+      return this.splitTextNode(element, offset);
+    }
+    const tail = element.cloneNode(false);
+
+    if (element.parentElement)
+      element.parentElement.insertBefore(tail, element.nextSibling);
+    // Skip nodes before offset.
+    let node = element.firstChild;
+    while (
+      node &&
+      (this.getLength(node) <= offset || this.getLength(node) === 0)
+    ) {
+      offset -= this.getLength(node);
+      node = node.nextSibling;
+    }
+    if (node && this.getLength(node) > offset) {
+      tail.appendChild(this.splitNode(node, offset));
+    }
+    while (node && node.nextSibling) {
+      tail.appendChild(node.nextSibling);
+    }
+    return tail;
+  },
+
+  /**
+   * Node.prototype.splitText Unicode-valid alternative.
+   *
+   * DOM Api for splitText() is broken for Unicode:
+   * https://mathiasbynens.be/notes/javascript-unicode
+   *
+   * @return Trailing Text Node.
+   */
+  splitTextNode(node: Text, offset: number) {
+    if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
+      const head = Array.from(node.textContent);
+      const tail = head.splice(offset);
+      const parent = node.parentNode;
+
+      // Split the content of the original node.
+      node.textContent = head.join('');
+
+      const tailNode = document.createTextNode(tail.join(''));
+      if (parent) {
+        parent.insertBefore(tailNode, node.nextSibling);
+      }
+      return tailNode;
+    } else {
+      return node.splitText(offset);
+    }
+  },
+
+  _annotateText(node: Text, offset: number, length: number, cssClass: string) {
+    const nodeLength = this.getLength(node);
+
+    // There are four cases:
+    //  1) Entire node is highlighted.
+    //  2) Highlight is at the start.
+    //  3) Highlight is at the end.
+    //  4) Highlight is in the middle.
+
+    if (offset === 0 && nodeLength === length) {
+      // Case 1.
+      this.wrapInHighlight(node, cssClass);
+    } else if (offset === 0) {
+      // Case 2.
+      this.splitAndWrapInHighlight(node, length, cssClass, true);
+    } else if (offset + length === nodeLength) {
+      // Case 3
+      this.splitAndWrapInHighlight(node, offset, cssClass, false);
+    } else {
+      // Case 4
+      this.splitAndWrapInHighlight(
+        this.splitTextNode(node, offset),
+        length,
+        cssClass,
+        true
+      );
+    }
+  },
+};
+
+/**
+ * Data used to construct an element.
+ *
+ */
+export interface ElementSpec {
+  tagName: string;
+  attributes?: {[attributeName: string]: string | undefined};
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-annotation_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-annotation_test.ts
new file mode 100644
index 0000000..f319a3c
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-annotation_test.ts
@@ -0,0 +1,308 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '../../../test/common-test-setup';
+import {GrAnnotation} from './gr-annotation';
+import {
+  getSanitizeDOMValue,
+  setSanitizeDOMValue,
+} from '@polymer/polymer/lib/utils/settings';
+import {assert, fixture, html} from '@open-wc/testing';
+
+suite('annotation', () => {
+  let str: string;
+  let parent: HTMLDivElement;
+  let textNode: Text;
+
+  setup(async () => {
+    parent = await fixture(
+      html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `
+    );
+    textNode = parent.childNodes[0] as Text;
+    str = textNode.textContent!;
+  });
+
+  test('_annotateText length:0 offset:0', () => {
+    GrAnnotation._annotateText(textNode, 0, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar"></hl>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateText length:0 offset:1', () => {
+    GrAnnotation._annotateText(textNode, 1, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'L<hl class="foobar"></hl>orem ipsum dolor sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateText length:0 offset:str.length', () => {
+    GrAnnotation._annotateText(textNode, str.length, 0, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula<hl class="foobar"></hl>'
+    );
+  });
+
+  test('_annotateText Case 1', () => {
+    GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar">Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</hl>'
+    );
+  });
+
+  test('_annotateText Case 2', () => {
+    GrAnnotation._annotateText(textNode, 0, 12, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      '<hl class="foobar">Lorem ipsum </hl>dolor sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateText Case 3', () => {
+    GrAnnotation._annotateText(textNode, 12, str.length - 12, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum <hl class="foobar">dolor sit amet, suspendisse inceptos vehicula</hl>'
+    );
+  });
+
+  test('_annotateText Case 4', () => {
+    const index = str.indexOf('dolor');
+    const length = 'dolor '.length;
+
+    GrAnnotation._annotateText(textNode, index, length, 'foobar');
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum <hl class="foobar">dolor </hl>sit amet, suspendisse inceptos vehicula'
+    );
+  });
+
+  test('_annotateElement design doc example', () => {
+    const layers = ['amet, ', 'inceptos ', 'amet, ', 'et, suspendisse ince'];
+
+    // Apply the layers successively.
+    layers.forEach((layer, i) => {
+      GrAnnotation.annotateElement(
+        parent,
+        str.indexOf(layer),
+        layer.length,
+        `layer-${i + 1}`
+      );
+    });
+
+    assert.equal(parent.textContent, str);
+    assert.equal(
+      parent.innerHTML,
+      'Lorem ipsum dolor sit <hl class="layer-1"><hl class="layer-3">am<hl class="layer-4">et, </hl></hl></hl><hl class="layer-4">suspendisse </hl><hl class="layer-2"><hl class="layer-4">ince</hl>ptos </hl>vehicula'
+    );
+  });
+
+  test('splitTextNode', () => {
+    const helloString = 'hello';
+    const asciiString = 'ASCII';
+    const unicodeString = 'Unic💢de';
+
+    let node;
+    let tail;
+
+    // Non-unicode path:
+    node = document.createTextNode(helloString + asciiString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, asciiString);
+
+    // Unicdoe path:
+    node = document.createTextNode(helloString + unicodeString);
+    tail = GrAnnotation.splitTextNode(node, helloString.length);
+    assert(node.textContent, helloString);
+    assert(tail.textContent, unicodeString);
+  });
+
+  suite('annotateWithElement', () => {
+    const fullText = '01234567890123456789';
+    let mockSanitize: sinon.SinonSpy;
+    let originalSanitizeDOMValue: (
+      p0: any,
+      p1: string,
+      p2: string,
+      p3: Node | null
+    ) => any;
+
+    setup(() => {
+      setSanitizeDOMValue(p0 => p0);
+      originalSanitizeDOMValue = getSanitizeDOMValue()!;
+      assert.isDefined(originalSanitizeDOMValue);
+      mockSanitize = sinon.spy(originalSanitizeDOMValue);
+      setSanitizeDOMValue(mockSanitize);
+    });
+
+    teardown(() => {
+      setSanitizeDOMValue(originalSanitizeDOMValue);
+    });
+
+    test('annotates when fully contained', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0<test-wrapper>1234567890</test-wrapper>123456789'
+      );
+    });
+
+    test('annotates when spanning multiple nodes', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateElement(container, 5, length, 'testclass');
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0' +
+          '<test-wrapper>' +
+          '1234' +
+          '<hl class="testclass">567890</hl>' +
+          '</test-wrapper>' +
+          '<hl class="testclass">1234</hl>' +
+          '56789'
+      );
+    });
+
+    test('annotates text node', () => {
+      const length = 10;
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      GrAnnotation.annotateWithElement(container.childNodes[0], 1, length, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0<test-wrapper>1234567890</test-wrapper>123456789'
+      );
+    });
+
+    test('handles zero-length nodes', () => {
+      const container = document.createElement('div');
+      container.appendChild(document.createTextNode('0123456789'));
+      container.appendChild(document.createElement('span'));
+      container.appendChild(document.createTextNode('0123456789'));
+      GrAnnotation.annotateWithElement(container, 1, 10, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789'
+      );
+    });
+
+    test('handles comment nodes', () => {
+      const container = document.createElement('div');
+      container.appendChild(document.createComment('comment1'));
+      container.appendChild(document.createTextNode('0123456789'));
+      container.appendChild(document.createComment('comment2'));
+      container.appendChild(document.createElement('span'));
+      container.appendChild(document.createTextNode('0123456789'));
+      GrAnnotation.annotateWithElement(container, 1, 10, {
+        tagName: 'test-wrapper',
+      });
+
+      assert.equal(
+        container.innerHTML,
+        '<!--comment1-->' +
+          '0<test-wrapper>123456789' +
+          '<!--comment2-->' +
+          '<span></span>0</test-wrapper>123456789'
+      );
+    });
+
+    test('sets sanitized attributes', () => {
+      const container = document.createElement('div');
+      container.textContent = fullText;
+      const attributes = {
+        href: 'foo',
+        'data-foo': 'bar',
+        class: 'hello world',
+      };
+      GrAnnotation.annotateWithElement(container, 1, length, {
+        tagName: 'test-wrapper',
+        attributes,
+      });
+      assert(
+        mockSanitize.calledWith(
+          'foo',
+          'href',
+          'attribute',
+          sinon.match.instanceOf(Element)
+        )
+      );
+      assert(
+        mockSanitize.calledWith(
+          'bar',
+          'data-foo',
+          'attribute',
+          sinon.match.instanceOf(Element)
+        )
+      );
+      assert(
+        mockSanitize.calledWith(
+          'hello world',
+          'class',
+          'attribute',
+          sinon.match.instanceOf(Element)
+        )
+      );
+      const el = container.querySelector('test-wrapper')!;
+      assert.equal(el.getAttribute('href'), 'foo');
+      assert.equal(el.getAttribute('data-foo'), 'bar');
+      assert.equal(el.getAttribute('class'), 'hello world');
+    });
+  });
+
+  suite('getStringLength', () => {
+    test('ASCII characters are counted correctly', () => {
+      assert.equal(GrAnnotation.getStringLength('ASCII'), 5);
+    });
+
+    test('Unicode surrogate pairs count as one symbol', () => {
+      assert.equal(GrAnnotation.getStringLength('Unic💢de'), 7);
+      assert.equal(GrAnnotation.getStringLength('💢💢'), 2);
+    });
+
+    test('Grapheme clusters count as multiple symbols', () => {
+      assert.equal(GrAnnotation.getStringLength('man\u0303ana'), 7); // mañana
+      assert.equal(GrAnnotation.getStringLength('q\u0307\u0323'), 3); // q̣̇
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-diff-highlight.ts
new file mode 100644
index 0000000..2c0663d
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-diff-highlight.ts
@@ -0,0 +1,525 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../styles/shared-styles';
+import '../../diff/gr-selection-action-box/gr-selection-action-box';
+import {GrAnnotation} from './gr-annotation';
+import {normalize} from './gr-range-normalizer';
+import {strToClassName} from '../../../utils/dom-util';
+import {Side} from '../../../constants/constants';
+import {CommentRange} from '../../../types/common';
+import {GrSelectionActionBox} from '../../diff/gr-selection-action-box/gr-selection-action-box';
+import {
+  getLineElByChild,
+  getLineNumberByChild,
+  getSideByLineEl,
+  GrDiffThreadElement,
+} from '../../diff/gr-diff/gr-diff-utils';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
+
+interface SidedRange {
+  side: Side;
+  range: CommentRange;
+}
+
+interface NormalizedPosition {
+  node: Node | null;
+  side: Side;
+  line: number;
+  column: number;
+}
+
+interface NormalizedRange {
+  start: NormalizedPosition | null;
+  end: NormalizedPosition | null;
+}
+
+/**
+ * The methods that we actually want to call on the builder. We don't want a
+ * fully blown dependency on GrDiffBuilderElement.
+ */
+export interface DiffBuilderInterface {
+  getContentTdByLineEl(lineEl?: Element): Element | undefined;
+}
+
+/**
+ * Handles showing, positioning and interacting with <gr-selection-action-box>.
+ *
+ * Toggles a css class for highlighting comment ranges when the mouse leaves or
+ * enters a comment thread element.
+ */
+export class GrDiffHighlight {
+  selectedRange?: SidedRange;
+
+  private diffBuilder?: DiffBuilderInterface;
+
+  private diffTable?: HTMLElement;
+
+  private selectionChangeTask?: DelayedTask;
+
+  init(diffTable: HTMLElement, diffBuilder: DiffBuilderInterface) {
+    this.cleanup();
+
+    this.diffTable = diffTable;
+    this.diffBuilder = diffBuilder;
+
+    diffTable.addEventListener(
+      'comment-thread-mouseleave',
+      this.handleCommentThreadMouseleave
+    );
+    diffTable.addEventListener(
+      'comment-thread-mouseenter',
+      this.handleCommentThreadMouseenter
+    );
+    diffTable.addEventListener(
+      'create-comment-requested',
+      this.handleRangeCommentRequest
+    );
+  }
+
+  cleanup() {
+    this.selectionChangeTask?.cancel();
+    if (this.diffTable) {
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseleave',
+        this.handleCommentThreadMouseleave
+      );
+      this.diffTable.removeEventListener(
+        'comment-thread-mouseenter',
+        this.handleCommentThreadMouseenter
+      );
+      this.diffTable.removeEventListener(
+        'create-comment-requested',
+        this.handleRangeCommentRequest
+      );
+    }
+  }
+
+  /**
+   * Determines side/line/range for a DOM selection and shows a tooltip.
+   *
+   * With native shadow DOM, gr-diff-highlight cannot access a selection that
+   * references the DOM elements making up the diff because they are in the
+   * shadow DOM the gr-diff element. For this reason, we listen to the
+   * selectionchange event and retrieve the selection in gr-diff, and then
+   * call this method to process the Selection.
+   *
+   * @param selection A DOM Selection living in the shadow DOM of
+   * the diff element.
+   * @param isMouseUp If true, this is called due to a mouseup
+   * event, in which case we might want to immediately create a comment,
+   * because isMouseUp === true combined with an existing selection must
+   * mean that this is the end of a double-click.
+   */
+  handleSelectionChange(
+    selection: Selection | Range | null,
+    isMouseUp: boolean
+  ) {
+    if (selection === null) return;
+    // Debounce is not just nice for waiting until the selection has settled,
+    // it is also vital for being able to click on the action box before it is
+    // removed.
+    // If you wait longer than 50 ms, then you don't properly catch a very
+    // quick 'c' press after the selection change. If you wait less than 10
+    // ms, then you will have about 50 handleSelection() calls when doing a
+    // simple drag for select.
+    this.selectionChangeTask = debounce(
+      this.selectionChangeTask,
+      () => this.handleSelection(selection, isMouseUp),
+      10
+    );
+  }
+
+  private getThreadEl(e: Event): GrDiffThreadElement | null {
+    for (const pathEl of e.composedPath()) {
+      if (
+        pathEl instanceof HTMLElement &&
+        pathEl.classList.contains('comment-thread')
+      ) {
+        return pathEl as GrDiffThreadElement;
+      }
+    }
+    return null;
+  }
+
+  private toggleRangeElHighlight(
+    threadEl: GrDiffThreadElement | null,
+    highlightRange = false
+  ) {
+    const rootId = threadEl?.rootId;
+    if (!rootId) return;
+    if (!this.diffTable) return;
+    if (highlightRange) {
+      const selector = `.range.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.add('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
+      );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.add('rangeHoverHighlight')
+        );
+    } else {
+      const selector = `.rangeHoverHighlight.${strToClassName(rootId)}`;
+      const rangeNodes = this.diffTable.querySelectorAll(selector);
+      rangeNodes.forEach(rangeNode => {
+        rangeNode.classList.remove('rangeHoverHighlight');
+      });
+      const hintNode = this.diffTable.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${rootId}"]`
+      );
+      hintNode?.shadowRoot
+        ?.querySelectorAll('.rangeHoverHighlight')
+        .forEach(highlightNode =>
+          highlightNode.classList.remove('rangeHoverHighlight')
+        );
+    }
+  }
+
+  private handleCommentThreadMouseenter = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+  };
+
+  private handleCommentThreadMouseleave = (e: Event) => {
+    const threadEl = this.getThreadEl(e);
+    this.toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+  };
+
+  /**
+   * Get current normalized selection.
+   * Merges multiple ranges, accounts for triple click, accounts for
+   * syntax highligh, convert native DOM Range objects to Gerrit concepts
+   * (line, side, etc).
+   */
+  private getNormalizedRange(selection: Selection | Range) {
+    /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
+       we can get is a single Range */
+    if (selection instanceof Range) {
+      return this.normalizeRange(selection);
+    }
+    const rangeCount = selection.rangeCount;
+    if (rangeCount === 0) {
+      return null;
+    } else if (rangeCount === 1) {
+      return this.normalizeRange(selection.getRangeAt(0));
+    } else {
+      const startRange = this.normalizeRange(selection.getRangeAt(0));
+      const endRange = this.normalizeRange(
+        selection.getRangeAt(rangeCount - 1)
+      );
+      return {
+        start: startRange.start,
+        end: endRange.end,
+      };
+    }
+  }
+
+  /**
+   * Normalize a specific DOM Range.
+   *
+   * @return fixed normalized range
+   */
+  private normalizeRange(domRange: Range): NormalizedRange {
+    const range = normalize(domRange);
+    return this.fixTripleClickSelection(
+      {
+        start: this.normalizeSelectionSide(
+          range.startContainer,
+          range.startOffset
+        ),
+        end: this.normalizeSelectionSide(range.endContainer, range.endOffset),
+      },
+      domRange
+    );
+  }
+
+  /**
+   * Adjust triple click selection for the whole line.
+   * A triple click always results in:
+   * - start.column == end.column == 0
+   * - end.line == start.line + 1
+   *
+   * @param range Normalized range, ie column/line numbers
+   * @param domRange DOM Range object
+   * @return fixed normalized range
+   */
+  private fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
+    if (!range.start) {
+      // Selection outside of current diff.
+      return range;
+    }
+    const start = range.start;
+    const end = range.end;
+    // Happens when triple click in side-by-side mode with other side empty.
+    const endsAtOtherEmptySide =
+      !end &&
+      domRange.endOffset === 0 &&
+      domRange.endContainer instanceof HTMLElement &&
+      domRange.endContainer.nodeName === 'TD' &&
+      (domRange.endContainer.classList.contains('left') ||
+        domRange.endContainer.classList.contains('right'));
+    const endsAtBeginningOfNextLine =
+      end &&
+      start.column === 0 &&
+      end.column === 0 &&
+      end.line === start.line + 1;
+    const content = domRange.cloneContents().querySelector('.contentText');
+    const lineLength = (content && this.getLength(content)) || 0;
+    if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
+      // Move the selection to the end of the previous line.
+      range.end = {
+        node: start.node,
+        column: lineLength,
+        side: start.side,
+        line: start.line,
+      };
+    }
+    return range;
+  }
+
+  /**
+   * Convert DOM Range selection to concrete numbers (line, column, side).
+   * Moves range end if it's not inside td.content.
+   * Returns null if selection end is not valid (outside of diff).
+   *
+   * @param node td.content child
+   * @param offset offset within node
+   */
+  private normalizeSelectionSide(
+    node: Node | null,
+    offset: number
+  ): NormalizedPosition | null {
+    let column;
+    if (!this.diffTable) return null;
+    if (!this.diffBuilder) return null;
+    if (!node || !this.diffTable.contains(node)) return null;
+    const lineEl = getLineElByChild(node);
+    if (!lineEl) return null;
+    const side = getSideByLineEl(lineEl);
+    if (!side) return null;
+    const line = getLineNumberByChild(lineEl);
+    if (typeof line !== 'number') return null;
+    const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
+    if (!contentTd) return null;
+    const contentText = contentTd.querySelector('.contentText');
+    if (!contentTd.contains(node)) {
+      node = contentText;
+      column = 0;
+    } else {
+      const thread = contentTd.querySelector('.comment-thread');
+      if (thread?.contains(node)) {
+        column = this.getLength(contentText);
+        node = contentText;
+      } else {
+        column = this.convertOffsetToColumn(node, offset);
+      }
+    }
+
+    return {
+      node,
+      side,
+      line,
+      column,
+    };
+  }
+
+  /**
+   * The only line in which add a comment tooltip is cut off is the first
+   * line. Even if there is a collapsed section, The first visible line is
+   * in the position where the second line would have been, if not for the
+   * collapsed section, so don't need to worry about this case for
+   * positioning the tooltip.
+   */
+  // visible for testing
+  positionActionBox(
+    actionBox: GrSelectionActionBox,
+    startLine: number,
+    range: Text | Element | Range
+  ) {
+    if (startLine > 1) {
+      actionBox.positionBelow = false;
+      actionBox.placeAbove(range);
+      return;
+    }
+    actionBox.positionBelow = true;
+    actionBox.placeBelow(range);
+  }
+
+  private isRangeValid(range: NormalizedRange | null) {
+    if (!range || !range.start || !range.start.node || !range.end) {
+      return false;
+    }
+    const start = range.start;
+    const end = range.end;
+    return !(
+      start.side !== end.side ||
+      end.line < start.line ||
+      (start.line === end.line && start.column === end.column)
+    );
+  }
+
+  // visible for testing
+  handleSelection(selection: Selection | Range, isMouseUp: boolean) {
+    /* On Safari, the selection events may return a null range that should
+       be ignored */
+    if (!selection) return;
+    if (!this.diffTable) return;
+
+    const normalizedRange = this.getNormalizedRange(selection);
+    if (!this.isRangeValid(normalizedRange)) {
+      this.removeActionBox();
+      return;
+    }
+    /* On Safari the ShadowRoot.getSelection() isn't there and the only thing
+       we can get is a single Range */
+    const domRange =
+      selection instanceof Range ? selection : selection.getRangeAt(0);
+    const start = normalizedRange!.start!;
+    const end = normalizedRange!.end!;
+
+    // TODO (viktard): Drop empty first and last lines from selection.
+
+    // If the selection is from the end of one line to the start of the next
+    // line, then this must have been a double-click, or you have started
+    // dragging. Showing the action box is bad in the former case and not very
+    // useful in the latter, so never do that.
+    // If this was a mouse-up event, we create a comment immediately if
+    // the selection is from the end of a line to the start of the next line.
+    // In a perfect world we would only do this for double-click, but it is
+    // extremely rare that a user would drag from the end of one line to the
+    // start of the next and release the mouse, so we don't bother.
+    // TODO(brohlfs): This does not work, if the double-click is before a new
+    // diff chunk (start will be equal to end), and neither before an "expand
+    // the diff context" block (end line will match the first line of the new
+    // section and thus be greater than start line + 1).
+    if (start.line === end.line - 1 && end.column === 0) {
+      // Rather than trying to find the line contents (for comparing
+      // start.column with the content length), we just check if the selection
+      // is empty to see that it's at the end of a line.
+      const content = domRange.cloneContents().querySelector('.contentText');
+      if (isMouseUp && this.getLength(content) === 0) {
+        this.fireCreateRangeComment(start.side, {
+          start_line: start.line,
+          start_character: 0,
+          end_line: start.line,
+          end_character: start.column,
+        });
+      }
+      return;
+    }
+
+    let actionBox = this.diffTable.querySelector('gr-selection-action-box');
+    if (!actionBox) {
+      actionBox = document.createElement('gr-selection-action-box');
+      this.diffTable.appendChild(actionBox);
+    }
+    this.selectedRange = {
+      range: {
+        start_line: start.line,
+        start_character: start.column,
+        end_line: end.line,
+        end_character: end.column,
+      },
+      side: start.side,
+    };
+    if (start.line === end.line) {
+      this.positionActionBox(actionBox, start.line, domRange);
+    } else if (start.node instanceof Text) {
+      if (start.column) {
+        this.positionActionBox(
+          actionBox,
+          start.line,
+          start.node.splitText(start.column)
+        );
+      }
+      start.node.parentElement!.normalize(); // Undo splitText from above.
+    } else if (
+      start.node instanceof HTMLElement &&
+      start.node.classList.contains('content') &&
+      (start.node.firstChild instanceof Element ||
+        start.node.firstChild instanceof Text)
+    ) {
+      this.positionActionBox(actionBox, start.line, start.node.firstChild);
+    } else if (start.node instanceof Element || start.node instanceof Text) {
+      this.positionActionBox(actionBox, start.line, start.node);
+    } else {
+      console.warn('Failed to position comment action box.');
+      this.removeActionBox();
+    }
+  }
+
+  private fireCreateRangeComment(side: Side, range: CommentRange) {
+    if (this.diffTable) {
+      fire(this.diffTable, 'create-range-comment', {side, range});
+    }
+    this.removeActionBox();
+  }
+
+  private handleRangeCommentRequest = (e: Event) => {
+    e.stopPropagation();
+    assertIsDefined(this.selectedRange, 'selectedRange');
+    const {side, range} = this.selectedRange;
+    this.fireCreateRangeComment(side, range);
+  };
+
+  // visible for testing
+  removeActionBox() {
+    this.selectedRange = undefined;
+    const actionBox = this.diffTable?.querySelector('gr-selection-action-box');
+    if (actionBox) actionBox.remove();
+  }
+
+  private convertOffsetToColumn(el: Node, offset: number) {
+    if (el instanceof Element && el.classList.contains('content')) {
+      return offset;
+    }
+    while (
+      el.previousSibling ||
+      !el.parentElement?.classList.contains('content')
+    ) {
+      if (el.previousSibling) {
+        el = el.previousSibling;
+        offset += this.getLength(el);
+      } else {
+        el = el.parentElement!;
+      }
+    }
+    return offset;
+  }
+
+  /**
+   * Get length of a node. If the node is a content node, then only give the
+   * length of its .contentText child.
+   *
+   * @param node this is sometimes passed as null.
+   */
+  // visible for testing
+  getLength(node: Node | null): number {
+    if (node === null) return 0;
+    if (node instanceof Element && node.classList.contains('content')) {
+      return this.getLength(queryAndAssert(node, '.contentText'));
+    } else {
+      return GrAnnotation.getLength(node);
+    }
+  }
+}
+
+export interface CreateRangeCommentEventDetail {
+  side: Side;
+  range: CommentRange;
+}
+
+declare global {
+  interface HTMLElementEventMap {
+    'create-range-comment': CustomEvent<CreateRangeCommentEventDetail>;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-diff-highlight_test.ts
new file mode 100644
index 0000000..e491e63
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -0,0 +1,717 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-highlight';
+import {getTextOffset} from './gr-range-normalizer';
+import {fixture, fixtureCleanup, html, assert} from '@open-wc/testing';
+import {
+  GrDiffHighlight,
+  DiffBuilderInterface,
+  CreateRangeCommentEventDetail,
+} from './gr-diff-highlight';
+import {Side} from '../../../api/diff';
+import {SinonStubbedMember} from 'sinon';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrDiffThreadElement} from '../../diff/gr-diff/gr-diff-utils';
+import {
+  stubElement,
+  waitQueryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
+import {GrSelectionActionBox} from '../../diff/gr-selection-action-box/gr-selection-action-box';
+
+// Splitting long lines in html into shorter rows breaks tests:
+// zero-length text nodes and new lines are not expected in some places
+/* eslint-disable max-len, lit/prefer-static-styles */
+/* prettier-ignore */
+const diffTable = html`
+  <table id="diffTable">
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="1"></td>
+        <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText">na💢ti <hl class="foo range generated_id314">te, inquit</hl>, sumus<hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>udiam, <hl>quid</hl> sit,<span class="tab-indicator" style="tab-size:8;"> </span>quod<hl>Epicurum</hl></div></td>
+        <td class="right lineNum" data-value="2"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>otiosum,<span class="tab-indicator" style="tab-size:8;"> </span> audiam,sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="138"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+        <td class="right lineNum" data-value="119"></td>
+        <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta">
+      <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+        <td class="left lineNum" data-value="140"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content remove"><div class="contentText"><!-- a comment node -->na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+          [Yet another random diff thread content here]
+        </div></td>
+        <td class="right lineNum" data-value="120"></td>
+        <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+        <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">\u0009</span> audiam,  sit, quod</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="141"></td>
+        <td class="content both"><div class="contentText">nam et<hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>complectitur<span class="tab-indicator" style="tab-size:8;"></span>verbis, quod vult, et dicit plane, quod intellegam;</div></td>
+        <td class="right lineNum" data-value="130"></td>
+        <td class="content both"><div class="contentText">nam et complectitur verbis, quod vult, et dicit plane, quodintellegam;</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section contextControl">
+      <tr
+        class="diff-row side-by-side"
+        left-type="contextControl"
+        right-type="contextControl"
+      >
+        <td class="left contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+        <td class="right contextLineNum"></td>
+        <td>
+          <gr-button>+10↑</gr-button>
+          -
+          <gr-button>Show 21 common lines</gr-button>
+          -
+          <gr-button>+10↓</gr-button>
+        </td>
+      </tr>
+    </tbody>
+
+    <tbody class="section delta total">
+      <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+        <td class="left"></td>
+        <td class="blank"></td>
+        <td class="right lineNum" data-value="146"></td>
+        <td class="content add"><div class="contentText">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</div></td>
+      </tr>
+    </tbody>
+
+    <tbody class="section both">
+      <tr class="diff-row side-by-side" left-type="both" right-type="both">
+        <td class="left lineNum" data-value="165"></td>
+        <td class="content both"><div class="contentText"></div></td>
+        <td class="right lineNum" data-value="147"></td>
+        <td class="content both"><div class="contentText">in physicis, <hl><span class="tab-indicator" style="tab-size:8;"> </span></hl>quibus maxime gloriatur, primum totus est alienus. Democritea dicit</div></td>
+      </tr>
+    </tbody>
+  </table>
+`;
+/* eslint-enable max-len */
+
+suite('gr-diff-highlight', () => {
+  suite('comment events', () => {
+    let threadEl: GrDiffThreadElement;
+    let hlRange: HTMLElement;
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      hlRange = queryAndAssert(diff, 'hl.range.generated_id314');
+
+      threadEl = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      threadEl.className = 'comment-thread';
+      threadEl.rootId = 'id314';
+      diff.appendChild(threadEl);
+    });
+
+    teardown(() => {
+      element.cleanup();
+      threadEl.remove();
+    });
+
+    test('comment-thread-mouseenter toggles rangeHoverHighlight class', async () => {
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseenter', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isTrue(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test('comment-thread-mouseleave toggles rangeHoverHighlight class', async () => {
+      hlRange.classList.add('rangeHoverHighlight');
+      threadEl.dispatchEvent(
+        new CustomEvent('comment-thread-mouseleave', {
+          bubbles: true,
+          composed: true,
+        })
+      );
+      await waitUntil(() => !hlRange.classList.contains('rangeHoverHighlight'));
+      assert.isFalse(hlRange.classList.contains('rangeHoverHighlight'));
+    });
+
+    test(`create-range-comment for range when create-comment-requested
+          is fired`, () => {
+      const removeActionBoxStub = sinon.stub(element, 'removeActionBox');
+      element.selectedRange = {
+        side: Side.LEFT,
+        range: {
+          start_line: 7,
+          start_character: 11,
+          end_line: 24,
+          end_character: 42,
+        },
+      };
+      const requestEvent = new CustomEvent('create-comment-requested');
+      let createRangeEvent: CustomEvent<CreateRangeCommentEventDetail>;
+      diff.addEventListener('create-range-comment', e => {
+        createRangeEvent = e;
+      });
+      diff.dispatchEvent(requestEvent);
+      if (!createRangeEvent!) assert.fail('event not set');
+      assert.deepEqual(element.selectedRange, createRangeEvent.detail);
+      assert.isTrue(removeActionBoxStub.called);
+    });
+  });
+
+  suite('selection', () => {
+    let element: GrDiffHighlight;
+    let diff: HTMLElement;
+    let builder: {
+      getContentTdByLineEl: SinonStubbedMember<
+        DiffBuilderInterface['getContentTdByLineEl']
+      >;
+    };
+    let contentStubs;
+
+    setup(async () => {
+      diff = await fixture<HTMLTableElement>(diffTable);
+      builder = {
+        getContentTdByLineEl: sinon.stub(),
+      };
+      element = new GrDiffHighlight();
+      element.init(diff, builder);
+      contentStubs = [];
+      stubElement('gr-selection-action-box', 'placeAbove');
+      stubElement('gr-selection-action-box', 'placeBelow');
+    });
+
+    teardown(() => {
+      fixtureCleanup();
+      element.cleanup();
+      contentStubs = null;
+      document.getSelection()!.removeAllRanges();
+    });
+
+    const stubContent = (line: number, side: Side) => {
+      const contentTd = diff.querySelector(
+        `.${side}.lineNum[data-value="${line}"] ~ .content`
+      );
+      if (!contentTd) assert.fail('content td not found');
+      const contentText = contentTd.querySelector('.contentText');
+      const lineEl =
+        diff.querySelector(`.${side}.lineNum[data-value="${line}"]`) ??
+        undefined;
+      contentStubs.push({
+        lineEl,
+        contentTd,
+        contentText,
+      });
+      builder.getContentTdByLineEl.withArgs(lineEl).returns(contentTd);
+      return contentText;
+    };
+
+    const emulateSelection = (
+      startNode: Node,
+      startOffset: number,
+      endNode: Node,
+      endOffset: number
+    ) => {
+      const selection = document.getSelection();
+      if (!selection) assert.fail('no selection');
+      selection.removeAllRanges();
+      const range = document.createRange();
+      range.setStart(startNode, startOffset);
+      range.setEnd(endNode, endOffset);
+      selection.addRange(range);
+      element.handleSelection(selection, false);
+    };
+
+    test('single first line', () => {
+      const content = stubContent(1, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('multiline starting on first line', () => {
+      const startContent = stubContent(1, Side.RIGHT);
+      const endContent = stubContent(2, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      assert.isTrue(actionBox.positionBelow);
+    });
+
+    test('single line', async () => {
+      const content = stubContent(138, Side.LEFT);
+      sinon.spy(element, 'positionActionBox');
+      if (!content?.firstChild) assert.fail('content first child not found');
+      emulateSelection(content.firstChild, 5, content.firstChild, 12);
+      const actionBox = await waitQueryAndAssert<GrSelectionActionBox>(
+        diff,
+        'gr-selection-action-box'
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 138,
+        start_character: 5,
+        end_line: 138,
+        end_character: 12,
+      });
+      assert.equal(side, Side.LEFT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiline', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      sinon.spy(element, 'positionActionBox');
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.lastChild, 7);
+      const actionBox = diff.querySelector('gr-selection-action-box');
+      if (!actionBox) assert.fail('action box not found');
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+      assert.equal(side, Side.RIGHT);
+      assert.notOk(actionBox.positionBelow);
+    });
+
+    test('multiple ranges aka firefox implementation', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.lastChild) {
+        assert.fail('last child of end content');
+      }
+
+      const startRange = document.createRange();
+      startRange.setStart(startContent.firstChild, 10);
+      startRange.setEnd(startContent.firstChild, 11);
+
+      const endRange = document.createRange();
+      endRange.setStart(endContent.lastChild, 6);
+      endRange.setEnd(endContent.lastChild, 7);
+
+      const getRangeAtStub = sinon.stub();
+      getRangeAtStub
+        .onFirstCall()
+        .returns(startRange)
+        .onSecondCall()
+        .returns(endRange);
+      const selection = {
+        rangeCount: 2,
+        getRangeAt: getRangeAtStub,
+        removeAllRanges: sinon.stub(),
+      } as unknown as Selection;
+      element.handleSelection(selection, false);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 36,
+      });
+    });
+
+    test('multiline grow end highlight over tabs', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 10,
+        end_line: 120,
+        end_character: 2,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('collapsed', () => {
+      const content = stubContent(138, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      emulateSelection(content.firstChild, 5, content.firstChild, 5);
+      const sel = document.getSelection();
+      if (!sel) assert.fail('no selection');
+      assert.isOk(sel.getRangeAt(0).startContainer);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) {
+        assert.fail('content not found');
+      }
+      const hl = content.querySelector('.foo');
+      if (!hl?.firstChild) {
+        assert.fail('first child of hl element not found');
+      }
+      if (!hl?.nextSibling) {
+        assert.fail('next sibling of hl element not found');
+      }
+      emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 8,
+        end_line: 140,
+        end_character: 23,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends inside hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      const hl = content.querySelector('.bar');
+      if (!hl) assert.fail('hl inside content not found');
+      if (!hl.previousSibling) assert.fail('previous sibling not found');
+      if (!hl.firstChild) assert.fail('first child not found');
+      emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 18,
+        end_line: 140,
+        end_character: 27,
+      });
+    });
+
+    test('multiple hl', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('first child not found');
+      const hl = content.querySelectorAll('hl')[4];
+      if (!hl) assert.fail('hl not found');
+      if (!hl.firstChild) assert.fail('first child of hl not found');
+      emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 2,
+        end_line: 140,
+        end_character: 61,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts outside of diff', () => {
+      const contentText = stubContent(140, Side.LEFT);
+      if (!contentText) assert.fail('content not found');
+      if (!contentText.firstChild) assert.fail('child not found');
+      const contentTd = contentText.parentElement;
+      if (!contentTd) assert.fail('content td not found');
+      if (!contentTd.parentElement) assert.fail('parent of td not found');
+
+      emulateSelection(contentTd.parentElement, 0, contentText.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends outside of diff', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('child not found');
+      if (!content.nextElementSibling) assert.fail('sibling not found');
+      if (!content.nextElementSibling.firstChild) {
+        assert.fail('sibling child not found');
+      }
+      emulateSelection(
+        content.nextElementSibling.firstChild,
+        2,
+        content.firstChild,
+        2
+      );
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts and ends on different sides', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      const endContent = stubContent(130, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('starts in comment thread element', () => {
+      const startContent = stubContent(140, Side.LEFT);
+      if (!startContent?.parentElement) {
+        assert.fail('parent el of start content not found');
+      }
+      const comment =
+        startContent.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment not found');
+      }
+      const endContent = stubContent(141, Side.LEFT);
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 83,
+        end_line: 141,
+        end_character: 4,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('ends in comment thread element', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content not found');
+      }
+      if (!content?.parentElement) {
+        assert.fail('parent element of content not found');
+      }
+      const comment = content.parentElement.querySelector('.comment-thread');
+      if (!comment?.firstChild) {
+        assert.fail('first child of comment element not found');
+      }
+      emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 4,
+        end_line: 140,
+        end_character: 83,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) assert.fail('context control not found');
+      const content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content not found');
+      if (!content.firstChild) assert.fail('content child not found');
+      emulateSelection(contextControl, 0, content.firstChild, 7);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('ends in context element', () => {
+      const contextControl = diff
+        .querySelector('.contextControl')!
+        .querySelector('gr-button');
+      if (!contextControl) {
+        assert.fail('context control element not found');
+      }
+      const content = stubContent(141, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      emulateSelection(content.firstChild, 2, contextControl, 1);
+      // TODO (viktard): Select nearest line.
+      assert.isFalse(!!element.selectedRange);
+    });
+
+    test('selection containing context element', () => {
+      const startContent = stubContent(130, Side.RIGHT);
+      const endContent = stubContent(146, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent?.firstChild) {
+        assert.fail('first child of end content not found');
+      }
+      emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 130,
+        start_character: 3,
+        end_line: 146,
+        end_character: 14,
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+
+    test('ends at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content?.firstChild) {
+        assert.fail('first child of content element not found');
+      }
+      const span = content.querySelector('span');
+      if (!span) assert.fail('span element not found');
+      emulateSelection(content.firstChild, 1, span, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 1,
+        end_line: 140,
+        end_character: 51,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('starts at a tab', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1].nextSibling!,
+        1
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 71,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('properly accounts for syntax highlighting', () => {
+      const content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      emulateSelection(
+        content.querySelectorAll('hl')[3],
+        0,
+        content.querySelectorAll('span')[1],
+        0
+      );
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 140,
+        start_character: 51,
+        end_line: 140,
+        end_character: 69,
+      });
+      assert.equal(side, Side.LEFT);
+    });
+
+    test('GrRangeNormalizer.getTextOffset computes text offset', () => {
+      let content = stubContent(140, Side.LEFT);
+      if (!content) assert.fail('content element not found');
+      if (!content.lastChild) assert.fail('last child of content not found');
+      let child = content.lastChild.lastChild;
+      if (!child) assert.fail('last child of last child of content not found');
+      let result = getTextOffset(content, child);
+      assert.equal(result, 75);
+      content = stubContent(146, Side.RIGHT);
+      if (!content) assert.fail('content element not found');
+      child = content.lastChild;
+      if (!child) assert.fail('child element not found');
+      result = getTextOffset(content, child);
+      assert.equal(result, 0);
+    });
+
+    test('fixTripleClickSelection', () => {
+      const startContent = stubContent(119, Side.RIGHT);
+      const endContent = stubContent(120, Side.RIGHT);
+      if (!startContent?.firstChild) {
+        assert.fail('first child of start content not found');
+      }
+      if (!endContent) assert.fail('end content not found');
+      if (!endContent.firstChild) assert.fail('first child not found');
+      emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
+      if (!element.selectedRange) assert.fail('no range selected');
+      const {range, side} = element.selectedRange;
+      assert.deepEqual(range, {
+        start_line: 119,
+        start_character: 0,
+        end_line: 119,
+        end_character: element.getLength(startContent),
+      });
+      assert.equal(side, Side.RIGHT);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-range-normalizer.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-range-normalizer.ts
new file mode 100644
index 0000000..b177e14
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-highlight/gr-range-normalizer.ts
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+export interface NormalizedRange {
+  endContainer: Node;
+  endOffset: number;
+  startContainer: Node;
+  startOffset: number;
+}
+
+/**
+ * Remap DOM range to whole lines of a diff if necessary. If the start or
+ * end containers are DOM elements that are singular pieces of syntax
+ * highlighting, the containers are remapped to the .contentText divs that
+ * contain the entire line of code.
+ *
+ * @param range - the standard DOM selector range.
+ * @return A modified version of the range that correctly accounts
+ *     for syntax highlighting.
+ */
+export function normalize(range: Range): NormalizedRange {
+  const startContainer = getContentTextParent(range.startContainer);
+  const startOffset =
+    range.startOffset + getTextOffset(startContainer, range.startContainer);
+  const endContainer = getContentTextParent(range.endContainer);
+  const endOffset =
+    range.endOffset + getTextOffset(endContainer, range.endContainer);
+  return {
+    startContainer,
+    startOffset,
+    endContainer,
+    endOffset,
+  };
+}
+
+function getContentTextParent(target: Node): Node {
+  if (!target.parentElement) return target;
+
+  let element: Element | null;
+  if (target instanceof Element) {
+    element = target;
+  } else {
+    element = target.parentElement;
+  }
+
+  while (element && !element.classList.contains('contentText')) {
+    if (element.parentElement === null) {
+      return target;
+    }
+    element = element.parentElement;
+  }
+  return element ? element : target;
+}
+
+/**
+ * Gets the character offset of the child within the parent.
+ * Performs a synchronous in-order traversal from top to bottom of the node
+ * element, counting the length of the syntax until child is found.
+ *
+ * @param node The root DOM element to be searched through.
+ * @param child The child element being searched for.
+ */
+// TODO(TS): Only export for test.
+export function getTextOffset(node: Node | null, child: Node): number {
+  let count = 0;
+  let stack = [node];
+  while (stack.length) {
+    const n = stack.pop();
+    if (n === child) {
+      break;
+    }
+    if (n?.childNodes && n.childNodes.length !== 0) {
+      const arr = [];
+      for (const childNode of n.childNodes) {
+        arr.push(childNode);
+      }
+      arr.reverse();
+      stack = stack.concat(arr);
+    } else {
+      count += getLength(n);
+    }
+  }
+  return count;
+}
+
+/**
+ * The DOM API textContent.length calculation is broken when the text
+ * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+ *
+ * @param node A text node.
+ * @return The length of the text.
+ */
+function getLength(node?: Node | null) {
+  return node && node.textContent && node.nodeType !== Node.COMMENT_NODE
+    ? node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length
+    : 0;
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-model/gr-diff-model.ts
new file mode 100644
index 0000000..8fbda14
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-model/gr-diff-model.ts
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Observable} from 'rxjs';
+import {filter} from 'rxjs/operators';
+import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  RenderPreferences,
+} from '../../../api/diff';
+import {define} from '../../../models/dependency';
+import {Model} from '../../../models/model';
+import {isDefined} from '../../../types/types';
+import {select} from '../../../utils/observable-util';
+
+export interface DiffState {
+  diff: DiffInfo;
+  path?: string;
+  renderPrefs: RenderPreferences;
+  diffPrefs: DiffPreferencesInfo;
+}
+
+export const diffModelToken = define<DiffModel>('diff-model');
+
+export class DiffModel extends Model<DiffState | undefined> {
+  readonly diff$: Observable<DiffInfo> = select(
+    this.state$.pipe(filter(isDefined)),
+    diffState => diffState.diff
+  );
+
+  readonly path$: Observable<string | undefined> = select(
+    this.state$.pipe(filter(isDefined)),
+    diffState => diffState.path
+  );
+
+  readonly renderPrefs$: Observable<RenderPreferences> = select(
+    this.state$.pipe(filter(isDefined)),
+    diffState => diffState.renderPrefs
+  );
+
+  readonly diffPrefs$: Observable<DiffPreferencesInfo> = select(
+    this.state$.pipe(filter(isDefined)),
+    diffState => diffState.diffPrefs
+  );
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-processor/gr-diff-processor.ts
new file mode 100644
index 0000000..256dc11
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-processor/gr-diff-processor.ts
@@ -0,0 +1,714 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {GrDiffLine, Highlights} from '../gr-diff/gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from '../gr-diff/gr-diff-group';
+import {DiffContent} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+import {assert, assertIsDefined} from '../../../utils/common-util';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {FILE, GrDiffLineType, LOST, LineNumber} from '../../../api/diff';
+
+const WHOLE_FILE = -1;
+
+// visible for testing
+export interface State {
+  lineNums: {
+    left: number;
+    right: number;
+  };
+  chunkIndex: number;
+}
+
+interface ChunkEnd {
+  offset: number;
+  keyLocation: boolean;
+}
+
+export interface KeyLocations {
+  left: {[key: string]: boolean};
+  right: {[key: string]: boolean};
+}
+
+/**
+ * The maximum size for an addition or removal chunk before it is broken down
+ * into a series of chunks that are this size at most.
+ *
+ * Note: The value of 120 is chosen so that it is larger than the default
+ * asyncThreshold of 64, but feel free to tune this constant to your
+ * performance needs.
+ */
+function calcMaxGroupSize(asyncThreshold?: number): number {
+  if (!asyncThreshold) return 120;
+  return asyncThreshold * 2;
+}
+
+/** Interface for listening to the output of the processor. */
+export interface GroupConsumer {
+  addGroup(group: GrDiffGroup): void;
+  clearGroups(): void;
+}
+
+/**
+ * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
+ *
+ * Glossary:
+ * - "chunk": A single `DiffContent` as returned by the API.
+ * - "group": A single `GrDiffGroup` as used for rendering.
+ * - "common" chunk/group: A chunk/group that should be considered unchanged
+ *   for diffing purposes. This can mean its either actually unchanged, or it
+ *   has only whitespace changes.
+ * - "key location": A line number and side of the diff that should not be
+ *   collapsed e.g. because a comment is attached to it, or because it was
+ *   provided in the URL and thus should be visible
+ * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
+ *   or cannot be collapsed because it contains a key location
+ *
+ * Here a a number of tasks this processor performs:
+ *  - splitting large chunks to allow more granular async rendering
+ *  - adding a group for the "File" pseudo line that file-level comments can
+ *    be attached to
+ *  - replacing common parts of the diff that are outside the user's
+ *    context setting and do not have comments with a group representing the
+ *    "expand context" widget. This may require splitting a chunk/group so
+ *    that the part that is within the context or has comments is shown, while
+ *    the rest is not.
+ */
+export class GrDiffProcessor {
+  context = 3;
+
+  consumer?: GroupConsumer;
+
+  keyLocations: KeyLocations = {left: {}, right: {}};
+
+  asyncThreshold = 64;
+
+  // visible for testing
+  isScrolling?: boolean;
+
+  /** Just for making sure that process() is only called once. */
+  private isStarted = false;
+
+  /** Indicates that processing should be stopped. */
+  private isCancelled = false;
+
+  private resetIsScrollingTask?: DelayedTask;
+
+  private readonly handleWindowScroll = () => {
+    this.isScrolling = true;
+    this.resetIsScrollingTask = debounce(
+      this.resetIsScrollingTask,
+      () => (this.isScrolling = false),
+      50
+    );
+  };
+
+  /**
+   * Asynchronously process the diff chunks into groups. As it processes, it
+   * will splice groups into the `groups` property of the component.
+   *
+   * @return A promise that resolves with an
+   * array of GrDiffGroups when the diff is completely processed.
+   */
+  process(chunks: DiffContent[], isBinary: boolean) {
+    assert(this.isStarted === false, 'diff processor cannot be started twice');
+    this.isStarted = true;
+
+    window.addEventListener('scroll', this.handleWindowScroll);
+
+    assertIsDefined(this.consumer, 'consumer');
+    this.consumer.clearGroups();
+    this.consumer.addGroup(this.makeGroup(LOST));
+    this.consumer.addGroup(this.makeGroup(FILE));
+
+    if (isBinary) return Promise.resolve();
+
+    return new Promise<void>(resolve => {
+      const state = {
+        lineNums: {left: 0, right: 0},
+        chunkIndex: 0,
+      };
+
+      chunks = this.splitLargeChunks(chunks);
+      chunks = this.splitCommonChunksWithKeyLocations(chunks);
+
+      let currentBatch = 0;
+      const nextStep = () => {
+        if (this.isCancelled || state.chunkIndex >= chunks.length) {
+          resolve();
+          return;
+        }
+        if (this.isScrolling) {
+          window.setTimeout(nextStep, 100);
+          return;
+        }
+
+        const stateUpdate = this.processNext(state, chunks);
+        for (const group of stateUpdate.groups) {
+          this.consumer?.addGroup(group);
+          currentBatch += group.lines.length;
+        }
+        state.lineNums.left += stateUpdate.lineDelta.left;
+        state.lineNums.right += stateUpdate.lineDelta.right;
+
+        state.chunkIndex = stateUpdate.newChunkIndex;
+        if (currentBatch >= this.asyncThreshold) {
+          currentBatch = 0;
+          window.setTimeout(nextStep, 1);
+        } else {
+          nextStep.call(this);
+        }
+      };
+
+      nextStep.call(this);
+    }).finally(() => {
+      this.finish();
+    });
+  }
+
+  finish() {
+    this.consumer = undefined;
+    window.removeEventListener('scroll', this.handleWindowScroll);
+  }
+
+  cancel() {
+    this.isCancelled = true;
+    this.finish();
+  }
+
+  /**
+   * Process the next uncollapsible chunk, or the next collapsible chunks.
+   */
+  // visible for testing
+  processNext(state: State, chunks: DiffContent[]) {
+    const firstUncollapsibleChunkIndex = this.firstUncollapsibleChunkIndex(
+      chunks,
+      state.chunkIndex
+    );
+    if (firstUncollapsibleChunkIndex === state.chunkIndex) {
+      const chunk = chunks[state.chunkIndex];
+      return {
+        lineDelta: {
+          left: this.linesLeft(chunk).length,
+          right: this.linesRight(chunk).length,
+        },
+        groups: [
+          this.chunkToGroup(
+            chunk,
+            state.lineNums.left + 1,
+            state.lineNums.right + 1
+          ),
+        ],
+        newChunkIndex: state.chunkIndex + 1,
+      };
+    }
+
+    return this.processCollapsibleChunks(
+      state,
+      chunks,
+      firstUncollapsibleChunkIndex
+    );
+  }
+
+  private linesLeft(chunk: DiffContent) {
+    return chunk.ab || chunk.a || [];
+  }
+
+  private linesRight(chunk: DiffContent) {
+    return chunk.ab || chunk.b || [];
+  }
+
+  private firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
+    let chunkIndex = offset;
+    while (
+      chunkIndex < chunks.length &&
+      this.isCollapsibleChunk(chunks[chunkIndex])
+    ) {
+      chunkIndex++;
+    }
+    return chunkIndex;
+  }
+
+  private isCollapsibleChunk(chunk: DiffContent) {
+    return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
+  }
+
+  /**
+   * Process a stretch of collapsible chunks.
+   *
+   * Outputs up to three groups:
+   * 1) Visible context before the hidden common code, unless it's the
+   * very beginning of the file.
+   * 2) Context hidden behind a context bar, unless empty.
+   * 3) Visible context after the hidden common code, unless it's the very
+   * end of the file.
+   */
+  private processCollapsibleChunks(
+    state: State,
+    chunks: DiffContent[],
+    firstUncollapsibleChunkIndex: number
+  ) {
+    const collapsibleChunks = chunks.slice(
+      state.chunkIndex,
+      firstUncollapsibleChunkIndex
+    );
+    const lineCount = collapsibleChunks.reduce(
+      (sum, chunk) => sum + this.commonChunkLength(chunk),
+      0
+    );
+
+    let groups = this.chunksToGroups(
+      collapsibleChunks,
+      state.lineNums.left + 1,
+      state.lineNums.right + 1
+    );
+
+    const hasSkippedGroup = !!groups.find(g => g.skip);
+    if (this.context !== WHOLE_FILE || hasSkippedGroup) {
+      const contextNumLines = this.context > 0 ? this.context : 0;
+      const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
+      const hiddenEnd =
+        lineCount -
+        (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
+      groups = hideInContextControl(groups, hiddenStart, hiddenEnd);
+    }
+
+    return {
+      lineDelta: {
+        left: lineCount,
+        right: lineCount,
+      },
+      groups,
+      newChunkIndex: firstUncollapsibleChunkIndex,
+    };
+  }
+
+  private commonChunkLength(chunk: DiffContent) {
+    if (chunk.skip) {
+      return chunk.skip;
+    }
+    console.assert(!!chunk.ab || !!chunk.common);
+
+    console.assert(
+      !chunk.a || (!!chunk.b && chunk.a.length === chunk.b.length),
+      'common chunk needs same number of a and b lines: ',
+      chunk
+    );
+    return this.linesLeft(chunk).length;
+  }
+
+  private chunksToGroups(
+    chunks: DiffContent[],
+    offsetLeft: number,
+    offsetRight: number
+  ): GrDiffGroup[] {
+    return chunks.map(chunk => {
+      const group = this.chunkToGroup(chunk, offsetLeft, offsetRight);
+      const chunkLength = this.commonChunkLength(chunk);
+      offsetLeft += chunkLength;
+      offsetRight += chunkLength;
+      return group;
+    });
+  }
+
+  private chunkToGroup(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ): GrDiffGroup {
+    const type =
+      chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
+    const lines = this.linesFromChunk(chunk, offsetLeft, offsetRight);
+    const options = {
+      moveDetails: chunk.move_details,
+      dueToRebase: !!chunk.due_to_rebase,
+      ignoredWhitespaceOnly: !!chunk.common,
+      keyLocation: !!chunk.keyLocation,
+    };
+    if (chunk.skip !== undefined) {
+      return new GrDiffGroup({
+        type,
+        skip: chunk.skip,
+        offsetLeft,
+        offsetRight,
+        ...options,
+      });
+    } else {
+      return new GrDiffGroup({
+        type,
+        lines,
+        ...options,
+      });
+    }
+  }
+
+  private linesFromChunk(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ) {
+    if (chunk.ab) {
+      return chunk.ab.map((row, i) =>
+        this.lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
+      );
+    }
+    let lines: GrDiffLine[] = [];
+    if (chunk.a) {
+      // Avoiding a.push(...b) because that causes callstack overflows for
+      // large b, which can occur when large files are added removed.
+      lines = lines.concat(
+        this.linesFromRows(
+          GrDiffLineType.REMOVE,
+          chunk.a,
+          offsetLeft,
+          chunk.edit_a
+        )
+      );
+    }
+    if (chunk.b) {
+      // Avoiding a.push(...b) because that causes callstack overflows for
+      // large b, which can occur when large files are added removed.
+      lines = lines.concat(
+        this.linesFromRows(
+          GrDiffLineType.ADD,
+          chunk.b,
+          offsetRight,
+          chunk.edit_b
+        )
+      );
+    }
+    return lines;
+  }
+
+  // visible for testing
+  linesFromRows(
+    lineType: GrDiffLineType,
+    rows: string[],
+    offset: number,
+    intralineInfos?: number[][]
+  ): GrDiffLine[] {
+    const grDiffHighlights = intralineInfos
+      ? this.convertIntralineInfos(rows, intralineInfos)
+      : undefined;
+    return rows.map((row, i) =>
+      this.lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
+    );
+  }
+
+  private lineFromRow(
+    type: GrDiffLineType,
+    offsetLeft: number,
+    offsetRight: number,
+    row: string,
+    i: number,
+    highlights?: Highlights[]
+  ): GrDiffLine {
+    const line = new GrDiffLine(type);
+    line.text = row;
+    if (type !== GrDiffLineType.ADD) line.beforeNumber = offsetLeft + i;
+    if (type !== GrDiffLineType.REMOVE) line.afterNumber = offsetRight + i;
+    if (highlights) {
+      line.hasIntralineInfo = true;
+      line.highlights = highlights.filter(hl => hl.contentIndex === i);
+    } else {
+      line.hasIntralineInfo = false;
+    }
+    return line;
+  }
+
+  private makeGroup(number: LineNumber) {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.beforeNumber = number;
+    line.afterNumber = number;
+    return new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [line]});
+  }
+
+  /**
+   * Split chunks into smaller chunks of the same kind.
+   *
+   * This is done to prevent doing too much work on the main thread in one
+   * uninterrupted rendering step, which would make the browser unresponsive.
+   *
+   * Note that in the case of unmodified chunks, we only split chunks if the
+   * context is set to file (because otherwise they are split up further down
+   * the processing into the visible and hidden context), and only split it
+   * into 2 chunks, one max sized one and the rest (for reasons that are
+   * unclear to me).
+   *
+   * @param chunks Chunks as returned from the server
+   * @return Finer grained chunks.
+   */
+  // visible for testing
+  splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
+    const newChunks = [];
+
+    for (const chunk of chunks) {
+      if (!chunk.ab) {
+        for (const subChunk of this.breakdownChunk(chunk)) {
+          newChunks.push(subChunk);
+        }
+        continue;
+      }
+
+      // If the context is set to "whole file", then break down the shared
+      // chunks so they can be rendered incrementally. Note: this is not
+      // enabled for any other context preference because manipulating the
+      // chunks in this way violates assumptions by the context grouper logic.
+      const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
+      if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
+        // Split large shared chunks in two, where the first is the maximum
+        // group size.
+        newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
+        newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
+      } else {
+        newChunks.push(chunk);
+      }
+    }
+    return newChunks;
+  }
+
+  /**
+   * In order to show key locations, such as comments, out of the bounds of
+   * the selected context, treat them as separate chunks within the model so
+   * that the content (and context surrounding it) renders correctly.
+   *
+   * @param chunks DiffContents as returned from server.
+   * @return Finer grained DiffContents.
+   */
+  // visible for testing
+  splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
+    const result = [];
+    let leftLineNum = 1;
+    let rightLineNum = 1;
+
+    for (const chunk of chunks) {
+      // If it isn't a common chunk, append it as-is and update line numbers.
+      if (!chunk.ab && !chunk.skip && !chunk.common) {
+        if (chunk.a) {
+          leftLineNum += chunk.a.length;
+        }
+        if (chunk.b) {
+          rightLineNum += chunk.b.length;
+        }
+        result.push(chunk);
+        continue;
+      }
+
+      if (chunk.common && chunk.a!.length !== chunk.b!.length) {
+        throw new Error(
+          'DiffContent with common=true must always have equal length'
+        );
+      }
+      const numLines = this.commonChunkLength(chunk);
+      const chunkEnds = this.findChunkEndsAtKeyLocations(
+        numLines,
+        leftLineNum,
+        rightLineNum
+      );
+      leftLineNum += numLines;
+      rightLineNum += numLines;
+
+      if (chunk.skip) {
+        result.push({
+          ...chunk,
+          skip: chunk.skip,
+          keyLocation: false,
+        });
+      } else if (chunk.ab) {
+        result.push(
+          ...this.splitAtChunkEnds(chunk.ab, chunkEnds).map(
+            ({lines, keyLocation}) => {
+              return {
+                ...chunk,
+                ab: lines,
+                keyLocation,
+              };
+            }
+          )
+        );
+      } else if (chunk.common) {
+        const aChunks = this.splitAtChunkEnds(chunk.a!, chunkEnds);
+        const bChunks = this.splitAtChunkEnds(chunk.b!, chunkEnds);
+        result.push(
+          ...aChunks.map(({lines, keyLocation}, i) => {
+            return {
+              ...chunk,
+              a: lines,
+              b: bChunks[i].lines,
+              keyLocation,
+            };
+          })
+        );
+      }
+    }
+
+    return result;
+  }
+
+  /**
+   * @return Offsets of the new chunk ends, including whether it's a key
+   * location.
+   */
+  private findChunkEndsAtKeyLocations(
+    numLines: number,
+    leftOffset: number,
+    rightOffset: number
+  ): ChunkEnd[] {
+    const result = [];
+    let lastChunkEnd = 0;
+    for (let i = 0; i < numLines; i++) {
+      // If this line should not be collapsed.
+      if (
+        this.keyLocations[Side.LEFT][leftOffset + i] ||
+        this.keyLocations[Side.RIGHT][rightOffset + i]
+      ) {
+        // If any lines have been accumulated into the chunk leading up to
+        // this non-collapse line, then add them as a chunk and start a new
+        // one.
+        if (i > lastChunkEnd) {
+          result.push({offset: i, keyLocation: false});
+          lastChunkEnd = i;
+        }
+
+        // Add the non-collapse line as its own chunk.
+        result.push({offset: i + 1, keyLocation: true});
+      }
+    }
+
+    if (numLines > lastChunkEnd) {
+      result.push({offset: numLines, keyLocation: false});
+    }
+
+    return result;
+  }
+
+  private splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
+    const result = [];
+    let lastChunkEndOffset = 0;
+    for (const {offset, keyLocation} of chunkEnds) {
+      if (lastChunkEndOffset === offset) continue;
+      result.push({
+        lines: lines.slice(lastChunkEndOffset, offset),
+        keyLocation,
+      });
+      lastChunkEndOffset = offset;
+    }
+    return result;
+  }
+
+  /**
+   * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
+   * for rendering.
+   */
+  // visible for testing
+  convertIntralineInfos(
+    rows: string[],
+    intralineInfos: number[][]
+  ): Highlights[] {
+    // +1 to account for the \n that is not part of the rows passed here
+    const lineLengths = rows.map(r => GrAnnotation.getStringLength(r) + 1);
+
+    let rowIndex = 0;
+    let idx = 0;
+    const normalized = [];
+    for (const [skipLength, markLength] of intralineInfos) {
+      let lineLength = lineLengths[rowIndex];
+      let j = 0;
+      while (j < skipLength) {
+        if (idx === lineLength) {
+          idx = 0;
+          lineLength = lineLengths[++rowIndex];
+          continue;
+        }
+        idx++;
+        j++;
+      }
+      let lineHighlight: Highlights = {
+        contentIndex: rowIndex,
+        startIndex: idx,
+      };
+
+      j = 0;
+      while (lineLength && j < markLength) {
+        if (idx === lineLength) {
+          idx = 0;
+          lineLength = lineLengths[++rowIndex];
+          normalized.push(lineHighlight);
+          lineHighlight = {
+            contentIndex: rowIndex,
+            startIndex: idx,
+          };
+          continue;
+        }
+        idx++;
+        j++;
+      }
+      lineHighlight.endIndex = idx;
+      normalized.push(lineHighlight);
+    }
+    return normalized;
+  }
+
+  /**
+   * If a group is an addition or a removal, break it down into smaller groups
+   * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
+   * or a delta it is returned as the single element of the result array.
+   */
+  // visible for testing
+  breakdownChunk(chunk: DiffContent): DiffContent[] {
+    let key: 'a' | 'b' | 'ab' | null = null;
+    const {a, b, ab, move_details} = chunk;
+    if (a?.length && !b?.length) {
+      key = 'a';
+    } else if (b?.length && !a?.length) {
+      key = 'b';
+    } else if (ab?.length) {
+      key = 'ab';
+    }
+
+    // Move chunks should not be divided because of move label
+    // positioned in the top of the chunk
+    if (!key || move_details) {
+      return [chunk];
+    }
+
+    const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
+    return this.breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
+      const subChunk: DiffContent = {};
+      subChunk[key!] = subChunkLines;
+      if (chunk.due_to_rebase) {
+        subChunk.due_to_rebase = true;
+      }
+      if (chunk.move_details) {
+        subChunk.move_details = chunk.move_details;
+      }
+      return subChunk;
+    });
+  }
+
+  /**
+   * Given an array and a size, return an array of arrays where no inner array
+   * is larger than that size, preserving the original order.
+   */
+  // visible for testing
+  breakdown<T>(array: T[], size: number): T[][] {
+    if (!array.length) {
+      return [];
+    }
+    if (array.length < size) {
+      return [array];
+    }
+
+    const head = array.slice(0, array.length - size);
+    const tail = array.slice(array.length - size);
+
+    return this.breakdown(head, size).concat([tail]);
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-processor/gr-diff-processor_test.ts
new file mode 100644
index 0000000..335f0d0
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-processor/gr-diff-processor_test.ts
@@ -0,0 +1,1136 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-processor';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffProcessor, State} from './gr-diff-processor';
+import {DiffContent} from '../../../types/diff';
+import {assert} from '@open-wc/testing';
+import {FILE, GrDiffLineType} from '../../../api/diff';
+
+suite('gr-diff-processor tests', () => {
+  const WHOLE_FILE = -1;
+  const loremIpsum =
+    'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+    'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
+    'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
+    'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
+    'fugit assum per.';
+
+  let element: GrDiffProcessor;
+  let groups: GrDiffGroup[];
+
+  setup(() => {});
+
+  suite('not logged in', () => {
+    setup(() => {
+      groups = [];
+      element = new GrDiffProcessor();
+      element.consumer = {
+        addGroup(group: GrDiffGroup) {
+          groups.push(group);
+        },
+        clearGroups() {
+          groups = [];
+        },
+      };
+      element.context = 4;
+    });
+
+    test('process loaded content', () => {
+      const content: DiffContent[] = [
+        {
+          ab: ['<!DOCTYPE html>', '<meta charset="utf-8">'],
+        },
+        {
+          a: ['  Welcome ', '  to the wooorld of tomorrow!'],
+          b: ['  Hello, world!'],
+        },
+        {
+          ab: [
+            'Leela: This is the only place the ship can’t hear us, so ',
+            'everyone pretend to shower.',
+            'Fry: Same as every day. Got it.',
+          ],
+        },
+      ];
+
+      return element.process(content, false).then(() => {
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+        assert.equal(groups.length, 4);
+
+        let group = groups[0];
+        assert.equal(group.type, GrDiffGroupType.BOTH);
+        assert.equal(group.lines.length, 1);
+        assert.equal(group.lines[0].text, '');
+        assert.equal(group.lines[0].beforeNumber, FILE);
+        assert.equal(group.lines[0].afterNumber, FILE);
+
+        group = groups[1];
+        assert.equal(group.type, GrDiffGroupType.BOTH);
+        assert.equal(group.lines.length, 2);
+
+        function beforeNumberFn(l: GrDiffLine) {
+          return l.beforeNumber;
+        }
+        function afterNumberFn(l: GrDiffLine) {
+          return l.afterNumber;
+        }
+        function textFn(l: GrDiffLine) {
+          return l.text;
+        }
+
+        assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
+        assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
+        assert.deepEqual(group.lines.map(textFn), [
+          '<!DOCTYPE html>',
+          '<meta charset="utf-8">',
+        ]);
+
+        group = groups[2];
+        assert.equal(group.type, GrDiffGroupType.DELTA);
+        assert.equal(group.lines.length, 3);
+        assert.equal(group.adds.length, 1);
+        assert.equal(group.removes.length, 2);
+        assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
+        assert.deepEqual(group.adds.map(afterNumberFn), [3]);
+        assert.deepEqual(group.removes.map(textFn), [
+          '  Welcome ',
+          '  to the wooorld of tomorrow!',
+        ]);
+        assert.deepEqual(group.adds.map(textFn), ['  Hello, world!']);
+
+        group = groups[3];
+        assert.equal(group.type, GrDiffGroupType.BOTH);
+        assert.equal(group.lines.length, 3);
+        assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
+        assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
+        assert.deepEqual(group.lines.map(textFn), [
+          'Leela: This is the only place the ship can’t hear us, so ',
+          'everyone pretend to shower.',
+          'Fry: Same as every day. Got it.',
+        ]);
+      });
+    });
+
+    test('first group is for file', () => {
+      const content = [{b: ['foo']}];
+
+      return element.process(content, false).then(() => {
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+        assert.equal(groups[0].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[0].lines.length, 1);
+        assert.equal(groups[0].lines[0].text, '');
+        assert.equal(groups[0].lines[0].beforeNumber, FILE);
+        assert.equal(groups[0].lines[0].afterNumber, FILE);
+      });
+    });
+
+    suite('context groups', () => {
+      test('at the beginning, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {
+            ab: Array.from<string>({length: 100}).fill(
+              'all work and no play make jack a dull boy'
+            ),
+          },
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+
+          assert.equal(groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.instanceOf(groups[1].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[1].contextGroups[0].lines.length, 90);
+          for (const l of groups[1].contextGroups[0].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+        });
+      });
+
+      test('at the beginning with skip chunks', async () => {
+        element.context = 10;
+        const content = [
+          {
+            ab: Array.from<string>({length: 20}).fill(
+              'all work and no play make jack a dull boy'
+            ),
+          },
+          {skip: 43900},
+          {ab: Array.from<string>({length: 30}).fill('some other content')},
+          {a: ['some other content']},
+        ];
+
+        await element.process(content, false);
+
+        groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+        // group[0] is the file group
+
+        const commonGroup = groups[1];
+
+        // Hidden context before
+        assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+        assert.equal(commonGroup.contextGroups[0].lines.length, 20);
+        for (const l of commonGroup.contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
+
+        // Skipped group
+        const skipGroup = commonGroup.contextGroups[1];
+        assert.equal(skipGroup.skip, 43900);
+        const expectedRange = {
+          left: {start_line: 21, end_line: 43920},
+          right: {start_line: 21, end_line: 43920},
+        };
+        assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+        // Hidden context after
+        assert.equal(commonGroup.contextGroups[2].lines.length, 20);
+        for (const l of commonGroup.contextGroups[2].lines) {
+          assert.equal(l.text, 'some other content');
+        }
+
+        // Displayed lines
+        assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+        assert.equal(groups[2].lines.length, 10);
+        for (const l of groups[2].lines) {
+          assert.equal(l.text, 'some other content');
+        }
+      });
+
+      test('at the beginning, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {
+            ab: Array.from<string>({length: 5}).fill(
+              'all work and no play make jack a dull boy'
+            ),
+          },
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+
+          assert.equal(groups[1].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[1].lines.length, 5);
+          for (const l of groups[1].lines) {
+            assert.equal(l.text, 'all work and no play make jack a dull boy');
+          }
+        });
+      });
+
+      test('at the end, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: Array.from<string>({length: 100}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].contextGroups[0].lines.length, 90);
+          for (const l of groups[3].contextGroups[0].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('at the end, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: Array.from<string>({length: 5}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 5);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('for interleaved ab and common: true chunks', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+          {
+            a: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+            b: Array.from<string>({length: 3}).fill(
+              '  all work and no play make jill a dull girl'
+            ),
+            common: true,
+          },
+          {
+            ab: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+          {
+            a: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+            b: Array.from<string>({length: 3}).fill(
+              '  all work and no play make jill a dull girl'
+            ),
+            common: true,
+          },
+          {
+            ab: Array.from<string>({length: 3}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          // The first three interleaved chunks are completely shown because
+          // they are part of the context (3 * 3 <= 10)
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 3);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroupType.DELTA);
+          assert.equal(groups[3].lines.length, 6);
+          assert.equal(groups[3].adds.length, 3);
+          assert.equal(groups[3].removes.length, 3);
+          for (const l of groups[3].removes) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[3].adds) {
+            assert.equal(
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
+          }
+
+          assert.equal(groups[4].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[4].lines.length, 3);
+          for (const l of groups[4].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+
+          // The next chunk is partially shown, so it results in two groups
+
+          assert.equal(groups[5].type, GrDiffGroupType.DELTA);
+          assert.equal(groups[5].lines.length, 2);
+          assert.equal(groups[5].adds.length, 1);
+          assert.equal(groups[5].removes.length, 1);
+          for (const l of groups[5].removes) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[5].adds) {
+            assert.equal(
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
+          }
+
+          assert.equal(groups[6].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.equal(groups[6].contextGroups.length, 2);
+
+          assert.equal(groups[6].contextGroups[0].lines.length, 4);
+          assert.equal(groups[6].contextGroups[0].removes.length, 2);
+          assert.equal(groups[6].contextGroups[0].adds.length, 2);
+          for (const l of groups[6].contextGroups[0].removes) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+          for (const l of groups[6].contextGroups[0].adds) {
+            assert.equal(
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
+          }
+
+          // The final chunk is completely hidden
+          assert.equal(groups[6].contextGroups[1].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[6].contextGroups[1].lines.length, 3);
+          for (const l of groups[6].contextGroups[1].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('in the middle, larger than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: Array.from<string>({length: 100}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 10);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[3].contextGroups[0].lines.length, 80);
+          for (const l of groups[3].contextGroups[0].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+
+          assert.equal(groups[4].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[4].lines.length, 10);
+          for (const l of groups[4].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+
+      test('in the middle, smaller than context', () => {
+        element.context = 10;
+        const content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {
+            ab: Array.from<string>({length: 5}).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
+          {a: ['all work and no play make andybons a dull boy']},
+        ];
+
+        return element.process(content, false).then(() => {
+          groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+          // group[0] is the file group
+          // group[1] is the "a" group
+
+          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[2].lines.length, 5);
+          for (const l of groups[2].lines) {
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
+          }
+        });
+      });
+    });
+
+    test('in the middle with skip chunks', async () => {
+      element.context = 10;
+      const content = [
+        {a: ['all work and no play make andybons a dull boy']},
+        {
+          ab: Array.from<string>({length: 20}).fill(
+            'all work and no play make jill a dull girl'
+          ),
+        },
+        {skip: 60},
+        {
+          ab: Array.from<string>({length: 20}).fill(
+            'all work and no play make jill a dull girl'
+          ),
+        },
+        {a: ['all work and no play make andybons a dull boy']},
+      ];
+
+      await element.process(content, false);
+
+      groups.shift(); // remove portedThreadsWithoutRangeGroup
+
+      // group[0] is the file group
+      // group[1] is the chunk with a
+      // group[2] is the displayed part of ab before
+
+      const commonGroup = groups[3];
+
+      // Hidden context before
+      assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+      assert.equal(commonGroup.contextGroups[0].lines.length, 10);
+      for (const l of commonGroup.contextGroups[0].lines) {
+        assert.equal(l.text, 'all work and no play make jill a dull girl');
+      }
+
+      // Skipped group
+      const skipGroup = commonGroup.contextGroups[1];
+      assert.equal(skipGroup.skip, 60);
+      const expectedRange = {
+        left: {start_line: 22, end_line: 81},
+        right: {start_line: 21, end_line: 80},
+      };
+      assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+      // Hidden context after
+      assert.equal(commonGroup.contextGroups[2].lines.length, 10);
+      for (const l of commonGroup.contextGroups[2].lines) {
+        assert.equal(l.text, 'all work and no play make jill a dull girl');
+      }
+      // group[4] is the displayed part of the second ab
+    });
+
+    test('works with skip === 0', async () => {
+      element.context = 3;
+      const content = [
+        {
+          skip: 0,
+        },
+        {
+          b: [
+            '/**',
+            ' * @license',
+            ' * Copyright 2015 Google LLC',
+            ' * SPDX-License-Identifier: Apache-2.0',
+            ' */',
+            "import '../../../test/common-test-setup';",
+          ],
+        },
+      ];
+      await element.process(content, false);
+    });
+
+    test('break up common diff chunks', () => {
+      element.keyLocations = {
+        left: {1: true},
+        right: {10: true},
+      };
+
+      const content = [
+        {
+          ab: [
+            'copy',
+            '',
+            'asdf',
+            'qwer',
+            'zxcv',
+            '',
+            'http',
+            '',
+            'vbnm',
+            'dfgh',
+            'yuio',
+            'sdfg',
+            '1234',
+          ],
+        },
+      ];
+      const result = element.splitCommonChunksWithKeyLocations(content);
+      assert.deepEqual(result, [
+        {
+          ab: ['copy'],
+          keyLocation: true,
+        },
+        {
+          ab: ['', 'asdf', 'qwer', 'zxcv', '', 'http', '', 'vbnm'],
+          keyLocation: false,
+        },
+        {
+          ab: ['dfgh'],
+          keyLocation: true,
+        },
+        {
+          ab: ['yuio', 'sdfg', '1234'],
+          keyLocation: false,
+        },
+      ]);
+    });
+
+    test('breaks down shared chunks w/ whole-file', () => {
+      const maxGroupSize = 128;
+      const size = maxGroupSize * 2 + 5;
+      const ab = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      const content = [{ab}];
+      element.context = -1;
+      const result = element.splitLargeChunks(content);
+      assert.equal(result.length, 2);
+      assert.deepEqual(result[0].ab, content[0].ab.slice(0, maxGroupSize));
+      assert.deepEqual(result[1].ab, content[0].ab.slice(maxGroupSize));
+    });
+
+    test('breaks down added chunks', () => {
+      const maxGroupSize = 128;
+      const size = maxGroupSize * 2 + 5;
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      element.context = 5;
+      const splitContent = element
+        .splitLargeChunks([{a: [], b: content}])
+        .map(r => r.b);
+      assert.equal(splitContent.length, 3);
+      assert.deepEqual(splitContent[0], content.slice(0, 5));
+      assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
+      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
+    });
+
+    test('breaks down removed chunks', () => {
+      const maxGroupSize = 128;
+      const size = maxGroupSize * 2 + 5;
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      element.context = 5;
+      const splitContent = element
+        .splitLargeChunks([{a: content, b: []}])
+        .map(r => r.a);
+      assert.equal(splitContent.length, 3);
+      assert.deepEqual(splitContent[0], content.slice(0, 5));
+      assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
+      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
+    });
+
+    test('does not break down moved chunks', () => {
+      const size = 120 * 2 + 5;
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      element.context = 5;
+      const splitContent = element
+        .splitLargeChunks([
+          {
+            a: content,
+            b: [],
+            move_details: {changed: false, range: {start: 1, end: 1}},
+          },
+        ])
+        .map(r => r.a);
+      assert.equal(splitContent.length, 1);
+      assert.deepEqual(splitContent[0], content);
+    });
+
+    test('does not break-down common chunks w/ context', () => {
+      const ab = Array(75)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      const content = [{ab}];
+      element.context = 4;
+      const result = element.splitCommonChunksWithKeyLocations(content);
+      assert.equal(result.length, 1);
+      assert.deepEqual(result[0].ab, content[0].ab);
+      assert.isFalse(result[0].keyLocation);
+    });
+
+    test('intraline normalization', () => {
+      // The content and highlights are in the format returned by the Gerrit
+      // REST API.
+      let content = [
+        '      <section class="summary">',
+        '        <gr-formatted-text content="' +
+          '[[_computeCurrentRevisionMessage(change)]]"></gr-formatted-text>',
+        '      </section>',
+      ];
+      let highlights = [
+        [31, 34],
+        [42, 26],
+      ];
+
+      let results = element.convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 31,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+          endIndex: 33,
+        },
+        {
+          contentIndex: 1,
+          endIndex: 101,
+          startIndex: 75,
+        },
+      ]);
+      const lines = element.linesFromRows(
+        GrDiffLineType.BOTH,
+        content,
+        0,
+        highlights
+      );
+      assert.equal(lines.length, 3);
+      assert.isTrue(lines[0].hasIntralineInfo);
+      assert.equal(lines[0].highlights.length, 1);
+      assert.isTrue(lines[1].hasIntralineInfo);
+      assert.equal(lines[1].highlights.length, 2);
+      assert.isTrue(lines[2].hasIntralineInfo);
+      assert.equal(lines[2].highlights.length, 0);
+
+      content = [
+        '        this._path = value.path;',
+        '',
+        '        // When navigating away from the page, there is a ' +
+          'possibility that the',
+        '        // patch number is no longer a part of the URL ' +
+          '(say when navigating to',
+        '        // the top-level change info view) and therefore ' +
+          'undefined in `params`.',
+        '        if (!this._patchRange.patchNum) {',
+      ];
+      highlights = [
+        [14, 17],
+        [11, 70],
+        [12, 67],
+        [12, 67],
+        [14, 29],
+      ];
+      results = element.convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 14,
+          endIndex: 31,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 8,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 3,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 4,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 5,
+          startIndex: 12,
+          endIndex: 41,
+        },
+      ]);
+
+      content = ['🙈 a', '🙉 b', '🙊 c'];
+      highlights = [[2, 7]];
+      results = element.convertIntralineInfos(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 2,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 0,
+          endIndex: 1,
+        },
+      ]);
+    });
+
+    test('isScrolling paused', () => {
+      const content = Array(200).fill({ab: ['', '']});
+      element.isScrolling = true;
+      element.process(content, false);
+      // Just the FILE and LOST groups.
+      assert.equal(groups.length, 2);
+    });
+
+    test('isScrolling unpaused', () => {
+      const content = Array(200).fill({ab: ['', '']});
+      element.isScrolling = false;
+      element.process(content, false);
+      // More groups have been processed. How many does not matter here.
+      assert.isAtLeast(groups.length, 3);
+    });
+
+    test('image diffs', () => {
+      const content = Array(200).fill({ab: ['', '']});
+      element.process(content, true);
+      assert.equal(groups.length, 2);
+
+      // Image diffs don't process content, just the 'FILE' line.
+      assert.equal(groups[0].lines.length, 1);
+    });
+
+    suite('processNext', () => {
+      let rows: string[];
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
+      });
+
+      test('WHOLE_FILE', () => {
+        element.context = WHOLE_FILE;
+        const state: State = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+
+        // Results in one, uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1);
+        assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+        assert.equal(result.groups[0].lines.length, rows.length);
+
+        // Line numbers are set correctly.
+        assert.equal(
+          result.groups[0].lines[0].beforeNumber,
+          state.lineNums.left + 1
+        );
+        assert.equal(
+          result.groups[0].lines[0].afterNumber,
+          state.lineNums.right + 1
+        );
+
+        assert.equal(
+          result.groups[0].lines[rows.length - 1].beforeNumber,
+          state.lineNums.left + rows.length
+        );
+        assert.equal(
+          result.groups[0].lines[rows.length - 1].afterNumber,
+          state.lineNums.right + rows.length
+        );
+      });
+
+      test('WHOLE_FILE with skip chunks still get collapsed', () => {
+        element.context = WHOLE_FILE;
+        const lineNums = {left: 10, right: 100};
+        const state = {
+          lineNums,
+          chunkIndex: 1,
+        };
+        const skip = 10000;
+        const chunks = [{a: ['foo']}, {skip}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+        // Results in one, uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1);
+        assert.equal(result.groups[0].type, GrDiffGroupType.CONTEXT_CONTROL);
+
+        // Skip and ab group are hidden in the same context control
+        assert.equal(result.groups[0].contextGroups.length, 2);
+        const [skippedGroup, abGroup] = result.groups[0].contextGroups;
+
+        // Line numbers are set correctly.
+        assert.deepEqual(skippedGroup.lineRange, {
+          left: {
+            start_line: lineNums.left + 1,
+            end_line: lineNums.left + skip,
+          },
+          right: {
+            start_line: lineNums.right + 1,
+            end_line: lineNums.right + skip,
+          },
+        });
+
+        assert.deepEqual(abGroup.lineRange, {
+          left: {
+            start_line: lineNums.left + skip + 1,
+            end_line: lineNums.left + skip + rows.length,
+          },
+          right: {
+            start_line: lineNums.right + skip + 1,
+            end_line: lineNums.right + skip + rows.length,
+          },
+        });
+      });
+
+      test('with context', () => {
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+        const expectedCollapseSize = rows.length - 2 * element.context;
+
+        assert.equal(result.groups.length, 3, 'Results in three groups');
+
+        // The first and last are uncollapsed context, whereas the middle has
+        // a single context-control line.
+        assert.equal(result.groups[0].lines.length, element.context);
+        assert.equal(result.groups[2].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(
+          result.groups[1].contextGroups[0].lines.length,
+          expectedCollapseSize
+        );
+      });
+
+      test('first', () => {
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+        const expectedCollapseSize = rows.length - element.context;
+
+        assert.equal(result.groups.length, 2, 'Results in two groups');
+
+        // Only the first group is collapsed.
+        assert.equal(result.groups[1].lines.length, element.context);
+
+        // The collapsed group has the hidden lines as its context group.
+        assert.equal(
+          result.groups[0].contextGroups[0].lines.length,
+          expectedCollapseSize
+        );
+      });
+
+      test('few-rows', () => {
+        // Only ten rows.
+        rows = rows.slice(0, 10);
+        element.context = 10;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 0,
+        };
+        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      test('no single line collapse', () => {
+        rows = rows.slice(0, 7);
+        element.context = 3;
+        const state = {
+          lineNums: {left: 10, right: 100},
+          chunkIndex: 1,
+        };
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
+
+        // Results in one uncollapsed group with all rows.
+        assert.equal(result.groups.length, 1, 'Results in one group');
+        assert.equal(result.groups[0].lines.length, rows.length);
+      });
+
+      suite('with key location', () => {
+        let state: State;
+        let chunks: DiffContent[];
+
+        setup(() => {
+          state = {
+            lineNums: {left: 10, right: 100},
+            chunkIndex: 0,
+          };
+          element.context = 10;
+          chunks = [{ab: rows}, {ab: ['foo'], keyLocation: true}, {ab: rows}];
+        });
+
+        test('context before', () => {
+          state.chunkIndex = 0;
+          const result = element.processNext(state, chunks);
+
+          // The first chunk is split into two groups:
+          // 1) A context-control, hiding everything but the context before
+          //    the key location.
+          // 2) The context before the key location.
+          // The key location is not processed in this call to processNext
+          assert.equal(result.groups.length, 2);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(
+            result.groups[0].contextGroups[0].lines.length,
+            rows.length - element.context
+          );
+          assert.equal(result.groups[1].lines.length, element.context);
+        });
+
+        test('key location itself', () => {
+          state.chunkIndex = 1;
+          const result = element.processNext(state, chunks);
+
+          // The second chunk results in a single group, that is just the
+          // line with the key location
+          assert.equal(result.groups.length, 1);
+          assert.equal(result.groups[0].lines.length, 1);
+          assert.equal(result.lineDelta.left, 1);
+          assert.equal(result.lineDelta.right, 1);
+        });
+
+        test('context after', () => {
+          state.chunkIndex = 2;
+          const result = element.processNext(state, chunks);
+
+          // The last chunk is split into two groups:
+          // 1) The context after the key location.
+          // 1) A context-control, hiding everything but the context after the
+          //    key location.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].lines.length, element.context);
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(
+            result.groups[1].contextGroups[0].lines.length,
+            rows.length - element.context
+          );
+        });
+      });
+    });
+
+    suite('gr-diff-processor helpers', () => {
+      let rows: string[];
+
+      setup(() => {
+        rows = loremIpsum.split(' ');
+      });
+
+      test('linesFromRows', () => {
+        const startLineNum = 10;
+        let result = element.linesFromRows(
+          GrDiffLineType.ADD,
+          rows,
+          startLineNum + 1
+        );
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLineType.ADD);
+        assert.notOk(result[0].hasIntralineInfo);
+        assert.equal(result[0].afterNumber, startLineNum + 1);
+        assert.notOk(result[0].beforeNumber);
+        assert.equal(
+          result[result.length - 1].afterNumber,
+          startLineNum + rows.length
+        );
+        assert.notOk(result[result.length - 1].beforeNumber);
+
+        result = element.linesFromRows(
+          GrDiffLineType.REMOVE,
+          rows,
+          startLineNum + 1
+        );
+
+        assert.equal(result.length, rows.length);
+        assert.equal(result[0].type, GrDiffLineType.REMOVE);
+        assert.notOk(result[0].hasIntralineInfo);
+        assert.equal(result[0].beforeNumber, startLineNum + 1);
+        assert.notOk(result[0].afterNumber);
+        assert.equal(
+          result[result.length - 1].beforeNumber,
+          startLineNum + rows.length
+        );
+        assert.notOk(result[result.length - 1].afterNumber);
+      });
+    });
+
+    suite('breakdown*', () => {
+      test('breakdownChunk breaks down additions', () => {
+        const breakdownSpy = sinon.spy(element, 'breakdown');
+        const chunk = {b: ['blah', 'blah', 'blah']};
+        const result = element.breakdownChunk(chunk);
+        assert.deepEqual(result, [chunk]);
+        assert.isTrue(breakdownSpy.called);
+      });
+
+      test('breakdownChunk keeps due_to_rebase for broken down additions', () => {
+        sinon.spy(element, 'breakdown');
+        const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
+        const result = element.breakdownChunk(chunk);
+        for (const subResult of result) {
+          assert.isTrue(subResult.due_to_rebase);
+        }
+      });
+
+      test('breakdown common case', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
+          ' '
+        );
+        const size = 3;
+
+        const result = element.breakdown(array, size);
+
+        for (const subResult of result) {
+          assert.isAtMost(subResult.length, size);
+        }
+        const flattened = result.reduce((a, b) => a.concat(b), []);
+        assert.deepEqual(flattened, array);
+      });
+
+      test('breakdown smaller than size', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
+          ' '
+        );
+        const size = 10;
+        const expected = [array];
+
+        const result = element.breakdown(array, size);
+
+        assert.deepEqual(result, expected);
+      });
+
+      test('breakdown empty', () => {
+        const array: string[] = [];
+        const size = 10;
+        const expected: string[][] = [];
+
+        const result = element.breakdown(array, size);
+
+        assert.deepEqual(result, expected);
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-selection/gr-diff-selection.ts
new file mode 100644
index 0000000..a9ec6a2
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-selection/gr-diff-selection.ts
@@ -0,0 +1,247 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../styles/shared-styles';
+import {normalize} from '../gr-diff-highlight/gr-range-normalizer';
+import {
+  descendedFromClass,
+  parentWithClass,
+  querySelectorAll,
+} from '../../../utils/dom-util';
+import {DiffInfo} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {
+  getLineElByChild,
+  getSide,
+  getSideByLineEl,
+  isThreadEl,
+} from '../../diff/gr-diff/gr-diff-utils';
+
+/**
+ * Possible CSS classes indicating the state of selection. Dynamically added/
+ * removed based on where the user clicks within the diff.
+ */
+const SelectionClass = {
+  COMMENT: 'selected-comment',
+  LEFT: 'selected-left',
+  RIGHT: 'selected-right',
+  BLAME: 'selected-blame',
+};
+
+function selectionClassForSide(side?: Side) {
+  return side === Side.LEFT ? SelectionClass.LEFT : SelectionClass.RIGHT;
+}
+
+interface LinesCache {
+  left: string[] | null;
+  right: string[] | null;
+}
+
+function getNewCache(): LinesCache {
+  return {left: null, right: null};
+}
+
+export class GrDiffSelection {
+  // visible for testing
+  diff?: DiffInfo;
+
+  // visible for testing
+  diffTable?: HTMLElement;
+
+  // visible for testing
+  linesCache: LinesCache = getNewCache();
+
+  init(diff: DiffInfo, diffTable: HTMLElement) {
+    this.cleanup();
+    this.diff = diff;
+    this.diffTable = diffTable;
+    this.diffTable.classList.add(SelectionClass.RIGHT);
+    this.diffTable.addEventListener('copy', this.handleCopy);
+    this.diffTable.addEventListener('mousedown', this.handleDown);
+    this.linesCache = getNewCache();
+  }
+
+  cleanup() {
+    if (!this.diffTable) return;
+    this.diffTable.removeEventListener('copy', this.handleCopy);
+    this.diffTable.removeEventListener('mousedown', this.handleDown);
+  }
+
+  handleDown = (e: Event) => {
+    const target = e.target;
+    if (!(target instanceof Element)) return;
+
+    const commentEl = parentWithClass(target, 'comment-thread', this.diffTable);
+    if (commentEl && isThreadEl(commentEl)) {
+      this.setClasses([
+        SelectionClass.COMMENT,
+        selectionClassForSide(getSide(commentEl)),
+      ]);
+      return;
+    }
+
+    const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
+    if (blameSelected) {
+      this.setClasses([SelectionClass.BLAME]);
+      return;
+    }
+
+    // This works for both, the content and the line number cells.
+    const lineEl = getLineElByChild(target);
+    if (lineEl) {
+      this.setClasses([selectionClassForSide(getSideByLineEl(lineEl))]);
+      return;
+    }
+  };
+
+  /**
+   * Set the provided list of classes on the element, to the exclusion of all
+   * other SelectionClass values.
+   */
+  setClasses(targetClasses: string[]) {
+    if (!this.diffTable) return;
+    // Remove any selection classes that do not belong.
+    for (const className of Object.values(SelectionClass)) {
+      if (!targetClasses.includes(className)) {
+        this.diffTable.classList.remove(className);
+      }
+    }
+    // Add new selection classes iff they are not already present.
+    for (const targetClass of targetClasses) {
+      if (!this.diffTable.classList.contains(targetClass)) {
+        this.diffTable.classList.add(targetClass);
+      }
+    }
+  }
+
+  handleCopy = (e: ClipboardEvent) => {
+    const target = e.composedPath()[0];
+    if (!(target instanceof Element)) return;
+    if (target instanceof HTMLTextAreaElement) return;
+    if (!descendedFromClass(target, 'diff-row', this.diffTable)) return;
+    if (!this.diffTable) return;
+    if (this.diffTable.classList.contains(SelectionClass.COMMENT)) return;
+
+    const lineEl = getLineElByChild(target);
+    if (!lineEl) return;
+    const side = getSideByLineEl(lineEl);
+    const text = this.getSelectedText(side);
+    if (text && e.clipboardData) {
+      e.clipboardData.setData('Text', text);
+      e.preventDefault();
+    }
+  };
+
+  getSelection() {
+    const diffHosts = querySelectorAll(document.body, 'gr-diff');
+    if (!diffHosts.length) return document.getSelection();
+
+    const curDiffHost = diffHosts.find(diffHost => {
+      if (!diffHost?.shadowRoot?.getSelection) return false;
+      const selection = diffHost.shadowRoot.getSelection();
+      // Pick the one with valid selection:
+      // https://developer.mozilla.org/en-US/docs/Web/API/Selection/type
+      return selection && selection.type !== 'None';
+    });
+
+    return curDiffHost?.shadowRoot?.getSelection
+      ? curDiffHost.shadowRoot.getSelection()
+      : document.getSelection();
+  }
+
+  /**
+   * Get the text of the current selection. If commentSelected is
+   * true, it returns only the text of comments within the selection.
+   * Otherwise it returns the text of the selected diff region.
+   *
+   * @param side The side that is selected.
+   * @param commentSelected Whether or not a comment is selected.
+   * @return The selected text.
+   */
+  getSelectedText(side: Side) {
+    const sel = this.getSelection();
+    if (!sel || sel.rangeCount !== 1) {
+      return ''; // No multi-select support yet.
+    }
+    const range = normalize(sel.getRangeAt(0));
+    const startLineEl = getLineElByChild(range.startContainer);
+    if (!startLineEl) return;
+    const endLineEl = getLineElByChild(range.endContainer);
+    // Happens when triple click in side-by-side mode with other side empty.
+    const endsAtOtherEmptySide =
+      !endLineEl &&
+      range.endOffset === 0 &&
+      range.endContainer.nodeName === 'TD' &&
+      range.endContainer instanceof HTMLTableCellElement &&
+      (range.endContainer.classList.contains('left') ||
+        range.endContainer.classList.contains('right'));
+    const startLineDataValue = startLineEl.getAttribute('data-value');
+    if (!startLineDataValue) return;
+    const startLineNum = Number(startLineDataValue);
+    let endLineNum;
+    if (endsAtOtherEmptySide) {
+      endLineNum = startLineNum + 1;
+    } else if (endLineEl) {
+      const endLineDataValue = endLineEl.getAttribute('data-value');
+      if (endLineDataValue) endLineNum = Number(endLineDataValue);
+    }
+
+    return this.getRangeFromDiff(
+      startLineNum,
+      range.startOffset,
+      endLineNum,
+      range.endOffset,
+      side
+    );
+  }
+
+  /**
+   * Query the diff object for the selected lines.
+   */
+  getRangeFromDiff(
+    startLineNum: number,
+    startOffset: number,
+    endLineNum: number | undefined,
+    endOffset: number,
+    side: Side
+  ) {
+    const skipChunk = this.diff?.content.find(chunk => chunk.skip);
+    if (skipChunk) {
+      startLineNum -= skipChunk.skip!;
+      if (endLineNum) endLineNum -= skipChunk.skip!;
+    }
+    const lines = this.getDiffLines(side).slice(startLineNum - 1, endLineNum);
+    if (lines.length) {
+      lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
+      lines[0] = lines[0].substring(startOffset);
+    }
+    return lines.join('\n');
+  }
+
+  /**
+   * Query the diff object for the lines from a particular side.
+   *
+   * @param side The side that is currently selected.
+   * @return An array of strings indexed by line number.
+   */
+  getDiffLines(side: Side): string[] {
+    if (this.linesCache[side]) {
+      return this.linesCache[side]!;
+    }
+    if (!this.diff) return [];
+    let lines: string[] = [];
+    for (const chunk of this.diff.content) {
+      if (chunk.ab) {
+        lines = lines.concat(chunk.ab);
+      } else if (side === Side.LEFT && chunk.a) {
+        lines = lines.concat(chunk.a);
+      } else if (side === Side.RIGHT && chunk.b) {
+        lines = lines.concat(chunk.b);
+      }
+    }
+    this.linesCache[side] = lines;
+    return lines;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff-selection/gr-diff-selection_test.ts
new file mode 100644
index 0000000..f216e04
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff-selection/gr-diff-selection_test.ts
@@ -0,0 +1,219 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-selection';
+import '../gr-diff/gr-diff';
+import '../../../elements/shared/gr-comment-thread/gr-comment-thread';
+import {GrDiffSelection} from './gr-diff-selection';
+import {createDiff} from '../../../test/test-data-generators';
+import {DiffInfo, Side} from '../../../api/diff';
+import {fixture, html, assert} from '@open-wc/testing';
+import {mouseDown} from '../../../test/test-utils';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+
+function firstTextNode(el: HTMLElement) {
+  return [...el.childNodes].filter(node => node.nodeType === Node.TEXT_NODE)[0];
+}
+
+suite('gr-diff-selection', () => {
+  let element: GrDiffSelection;
+  let diffTable: HTMLElement;
+  let grDiff: GrDiff;
+
+  const emulateCopyOn = function (target: HTMLElement | null) {
+    const fakeEvent = {
+      target,
+      preventDefault: sinon.stub(),
+      composedPath() {
+        return [target];
+      },
+      clipboardData: {
+        setData: sinon.stub(),
+      },
+    };
+    element.handleCopy(fakeEvent as unknown as ClipboardEvent);
+    return fakeEvent;
+  };
+
+  setup(async () => {
+    grDiff = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+    element = grDiff.diffSelection;
+
+    const diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {
+          a: ['ba ba'],
+          b: ['some other text'],
+        },
+        {
+          a: ['zin'],
+          b: ['more more more'],
+        },
+        {
+          a: ['ga ga'],
+          b: ['some other text'],
+        },
+      ],
+    };
+    grDiff.prefs = createDefaultDiffPrefs();
+    grDiff.diff = diff;
+    await waitForEventOnce(grDiff, 'render');
+    assert.isOk(element.diffTable);
+    diffTable = element.diffTable!;
+  });
+
+  test('applies selected-left on left side click', () => {
+    diffTable.classList.add('selected-right');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.left');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
+    assert.isTrue(
+      diffTable.classList.contains('selected-left'),
+      'adds selected-left'
+    );
+    assert.isFalse(
+      diffTable.classList.contains('selected-right'),
+      'removes selected-right'
+    );
+  });
+
+  test('applies selected-right on right side click', () => {
+    diffTable.classList.add('selected-left');
+    const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.right');
+    if (!lineNumberEl) assert.fail('line number element missing');
+    mouseDown(lineNumberEl);
+    assert.isTrue(
+      diffTable.classList.contains('selected-right'),
+      'adds selected-right'
+    );
+    assert.isFalse(
+      diffTable.classList.contains('selected-left'),
+      'removes selected-left'
+    );
+  });
+
+  test('applies selected-blame on blame click', () => {
+    diffTable.classList.add('selected-left');
+    const blameDiv = document.createElement('div');
+    blameDiv.classList.add('blame');
+    diffTable.appendChild(blameDiv);
+    mouseDown(blameDiv);
+    assert.isTrue(
+      diffTable.classList.contains('selected-blame'),
+      'adds selected-right'
+    );
+    assert.isFalse(
+      diffTable.classList.contains('selected-left'),
+      'removes selected-left'
+    );
+  });
+
+  test('ignores copy for non-content Element', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('.not-diff-row'));
+    assert.isFalse(getSelectedTextStub.called);
+  });
+
+  test('asks for text for left side Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.deepEqual([Side.LEFT], getSelectedTextStub.lastCall.args);
+  });
+
+  test('reacts to copy for content Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.isTrue(getSelectedTextStub.called);
+  });
+
+  test('copy event is prevented for content Elements', () => {
+    const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
+    getSelectedTextStub.returns('test');
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.isTrue(event.preventDefault.called);
+  });
+
+  test('inserts text into clipboard on copy', () => {
+    sinon.stub(element, 'getSelectedText').returns('the text');
+    const event = emulateCopyOn(diffTable.querySelector('div.contentText'));
+    assert.deepEqual(
+      ['Text', 'the text'],
+      event.clipboardData.setData.lastCall.args
+    );
+  });
+
+  test('setClasses adds given SelectionClass values, removes others', () => {
+    diffTable.classList.add('selected-right');
+    element.setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(diffTable.classList.contains('selected-comment'));
+    assert.isTrue(diffTable.classList.contains('selected-left'));
+    assert.isFalse(diffTable.classList.contains('selected-right'));
+    assert.isFalse(diffTable.classList.contains('selected-blame'));
+
+    element.setClasses(['selected-blame']);
+    assert.isFalse(diffTable.classList.contains('selected-comment'));
+    assert.isFalse(diffTable.classList.contains('selected-left'));
+    assert.isFalse(diffTable.classList.contains('selected-right'));
+    assert.isTrue(diffTable.classList.contains('selected-blame'));
+  });
+
+  test('setClasses removes before it ads', () => {
+    diffTable.classList.add('selected-right');
+    const addStub = sinon.stub(diffTable.classList, 'add');
+    const removeStub = sinon
+      .stub(diffTable.classList, 'remove')
+      .callsFake(() => {
+        assert.isFalse(addStub.called);
+      });
+    element.setClasses(['selected-comment', 'selected-left']);
+    assert.isTrue(addStub.called);
+    assert.isTrue(removeStub.called);
+  });
+
+  test('copies content correctly', () => {
+    diffTable.classList.add('selected-left');
+    diffTable.classList.remove('selected-right');
+
+    const selection = document.getSelection();
+    if (selection === null) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    const texts = diffTable.querySelectorAll<HTMLElement>('gr-diff-text');
+    range.setStart(firstTextNode(texts[0]), 3);
+    range.setEnd(firstTextNode(texts[4]), 2);
+    selection.addRange(range);
+
+    assert.equal(element.getSelectedText(Side.LEFT), 'ba\nzin\nga');
+  });
+
+  test('defers to default behavior for textarea', () => {
+    diffTable.classList.add('selected-left');
+    diffTable.classList.remove('selected-right');
+    const selectedTextSpy = sinon.spy(element, 'getSelectedText');
+    emulateCopyOn(diffTable.querySelector('textarea'));
+
+    assert.isFalse(selectedTextSpy.called);
+  });
+
+  test('regression test for 4794', () => {
+    diffTable.classList.add('selected-right');
+    diffTable.classList.remove('selected-left');
+
+    const selection = document.getSelection();
+    if (!selection) assert.fail('no selection');
+    selection.removeAllRanges();
+    const range = document.createRange();
+    const texts = diffTable.querySelectorAll<HTMLElement>('gr-diff-text');
+    range.setStart(firstTextNode(texts[1]), 4);
+    range.setEnd(firstTextNode(texts[1]), 10);
+    selection.addRange(range);
+
+    assert.equal(element.getSelectedText(Side.RIGHT), ' other');
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-group.ts
new file mode 100644
index 0000000..771e298
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-group.ts
@@ -0,0 +1,520 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {BLANK_LINE, GrDiffLine} from './gr-diff-line';
+import {GrDiffLineType, LineNumber, LineRange, Side} from '../../../api/diff';
+import {assertIsDefined, assert} from '../../../utils/common-util';
+import {untilRendered} from '../../../utils/dom-util';
+import {isDefined} from '../../../types/types';
+import {LitElement} from 'lit';
+
+export enum GrDiffGroupType {
+  /** Unchanged context. */
+  BOTH = 'both',
+
+  /** A widget used to show more context. */
+  CONTEXT_CONTROL = 'contextControl',
+
+  /** Added, removed or modified chunk. */
+  DELTA = 'delta',
+}
+
+export interface GrDiffLinePair {
+  left: GrDiffLine;
+  right: GrDiffLine;
+}
+
+/**
+ * Hides lines in the given range behind a context control group.
+ *
+ * Groups that would be partially visible are split into their visible and
+ * hidden parts, respectively.
+ * The groups need to be "common groups", meaning they have to have either
+ * originated from an `ab` chunk, or from an `a`+`b` chunk with
+ * `common: true`.
+ *
+ * If the hidden range is 3 lines or less, nothing is hidden and no context
+ * control group is created.
+ *
+ * @param groups Common groups, ordered by their line ranges.
+ * @param hiddenStart The first element to be hidden, as a
+ *     non-negative line number offset relative to the first group's start
+ *     line, left and right respectively.
+ * @param hiddenEnd The first visible element after the hidden range,
+ *     as a non-negative line number offset relative to the first group's
+ *     start line, left and right respectively.
+ */
+export function hideInContextControl(
+  groups: readonly GrDiffGroup[],
+  hiddenStart: number,
+  hiddenEnd: number
+): GrDiffGroup[] {
+  if (groups.length === 0) return [];
+  // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
+  hiddenStart = Math.max(hiddenStart, 0);
+  hiddenEnd = Math.max(hiddenEnd, hiddenStart);
+
+  let before: GrDiffGroup[] = [];
+  let hidden = groups;
+  let after: readonly GrDiffGroup[] = [];
+
+  const numHidden = hiddenEnd - hiddenStart;
+
+  // Showing a context control row for less than 4 lines does not make much,
+  // because then that row would consume as much space as the collapsed code.
+  if (numHidden > 3) {
+    if (hiddenStart) {
+      [before, hidden] = splitCommonGroups(hidden, hiddenStart);
+    }
+    if (hiddenEnd) {
+      let beforeLength = 0;
+      if (before.length > 0) {
+        const beforeStart = before[0].lineRange.left.start_line;
+        const beforeEnd = before[before.length - 1].lineRange.left.end_line;
+        beforeLength = beforeEnd - beforeStart + 1;
+      }
+      [hidden, after] = splitCommonGroups(hidden, hiddenEnd - beforeLength);
+    }
+  } else {
+    [hidden, after] = [[], hidden];
+  }
+
+  const result = [...before];
+  if (hidden.length) {
+    result.push(
+      new GrDiffGroup({
+        type: GrDiffGroupType.CONTEXT_CONTROL,
+        contextGroups: [...hidden],
+      })
+    );
+  }
+  result.push(...after);
+  return result;
+}
+
+/**
+ * Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
+ * used in function splitCommonGroups
+ * Groups with some lines before and some lines after the split will be split
+ * into two groups, which will be put into the first and second list.
+ *
+ * @param group The group to be split in two
+ * @param leftSplit The line number relative to the split on the left side
+ * @param rightSplit The line number relative to the split on the right side
+ * @return two new groups, one before the split and another after it
+ */
+function splitGroupInTwo(
+  group: GrDiffGroup,
+  leftSplit: number,
+  rightSplit: number
+) {
+  let beforeSplit: GrDiffGroup | undefined;
+  let afterSplit: GrDiffGroup | undefined;
+  // split line is in the middle of a group, we need to break the group
+  // in lines before and after the split.
+  if (group.skip) {
+    // Currently we assume skip chunks "refuse" to be split. Expanding this
+    // group will in the future mean load more data - and therefore we want to
+    // fire an event when user wants to do it.
+    const closerToStartThanEnd =
+      leftSplit - group.lineRange.left.start_line <
+      group.lineRange.right.end_line - leftSplit;
+    if (closerToStartThanEnd) {
+      afterSplit = group;
+    } else {
+      beforeSplit = group;
+    }
+  } else {
+    const before = [];
+    const after = [];
+    for (const line of group.lines) {
+      if (
+        (line.beforeNumber &&
+          typeof line.beforeNumber === 'number' &&
+          line.beforeNumber < leftSplit) ||
+        (line.afterNumber &&
+          typeof line.afterNumber === 'number' &&
+          line.afterNumber < rightSplit)
+      ) {
+        before.push(line);
+      } else {
+        after.push(line);
+      }
+    }
+    if (before.length) {
+      beforeSplit =
+        before.length === group.lines.length
+          ? group
+          : group.cloneWithLines(before);
+    }
+    if (after.length) {
+      afterSplit =
+        after.length === group.lines.length
+          ? group
+          : group.cloneWithLines(after);
+    }
+  }
+  return {beforeSplit, afterSplit};
+}
+
+/**
+ * Splits a list of common groups into two lists of groups.
+ *
+ * Groups where all lines are before or all lines are after the split will be
+ * retained as is and put into the first or second list respectively. Groups
+ * with some lines before and some lines after the split will be split into
+ * two groups, which will be put into the first and second list.
+ *
+ * @param split A line number offset relative to the first group's
+ *     start line at which the groups should be split.
+ * @return The outer array has 2 elements, the
+ *   list of groups before and the list of groups after the split.
+ */
+function splitCommonGroups(
+  groups: readonly GrDiffGroup[],
+  split: number
+): GrDiffGroup[][] {
+  if (groups.length === 0) return [[], []];
+  const leftSplit = groups[0].lineRange.left.start_line + split;
+  const rightSplit = groups[0].lineRange.right.start_line + split;
+
+  const beforeGroups = [];
+  const afterGroups = [];
+  for (const group of groups) {
+    const isCompletelyBefore =
+      group.lineRange.left.end_line < leftSplit ||
+      group.lineRange.right.end_line < rightSplit;
+    const isCompletelyAfter =
+      leftSplit <= group.lineRange.left.start_line ||
+      rightSplit <= group.lineRange.right.start_line;
+    if (isCompletelyBefore) {
+      beforeGroups.push(group);
+    } else if (isCompletelyAfter) {
+      afterGroups.push(group);
+    } else {
+      const {beforeSplit, afterSplit} = splitGroupInTwo(
+        group,
+        leftSplit,
+        rightSplit
+      );
+      if (beforeSplit) {
+        beforeGroups.push(beforeSplit);
+      }
+      if (afterSplit) {
+        afterGroups.push(afterSplit);
+      }
+    }
+  }
+  return [beforeGroups, afterGroups];
+}
+
+export interface GrMoveDetails {
+  changed: boolean;
+  range?: {
+    start: number;
+    end: number;
+  };
+}
+
+/** A chunk of the diff that should be rendered together. */
+export class GrDiffGroup {
+  constructor(
+    options:
+      | {
+          type: GrDiffGroupType.BOTH | GrDiffGroupType.DELTA;
+          lines?: GrDiffLine[];
+          skip?: undefined;
+          moveDetails?: GrMoveDetails;
+          dueToRebase?: boolean;
+          ignoredWhitespaceOnly?: boolean;
+          keyLocation?: boolean;
+        }
+      | {
+          type: GrDiffGroupType.BOTH | GrDiffGroupType.DELTA;
+          lines?: undefined;
+          skip: number;
+          offsetLeft: number;
+          offsetRight: number;
+          moveDetails?: GrMoveDetails;
+          dueToRebase?: boolean;
+          ignoredWhitespaceOnly?: boolean;
+          keyLocation?: boolean;
+        }
+      | {
+          type: GrDiffGroupType.CONTEXT_CONTROL;
+          contextGroups: GrDiffGroup[];
+        }
+  ) {
+    this.type = options.type;
+    switch (options.type) {
+      case GrDiffGroupType.BOTH:
+      case GrDiffGroupType.DELTA: {
+        this.moveDetails = options.moveDetails;
+        this.dueToRebase = options.dueToRebase ?? false;
+        this.ignoredWhitespaceOnly = options.ignoredWhitespaceOnly ?? false;
+        this.keyLocation = options.keyLocation ?? false;
+        if (options.skip && options.lines) {
+          throw new Error('Cannot set skip and lines');
+        }
+        this.skip = options.skip;
+        if (options.skip !== undefined) {
+          this.lineRange = {
+            left: {
+              start_line: options.offsetLeft,
+              end_line: options.offsetLeft + options.skip - 1,
+            },
+            right: {
+              start_line: options.offsetRight,
+              end_line: options.offsetRight + options.skip - 1,
+            },
+          };
+        } else {
+          assertIsDefined(options.lines);
+          assert(options.lines.length > 0, 'diff group must have lines');
+          for (const line of options.lines) {
+            this.addLine(line);
+          }
+        }
+        break;
+      }
+      case GrDiffGroupType.CONTEXT_CONTROL: {
+        this.contextGroups = options.contextGroups;
+        if (this.contextGroups.length > 0) {
+          const firstGroup = this.contextGroups[0];
+          const lastGroup = this.contextGroups[this.contextGroups.length - 1];
+          this.lineRange = {
+            left: {
+              start_line: firstGroup.lineRange.left.start_line,
+              end_line: lastGroup.lineRange.left.end_line,
+            },
+            right: {
+              start_line: firstGroup.lineRange.right.start_line,
+              end_line: lastGroup.lineRange.right.end_line,
+            },
+          };
+        }
+        break;
+      }
+      default:
+        throw new Error(`Unknown group type: ${this.type}`);
+    }
+  }
+
+  readonly type: GrDiffGroupType;
+
+  readonly dueToRebase: boolean = false;
+
+  /**
+   * True means all changes in this line are whitespace changes that should
+   * not be highlighted as changed as per the user settings.
+   */
+  readonly ignoredWhitespaceOnly: boolean = false;
+
+  /**
+   * True means it should not be collapsed (because it was in the URL, or
+   * there is a comment on that line)
+   */
+  readonly keyLocation: boolean = false;
+
+  /**
+   * Once rendered the diff builder sets this to the diff section element.
+   */
+  element?: HTMLElement;
+
+  readonly lines: GrDiffLine[] = [];
+
+  readonly adds: GrDiffLine[] = [];
+
+  readonly removes: GrDiffLine[] = [];
+
+  readonly contextGroups: GrDiffGroup[] = [];
+
+  readonly skip?: number;
+
+  /** Both start and end line are inclusive. */
+  readonly lineRange: {[side in Side]: LineRange} = {
+    [Side.LEFT]: {start_line: 0, end_line: 0},
+    [Side.RIGHT]: {start_line: 0, end_line: 0},
+  };
+
+  readonly moveDetails?: GrMoveDetails;
+
+  /**
+   * Creates a new group with the same properties but different lines.
+   *
+   * The element property is not copied, because the original element is still a
+   * rendering of the old lines, so that would not make sense.
+   */
+  cloneWithLines(lines: GrDiffLine[]): GrDiffGroup {
+    if (
+      this.type !== GrDiffGroupType.BOTH &&
+      this.type !== GrDiffGroupType.DELTA
+    ) {
+      throw new Error('Cannot clone context group with lines');
+    }
+    const group = new GrDiffGroup({
+      type: this.type,
+      lines,
+      dueToRebase: this.dueToRebase,
+      ignoredWhitespaceOnly: this.ignoredWhitespaceOnly,
+    });
+    return group;
+  }
+
+  private addLine(line: GrDiffLine) {
+    this.lines.push(line);
+
+    const notDelta =
+      this.type === GrDiffGroupType.BOTH ||
+      this.type === GrDiffGroupType.CONTEXT_CONTROL;
+    if (
+      notDelta &&
+      (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.REMOVE)
+    ) {
+      throw Error('Cannot add delta line to a non-delta group.');
+    }
+
+    if (line.type === GrDiffLineType.ADD) {
+      this.adds.push(line);
+    } else if (line.type === GrDiffLineType.REMOVE) {
+      this.removes.push(line);
+    }
+    this._updateRangeWithNewLine(line);
+  }
+
+  getSideBySidePairs(): GrDiffLinePair[] {
+    if (
+      this.type === GrDiffGroupType.BOTH ||
+      this.type === GrDiffGroupType.CONTEXT_CONTROL
+    ) {
+      return this.lines.map(line => {
+        return {left: line, right: line};
+      });
+    }
+
+    const pairs: GrDiffLinePair[] = [];
+    let i = 0;
+    let j = 0;
+    while (i < this.removes.length || j < this.adds.length) {
+      pairs.push({
+        left: this.removes[i] || BLANK_LINE,
+        right: this.adds[j] || BLANK_LINE,
+      });
+      i++;
+      j++;
+    }
+    return pairs;
+  }
+
+  getUnifiedPairs(): GrDiffLinePair[] {
+    return this.lines
+      .map(line => {
+        if (line.type === GrDiffLineType.ADD) {
+          return {left: BLANK_LINE, right: line};
+        }
+        if (line.type === GrDiffLineType.REMOVE) {
+          if (this.ignoredWhitespaceOnly) return undefined;
+          return {left: line, right: BLANK_LINE};
+        }
+        return {left: line, right: line};
+      })
+      .filter(isDefined);
+  }
+
+  /** Returns true if it is, or contains, a skip group. */
+  hasSkipGroup() {
+    return (
+      this.skip !== undefined ||
+      this.contextGroups?.some(g => g.skip !== undefined)
+    );
+  }
+
+  containsLine(side: Side, line: LineNumber) {
+    if (typeof line !== 'number') {
+      // For FILE and LOST, beforeNumber and afterNumber are the same
+      return this.lines[0]?.beforeNumber === line;
+    }
+    const lineRange = this.lineRange[side];
+    return lineRange.start_line <= line && line <= lineRange.end_line;
+  }
+
+  startLine(side: Side): LineNumber {
+    // For both CONTEXT_CONTROL groups and SKIP groups the `lines` array will
+    // be empty. So we have to use `lineRange` instead of looking at the first
+    // line.
+    if (
+      this.type === GrDiffGroupType.CONTEXT_CONTROL ||
+      this.skip !== undefined
+    ) {
+      return side === Side.LEFT
+        ? this.lineRange.left.start_line
+        : this.lineRange.right.start_line;
+    }
+    // For "normal" groups we could also use the `lineRange`, but for FILE or
+    // LOST lines we want to return FILE or LOST. The `lineRange` contains
+    // numbers only.
+    return this.lines[0].lineNumber(side);
+  }
+
+  private _updateRangeWithNewLine(line: GrDiffLine) {
+    if (typeof line.beforeNumber !== 'number') return;
+    if (typeof line.afterNumber !== 'number') return;
+
+    if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
+      if (
+        this.lineRange.right.start_line === 0 ||
+        line.afterNumber < this.lineRange.right.start_line
+      ) {
+        this.lineRange.right.start_line = line.afterNumber;
+      }
+      if (line.afterNumber > this.lineRange.right.end_line) {
+        this.lineRange.right.end_line = line.afterNumber;
+      }
+    }
+
+    if (
+      line.type === GrDiffLineType.REMOVE ||
+      line.type === GrDiffLineType.BOTH
+    ) {
+      if (
+        this.lineRange.left.start_line === 0 ||
+        line.beforeNumber < this.lineRange.left.start_line
+      ) {
+        this.lineRange.left.start_line = line.beforeNumber;
+      }
+      if (line.beforeNumber > this.lineRange.left.end_line) {
+        this.lineRange.left.end_line = line.beforeNumber;
+      }
+    }
+  }
+
+  async waitUntilRendered() {
+    const lineNumber = this.lines[0]?.beforeNumber;
+    // The LOST or FILE lines may be hidden and thus never resolve an
+    // untilRendered() promise.
+    if (
+      this.skip !== undefined ||
+      typeof lineNumber !== 'number' ||
+      this.type === GrDiffGroupType.CONTEXT_CONTROL
+    ) {
+      return Promise.resolve();
+    }
+    assertIsDefined(this.element);
+    await (this.element as LitElement).updateComplete;
+    await untilRendered(this.element.firstElementChild as HTMLElement);
+  }
+
+  /**
+   * Determines whether the group is either totally an addition or totally
+   * a removal.
+   */
+  isTotal(): boolean {
+    return (
+      this.type === GrDiffGroupType.DELTA &&
+      (!this.adds.length || !this.removes.length) &&
+      !(!this.adds.length && !this.removes.length)
+    );
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-group_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-group_test.ts
new file mode 100644
index 0000000..bbbb4ad
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-group_test.ts
@@ -0,0 +1,314 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {GrDiffLine, BLANK_LINE} from './gr-diff-line';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from './gr-diff-group';
+import {assert} from '@open-wc/testing';
+import {FILE, GrDiffLineType, LOST, Side} from '../../../api/diff';
+
+suite('gr-diff-group tests', () => {
+  test('delta line pairs', () => {
+    const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
+    const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
+    const l3 = new GrDiffLine(GrDiffLineType.REMOVE, 64, 0);
+    let group = new GrDiffGroup({
+      type: GrDiffGroupType.DELTA,
+      lines: [l1, l2, l3],
+    });
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, [l1, l2]);
+    assert.deepEqual(group.removes, [l3]);
+    assert.deepEqual(group.lineRange, {
+      left: {start_line: 64, end_line: 64},
+      right: {start_line: 128, end_line: 129},
+    });
+
+    let pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l3, right: l1},
+      {left: BLANK_LINE, right: l2},
+    ]);
+
+    group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines: [l1, l2, l3]});
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, [l1, l2]);
+    assert.deepEqual(group.removes, [l3]);
+
+    pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l3, right: l1},
+      {left: BLANK_LINE, right: l2},
+    ]);
+  });
+
+  test('group must have lines', () => {
+    try {
+      new GrDiffGroup({type: GrDiffGroupType.BOTH});
+    } catch (e) {
+      // expected
+      return;
+    }
+    assert.fail('a standard diff group cannot be empty');
+  });
+
+  test('group/header line pairs', () => {
+    const l1 = new GrDiffLine(GrDiffLineType.BOTH, 64, 128);
+    const l2 = new GrDiffLine(GrDiffLineType.BOTH, 65, 129);
+    const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
+
+    const group = new GrDiffGroup({
+      type: GrDiffGroupType.BOTH,
+      lines: [l1, l2, l3],
+    });
+
+    assert.deepEqual(group.lines, [l1, l2, l3]);
+    assert.deepEqual(group.adds, []);
+    assert.deepEqual(group.removes, []);
+
+    assert.deepEqual(group.lineRange, {
+      left: {start_line: 64, end_line: 66},
+      right: {start_line: 128, end_line: 130},
+    });
+
+    const pairs = group.getSideBySidePairs();
+    assert.deepEqual(pairs, [
+      {left: l1, right: l1},
+      {left: l2, right: l2},
+      {left: l3, right: l3},
+    ]);
+  });
+
+  test('adding delta lines to non-delta group', () => {
+    const l1 = new GrDiffLine(GrDiffLineType.ADD);
+    const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
+    const l3 = new GrDiffLine(GrDiffLineType.BOTH);
+
+    assert.throws(
+      () => new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [l1, l2, l3]})
+    );
+  });
+
+  suite('hideInContextControl', () => {
+    let groups: GrDiffGroup[];
+    setup(() => {
+      groups = [
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.DELTA,
+          lines: [
+            new GrDiffLine(GrDiffLineType.REMOVE, 8),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+            new GrDiffLine(GrDiffLineType.REMOVE, 9),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 11),
+            new GrDiffLine(GrDiffLineType.REMOVE, 10),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 12),
+            new GrDiffLine(GrDiffLineType.REMOVE, 11),
+            new GrDiffLine(GrDiffLineType.ADD, 0, 13),
+          ],
+        }),
+        new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          lines: [
+            new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
+            new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+            new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
+          ],
+        }),
+      ];
+    });
+
+    test('hides hidden groups in context control', () => {
+      const collapsedGroups = hideInContextControl(groups, 3, 7);
+      assert.equal(collapsedGroups.length, 3);
+
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[1].contextGroups.length, 1);
+      assert.equal(collapsedGroups[1].contextGroups[0], groups[1]);
+
+      assert.equal(collapsedGroups[2], groups[2]);
+    });
+
+    test('splits partially hidden groups', () => {
+      const collapsedGroups = hideInContextControl(groups, 4, 8);
+      assert.equal(collapsedGroups.length, 4);
+      assert.equal(collapsedGroups[0], groups[0]);
+
+      assert.equal(collapsedGroups[1].type, GrDiffGroupType.DELTA);
+      assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
+      assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
+
+      assert.equal(collapsedGroups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
+      assert.equal(collapsedGroups[2].contextGroups.length, 2);
+
+      assert.equal(
+        collapsedGroups[2].contextGroups[0].type,
+        GrDiffGroupType.DELTA
+      );
+      assert.deepEqual(
+        collapsedGroups[2].contextGroups[0].adds,
+        groups[1].adds.slice(1)
+      );
+      assert.deepEqual(
+        collapsedGroups[2].contextGroups[0].removes,
+        groups[1].removes.slice(1)
+      );
+
+      assert.equal(
+        collapsedGroups[2].contextGroups[1].type,
+        GrDiffGroupType.BOTH
+      );
+      assert.deepEqual(collapsedGroups[2].contextGroups[1].lines, [
+        groups[2].lines[0],
+      ]);
+
+      assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
+      assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
+    });
+
+    suite('with skip chunks', () => {
+      setup(() => {
+        const skipGroup = new GrDiffGroup({
+          type: GrDiffGroupType.BOTH,
+          skip: 60,
+          offsetLeft: 8,
+          offsetRight: 10,
+        });
+        groups = [
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+              new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+              new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+            ],
+          }),
+          skipGroup,
+          new GrDiffGroup({
+            type: GrDiffGroupType.BOTH,
+            lines: [
+              new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+              new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+              new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+            ],
+          }),
+        ];
+      });
+
+      test('refuses to split skip group when closer to before', () => {
+        const collapsedGroups = hideInContextControl(groups, 4, 10);
+        assert.deepEqual(groups, collapsedGroups);
+      });
+    });
+
+    test('groups unchanged if the hidden range is empty', () => {
+      assert.deepEqual(hideInContextControl(groups, 0, 0), groups);
+    });
+
+    test('groups unchanged if there is only 1 line to hide', () => {
+      assert.deepEqual(hideInContextControl(groups, 3, 4), groups);
+    });
+  });
+
+  suite('isTotal', () => {
+    test('is total for add', () => {
+      const lines = [];
+      for (let idx = 0; idx < 10; idx++) {
+        lines.push(new GrDiffLine(GrDiffLineType.ADD));
+      }
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.isTrue(group.isTotal());
+    });
+
+    test('is total for remove', () => {
+      const lines = [];
+      for (let idx = 0; idx < 10; idx++) {
+        lines.push(new GrDiffLine(GrDiffLineType.REMOVE));
+      }
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.isTrue(group.isTotal());
+    });
+
+    test('not total for non-delta', () => {
+      const lines = [];
+      for (let idx = 0; idx < 10; idx++) {
+        lines.push(new GrDiffLine(GrDiffLineType.BOTH));
+      }
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.isFalse(group.isTotal());
+    });
+  });
+
+  suite('startLine', () => {
+    test('DELTA', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 3, 4));
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.equal(group.startLine(Side.LEFT), 3);
+      assert.equal(group.startLine(Side.RIGHT), 4);
+    });
+
+    test('CONTEXT CONTROL', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 3, 4));
+      const delta = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.CONTEXT_CONTROL,
+        contextGroups: [delta],
+      });
+      assert.equal(group.startLine(Side.LEFT), 3);
+      assert.equal(group.startLine(Side.RIGHT), 4);
+    });
+
+    test('SKIP', () => {
+      const group = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        skip: 10,
+        offsetLeft: 3,
+        offsetRight: 6,
+      });
+      assert.equal(group.startLine(Side.LEFT), 3);
+      assert.equal(group.startLine(Side.RIGHT), 6);
+
+      const group2 = new GrDiffGroup({
+        type: GrDiffGroupType.BOTH,
+        skip: 0,
+        offsetLeft: 3,
+        offsetRight: 6,
+      });
+      assert.equal(group2.startLine(Side.LEFT), 3);
+      assert.equal(group2.startLine(Side.RIGHT), 6);
+    });
+
+    test('FILE', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, FILE, FILE));
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.equal(group.startLine(Side.LEFT), FILE);
+      assert.equal(group.startLine(Side.RIGHT), FILE);
+    });
+
+    test('LOST', () => {
+      const lines: GrDiffLine[] = [];
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, LOST, LOST));
+      const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+      assert.equal(group.startLine(Side.LEFT), LOST);
+      assert.equal(group.startLine(Side.RIGHT), LOST);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-line.ts
new file mode 100644
index 0000000..1a89207
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-line.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+  FILE,
+  GrDiffLine as GrDiffLineApi,
+  GrDiffLineType,
+  LineNumber,
+  Side,
+} from '../../../api/diff';
+
+export class GrDiffLine implements GrDiffLineApi {
+  constructor(
+    readonly type: GrDiffLineType,
+    public beforeNumber: LineNumber = 0,
+    public afterNumber: LineNumber = 0
+  ) {}
+
+  hasIntralineInfo = false;
+
+  highlights: Highlights[] = [];
+
+  text = '';
+
+  lineNumber(side: Side) {
+    return side === Side.LEFT ? this.beforeNumber : this.afterNumber;
+  }
+
+  // TODO(TS): remove this properties
+  static readonly Type = GrDiffLineType;
+
+  static readonly File = FILE;
+}
+
+/**
+ * A line highlight object consists of three fields:
+ * - contentIndex: The index of the chunk `content` field (the line
+ *   being referred to).
+ * - startIndex: Index of the character where the highlight should begin.
+ * - endIndex: (optional) Index of the character where the highlight should
+ *   end. If omitted, the highlight is meant to be a continuation onto the
+ *   next line.
+ */
+export interface Highlights {
+  contentIndex: number;
+  startIndex: number;
+  endIndex?: number;
+}
+
+export const BLANK_LINE = new GrDiffLine(GrDiffLineType.BLANK);
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-styles.ts
new file mode 100644
index 0000000..e7f4b51
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff-styles.ts
@@ -0,0 +1,671 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const grDiffStyles = css`
+  /* This is used to hide all left side of the diff (e.g. diffs besides
+     comments in the change log). Since we want to remove the first 4
+     cells consistently in all rows except context buttons (.dividerRow). */
+  :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
+  :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
+    display: none;
+  }
+  :host(.disable-context-control-buttons) {
+    --context-control-display: none;
+  }
+  :host(.disable-context-control-buttons) .section {
+    border-right: none;
+  }
+  :host(.hide-line-length-indicator) .full-width td.content .contentText {
+    background-image: none;
+  }
+
+  :host {
+    font-family: var(--monospace-font-family, ''), 'Roboto Mono';
+    font-size: var(--font-size, var(--font-size-code, 12px));
+    /* usually 16px = 12px + 4px */
+    line-height: calc(
+      var(--font-size, var(--font-size-code, 12px)) + var(--spacing-s, 4px)
+    );
+  }
+
+  .thread-group {
+    display: block;
+    max-width: var(--content-width, 80ch);
+    white-space: normal;
+    background-color: var(--diff-blank-background-color);
+  }
+  .diffContainer {
+    max-width: var(--diff-max-width, none);
+    font-family: var(--monospace-font-family);
+  }
+  table {
+    border-collapse: collapse;
+    table-layout: fixed;
+  }
+  td.lineNum {
+    /* Enforces background whenever lines wrap */
+    background-color: var(--diff-blank-background-color);
+  }
+
+  /* Provides the option to add side borders (left and right) to the line
+     number column. */
+  td.lineNum,
+  td.blankLineNum,
+  td.moveControlsLineNumCol,
+  td.contextLineNum {
+    box-shadow: var(--line-number-box-shadow, unset);
+  }
+
+  /* Context controls break up the table visually, so we set the right
+     border on individual sections to leave a gap for the divider.
+
+     Also taken into account for max-width calculations in SHRINK_ONLY mode
+     (check GrDiff.updatePreferenceStyles). */
+  .section {
+    border-right: 1px solid var(--border-color);
+  }
+  .section.contextControl {
+    /* Divider inside this section must not have border; we set borders on
+       the padding rows below. */
+    border-right-width: 0;
+  }
+  /* Padding rows behind context controls. The diff is styled to be cut
+     into two halves by the negative space of the divider on which the
+     context control buttons are anchored. */
+  .contextBackground {
+    border-right: 1px solid var(--border-color);
+  }
+  .contextBackground.above {
+    border-bottom: 1px solid var(--border-color);
+  }
+  .contextBackground.below {
+    border-top: 1px solid var(--border-color);
+  }
+
+  .lineNumButton {
+    display: block;
+    width: 100%;
+    height: 100%;
+    background-color: var(--diff-blank-background-color);
+    box-shadow: var(--line-number-box-shadow, unset);
+  }
+  td.lineNum {
+    vertical-align: top;
+  }
+
+  /* The only way to focus this (clicking) will apply our own focus
+     styling, so this default styling is not needed and distracting. */
+  .lineNumButton:focus {
+    outline: none;
+  }
+  gr-image-viewer {
+    width: 100%;
+    height: 100%;
+    max-width: var(--image-viewer-max-width, 95vw);
+    max-height: var(--image-viewer-max-height, 90vh);
+    /* Defined by paper-styles default-theme and used in various
+       components. background-color-secondary is a compromise between
+       fairly light in light theme (where we ideally would want
+       background-color-primary) yet slightly offset against the app
+       background in dark mode, where drop shadows e.g. around paper-card
+       are almost invisible. */
+    --primary-background-color: var(--background-color-secondary);
+  }
+  .image-diff .gr-diff {
+    text-align: center;
+  }
+  .image-diff img {
+    box-shadow: var(--elevation-level-1);
+    max-width: 50em;
+  }
+  .image-diff .right.lineNumButton {
+    border-left: 1px solid var(--border-color);
+  }
+  .image-diff label {
+    font-family: var(--font-family);
+    font-style: italic;
+  }
+  tbody.binary-diff td {
+    font-family: var(--font-family);
+    font-style: italic;
+    text-align: center;
+    padding: var(--spacing-s) 0;
+  }
+  .diff-row {
+    outline: none;
+    user-select: none;
+  }
+  .diff-row.target-row.target-side-left .lineNumButton.left,
+  .diff-row.target-row.target-side-right .lineNumButton.right,
+  .diff-row.target-row.unified .lineNumButton {
+    color: var(--primary-text-color);
+  }
+
+  /* Preparing selected line cells with position relative so it allows a
+     positioned overlay with 'position: absolute'. */
+  .target-row td {
+    position: relative;
+  }
+
+  /* Defines an overlay to the selected line for drawing an outline without
+     blocking user interaction (e.g. text selection). */
+  .target-row td::before {
+    border-width: 0;
+    border-style: solid;
+    border-color: var(--focused-line-outline-color);
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    pointer-events: none;
+    user-select: none;
+    content: ' ';
+  }
+
+  /* The outline for the selected content cell should be the same in all
+     cases. */
+  .target-row.target-side-left td.left.content::before,
+  .target-row.target-side-right td.right.content::before,
+  .unified.target-row td.content::before {
+    border-width: 1px 1px 1px 0;
+  }
+
+  /* The outline for the sign cell should be always be contiguous
+     top/bottom. */
+  .target-row.target-side-left td.left.sign::before,
+  .target-row.target-side-right td.right.sign::before {
+    border-width: 1px 0;
+  }
+
+  /* For side-by-side we need to select the correct line number to
+     "visually close" the outline. */
+  .side-by-side.target-row.target-side-left td.left.lineNum::before,
+  .side-by-side.target-row.target-side-right td.right.lineNum::before {
+    border-width: 1px 0 1px 1px;
+  }
+
+  /* For unified diff we always start the overlay from the left cell. */
+  .unified.target-row td.left:not(.content)::before {
+    border-width: 1px 0 1px 1px;
+  }
+
+  /* For unified diff we should continue the top/bottom border in right
+     line number column. */
+  .unified.target-row td.right:not(.content)::before {
+    border-width: 1px 0;
+  }
+
+  .content {
+    background-color: var(--diff-blank-background-color);
+  }
+
+  /* Describes two states of semantic tokens: whenever a token has a
+     definition that can be navigated to (navigable) and whenever
+     the token is actually clickable to perform this navigation. */
+  .semantic-token.navigable {
+    text-decoration-style: dotted;
+    text-decoration-line: underline;
+  }
+  .semantic-token.navigable.clickable {
+    text-decoration-style: solid;
+    cursor: pointer;
+  }
+
+  /* The file line, which has no contentText, add some margin before the
+     first comment. We cannot add padding the container because we only
+     want it if there is at least one comment thread, and the slotting
+     makes :empty not work as expected. */
+  .content.file slot:first-child::slotted(.comment-thread) {
+    display: block;
+    margin-top: var(--spacing-xs);
+  }
+  .contentText {
+    background-color: var(--view-background-color);
+  }
+  .blank {
+    background-color: var(--diff-blank-background-color);
+  }
+  .image-diff .content {
+    background-color: var(--diff-blank-background-color);
+  }
+  .responsive {
+    width: 100%;
+  }
+  .responsive .contentText {
+    white-space: break-spaces;
+    word-break: break-all;
+  }
+  .lineNumButton,
+  .content {
+    vertical-align: top;
+    white-space: pre;
+  }
+  .contextLineNum,
+  .lineNumButton {
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+
+    color: var(--deemphasized-text-color);
+    padding: 0 var(--spacing-m);
+    text-align: right;
+  }
+  .canComment .lineNumButton {
+    cursor: pointer;
+  }
+  .sign {
+    min-width: 1ch;
+    width: 1ch;
+    background-color: var(--view-background-color);
+  }
+  .sign.blank {
+    background-color: var(--diff-blank-background-color);
+  }
+  .content {
+    /* Set min width since setting width on table cells still allows them
+       to shrink. Do not set max width because CJK
+       (Chinese-Japanese-Korean) glyphs have variable width. */
+    min-width: var(--content-width, 80ch);
+    width: var(--content-width, 80ch);
+  }
+  /* If there are no intraline info, consider everything changed */
+  .content.add .contentText .intraline,
+  .content.add.no-intraline-info .contentText,
+  .sign.add.no-intraline-info,
+  .delta.total .content.add .contentText {
+    background-color: var(--dark-add-highlight-color);
+  }
+  .content.add .contentText,
+  .sign.add {
+    background-color: var(--light-add-highlight-color);
+  }
+  /* If there are no intraline info, consider everything changed */
+  .content.remove .contentText .intraline,
+  .content.remove.no-intraline-info .contentText,
+  .delta.total .content.remove .contentText,
+  .sign.remove.no-intraline-info {
+    background-color: var(--dark-remove-highlight-color);
+  }
+  .content.remove .contentText,
+  .sign.remove {
+    background-color: var(--light-remove-highlight-color);
+  }
+
+  .ignoredWhitespaceOnly .sign.no-intraline-info {
+    background-color: var(--view-background-color);
+  }
+
+  /* dueToRebase */
+  .dueToRebase .content.add .contentText .intraline,
+  .delta.total.dueToRebase .content.add .contentText {
+    background-color: var(--dark-rebased-add-highlight-color);
+  }
+  .dueToRebase .content.add .contentText {
+    background-color: var(--light-rebased-add-highlight-color);
+  }
+  .dueToRebase .content.remove .contentText .intraline,
+  .delta.total.dueToRebase .content.remove .contentText {
+    background-color: var(--dark-rebased-remove-highlight-color);
+  }
+  .dueToRebase .content.remove .contentText {
+    background-color: var(--light-rebased-remove-highlight-color);
+  }
+
+  /* dueToMove */
+  .dueToMove .sign.add,
+  .dueToMove .content.add .contentText,
+  .dueToMove .moveControls.movedIn .sign.right,
+  .dueToMove .moveControls.movedIn .moveHeader,
+  .delta.total.dueToMove .content.add .contentText {
+    background-color: var(--diff-moved-in-background);
+  }
+
+  .dueToMove.changed .sign.add,
+  .dueToMove.changed .content.add .contentText,
+  .dueToMove.changed .moveControls.movedIn .sign.right,
+  .dueToMove.changed .moveControls.movedIn .moveHeader,
+  .delta.total.dueToMove.changed .content.add .contentText {
+    background-color: var(--diff-moved-in-changed-background);
+  }
+
+  .dueToMove .sign.remove,
+  .dueToMove .content.remove .contentText,
+  .dueToMove .moveControls.movedOut .moveHeader,
+  .dueToMove .moveControls.movedOut .sign.left,
+  .delta.total.dueToMove .content.remove .contentText {
+    background-color: var(--diff-moved-out-background);
+  }
+
+  .delta.dueToMove .movedIn .moveHeader {
+    --gr-range-header-color: var(--diff-moved-in-label-color);
+  }
+  .delta.dueToMove.changed .movedIn .moveHeader {
+    --gr-range-header-color: var(--diff-moved-in-changed-label-color);
+  }
+  .delta.dueToMove .movedOut .moveHeader {
+    --gr-range-header-color: var(--diff-moved-out-label-color);
+  }
+
+  .moveHeader a {
+    color: inherit;
+  }
+
+  /* ignoredWhitespaceOnly */
+  .ignoredWhitespaceOnly .content.add .contentText .intraline,
+  .delta.total.ignoredWhitespaceOnly .content.add .contentText,
+  .ignoredWhitespaceOnly .content.add .contentText,
+  .ignoredWhitespaceOnly .content.remove .contentText .intraline,
+  .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
+  .ignoredWhitespaceOnly .content.remove .contentText {
+    background-color: var(--view-background-color);
+  }
+
+  .content .contentText gr-diff-text:empty:after,
+  .content .contentText gr-legacy-text:empty:after,
+  .content .contentText:empty:after {
+    /* Newline, to ensure empty lines are one line-height tall. */
+    content: '\\A';
+  }
+
+  /* Context controls */
+  .contextControl {
+    display: var(--context-control-display, table-row-group);
+    background-color: transparent;
+    border: none;
+    --divider-height: var(--spacing-s);
+    --divider-border: 1px;
+  }
+  /* TODO: Is this still used? */
+  .contextControl gr-button gr-icon {
+    /* should match line-height of gr-button */
+    font-size: var(--line-height-mono, 18px);
+  }
+  .contextControl td:not(.lineNumButton) {
+    text-align: center;
+  }
+
+  /* Padding rows behind context controls. Styled as a continuation of the
+     line gutters and code area. */
+  .contextBackground > .contextLineNum {
+    background-color: var(--diff-blank-background-color);
+  }
+  .contextBackground > td:not(.contextLineNum) {
+    background-color: var(--view-background-color);
+  }
+  .contextBackground {
+    /* One line of background behind the context expanders which they can
+       render on top of, plus some padding. */
+    height: calc(var(--line-height-normal) + var(--spacing-s));
+  }
+
+  .dividerCell {
+    vertical-align: top;
+  }
+  .dividerRow.show-both .dividerCell {
+    height: var(--divider-height);
+  }
+  .dividerRow.show-above .dividerCell,
+  .dividerRow.show-above .dividerCell {
+    height: 0;
+  }
+
+  .br:after {
+    /* Line feed */
+    content: '\\A';
+  }
+  .tab {
+    display: inline-block;
+  }
+  .tab-indicator:before {
+    color: var(--diff-tab-indicator-color);
+    /* >> character */
+    content: '\\00BB';
+    position: absolute;
+  }
+  .special-char-indicator {
+    /* spacing so elements don't collide */
+    padding-right: var(--spacing-m);
+  }
+  .special-char-indicator:before {
+    color: var(--diff-tab-indicator-color);
+    content: '•';
+    position: absolute;
+  }
+  .special-char-warning {
+    /* spacing so elements don't collide */
+    padding-right: var(--spacing-m);
+  }
+  .special-char-warning:before {
+    color: var(--warning-foreground);
+    content: '!';
+    position: absolute;
+  }
+  /* Is defined after other background-colors, such that this
+     rule wins in case of same specificity. */
+  .trailing-whitespace,
+  .content .contentText .trailing-whitespace,
+  .trailing-whitespace .intraline,
+  .content .contentText .trailing-whitespace .intraline {
+    border-radius: var(--border-radius, 4px);
+    background-color: var(--diff-trailing-whitespace-indicator);
+  }
+  #diffHeader {
+    background-color: var(--table-header-background-color);
+    border-bottom: 1px solid var(--border-color);
+    color: var(--link-color);
+    padding: var(--spacing-m) 0 var(--spacing-m) 48px;
+  }
+  #diffTable {
+    /* for gr-selection-action-box positioning */
+    position: relative;
+  }
+  #diffTable:focus {
+    outline: none;
+  }
+  #loadingError,
+  #sizeWarning {
+    display: block;
+    margin: var(--spacing-l) auto;
+    max-width: 60em;
+    text-align: center;
+  }
+  #loadingError {
+    color: var(--error-text-color);
+  }
+  #sizeWarning gr-button {
+    margin: var(--spacing-l);
+  }
+  .target-row td.blame {
+    background: var(--diff-selection-background-color);
+  }
+  td.lost div {
+    background-color: var(--info-background);
+  }
+  td.lost div.lost-message {
+    font-family: var(--font-family, 'Roboto');
+    font-size: var(--font-size-normal, 14px);
+    line-height: var(--line-height-normal);
+    padding: var(--spacing-s) 0;
+  }
+  td.lost div.lost-message gr-icon {
+    padding: 0 var(--spacing-s) 0 var(--spacing-m);
+    color: var(--blue-700);
+  }
+
+  col.sign,
+  td.sign {
+    display: none;
+  }
+
+  /* Sign column should only be shown in high-contrast mode. */
+  :host(.with-sign-col) col.sign {
+    display: table-column;
+  }
+  :host(.with-sign-col) td.sign {
+    display: table-cell;
+  }
+  col.blame {
+    display: none;
+  }
+  td.blame {
+    display: none;
+    padding: 0 var(--spacing-m);
+    white-space: pre;
+  }
+  :host(.showBlame) col.blame {
+    display: table-column;
+  }
+  :host(.showBlame) td.blame {
+    display: table-cell;
+  }
+  td.blame > span {
+    opacity: 0.6;
+  }
+  td.blame > span.startOfRange {
+    opacity: 1;
+  }
+  td.blame .blameDate {
+    font-family: var(--monospace-font-family);
+    color: var(--link-color);
+    text-decoration: none;
+  }
+  .responsive td.blame {
+    overflow: hidden;
+    width: 200px;
+  }
+  /** Support the line length indicator **/
+  .responsive td.content .contentText {
+    /* Same strategy as in
+       https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+       */
+    background-image: linear-gradient(
+      var(--line-length-indicator-color),
+      var(--line-length-indicator-color)
+    );
+    background-size: 1px 100%;
+    background-position: var(--line-limit-marker) 0;
+    background-repeat: no-repeat;
+  }
+  .newlineWarning {
+    color: var(--deemphasized-text-color);
+    text-align: center;
+  }
+  .newlineWarning.hidden {
+    display: none;
+  }
+  .lineNum.COVERED .lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background-color: var(--coverage-covered, #e0f2f1);
+  }
+  .lineNum.NOT_COVERED .lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background-color: var(--coverage-not-covered, #ffd1a4);
+  }
+  .lineNum.PARTIALLY_COVERED .lineNumButton {
+    color: var(
+      --coverage-covered-line-num-color,
+      var(--deemphasized-text-color)
+    );
+    background: linear-gradient(
+      to right bottom,
+      var(--coverage-not-covered, #ffd1a4) 0%,
+      var(--coverage-not-covered, #ffd1a4) 50%,
+      var(--coverage-covered, #e0f2f1) 50%,
+      var(--coverage-covered, #e0f2f1) 100%
+    );
+  }
+
+  // TODO: Investigate whether this CSS is still necessary.
+  /* BEGIN: Select and copy for Polymer 2 */
+  /* Below was copied and modified from the original css in gr-diff-selection.html. */
+  .content,
+  .contextControl,
+  .blame {
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+  }
+
+  .selected-left:not(.selected-comment)
+    .side-by-side
+    .left
+    + .content
+    .contentText,
+  .selected-right:not(.selected-comment)
+    .side-by-side
+    .right
+    + .content
+    .contentText,
+  .selected-left:not(.selected-comment)
+    .unified
+    .left.lineNum
+    ~ .content:not(.both)
+    .contentText,
+  .selected-right:not(.selected-comment)
+    .unified
+    .right.lineNum
+    ~ .content
+    .contentText,
+  .selected-left.selected-comment .side-by-side .left + .content .message,
+  .selected-right.selected-comment
+    .side-by-side
+    .right
+    + .content
+    .message
+    :not(.collapsedContent),
+  .selected-comment .unified .message :not(.collapsedContent),
+  .selected-blame .blame {
+    -webkit-user-select: text;
+    -moz-user-select: text;
+    -ms-user-select: text;
+    user-select: text;
+  }
+
+  /* Make comments and check results selectable when selected */
+  .selected-left.selected-comment ::slotted(.comment-thread[diff-side='left']),
+  .selected-right.selected-comment
+    ::slotted(.comment-thread[diff-side='right']) {
+    -webkit-user-select: text;
+    -moz-user-select: text;
+    -ms-user-select: text;
+    user-select: text;
+  }
+  /* END: Select and copy for Polymer 2 */
+
+  .whitespace-change-only-message {
+    background-color: var(--diff-context-control-background-color);
+    border: 1px solid var(--diff-context-control-border-color);
+    text-align: center;
+  }
+
+  .token-highlight {
+    background-color: var(--token-highlighting-color, #fffd54);
+  }
+
+  gr-selection-action-box {
+    /* Needs z-index to appear above wrapped content, since it's inserted
+       into DOM before it. */
+    z-index: 10;
+  }
+
+  gr-diff-image-new,
+  gr-diff-image-old,
+  gr-diff-section,
+  gr-context-controls-section,
+  gr-diff-row {
+    display: contents;
+  }
+`;
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff.ts
new file mode 100644
index 0000000..2aa8096
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff.ts
@@ -0,0 +1,1126 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../styles/shared-styles';
+import '../../../elements/shared/gr-button/gr-button';
+import '../../../elements/shared/gr-icon/gr-icon';
+import '../gr-diff-builder/gr-diff-builder-element';
+import '../gr-diff-highlight/gr-diff-highlight';
+import '../gr-diff-selection/gr-diff-selection';
+import '../../diff/gr-syntax-themes/gr-syntax-theme';
+import '../../diff/gr-ranged-comment-themes/gr-ranged-comment-theme';
+import '../../diff/gr-ranged-comment-hint/gr-ranged-comment-hint';
+import {
+  getLine,
+  getLineElByChild,
+  getLineNumber,
+  getRange,
+  getSide,
+  GrDiffThreadElement,
+  isLongCommentRange,
+  isThreadEl,
+  rangesEqual,
+  getResponsiveMode,
+  isResponsive,
+  isNewDiff,
+} from '../../diff/gr-diff/gr-diff-utils';
+import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {
+  CreateRangeCommentEventDetail,
+  GrDiffHighlight,
+} from '../gr-diff-highlight/gr-diff-highlight';
+import {
+  GrDiffBuilderElement,
+  getLineNumberCellWidth,
+} from '../gr-diff-builder/gr-diff-builder-element';
+import {CoverageRange, DiffLayer} from '../../../types/types';
+import {CommentRangeLayer} from '../../diff/gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {
+  createDefaultDiffPrefs,
+  DiffViewMode,
+  Side,
+} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {fire, fireAlert} from '../../../utils/event-util';
+import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
+import {getContentEditableRange} from '../../../utils/safari-selection-util';
+import {AbortStop} from '../../../api/core';
+import {
+  RenderPreferences,
+  GrDiff as GrDiffApi,
+  DisplayLine,
+  LineNumber,
+  LOST,
+} from '../../../api/diff';
+import {isSafari, toggleClass} from '../../../utils/dom-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {
+  debounceP,
+  DelayedPromise,
+  DELAYED_CANCELLATION,
+} from '../../../utils/async-util';
+import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
+import {property, query, state} from 'lit/decorators.js';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {html, LitElement, nothing, PropertyValues} from 'lit';
+import {when} from 'lit/directives/when.js';
+import {grSyntaxTheme} from '../../diff/gr-syntax-themes/gr-syntax-theme';
+import {grRangedCommentTheme} from '../../diff/gr-ranged-comment-themes/gr-ranged-comment-theme';
+import {classMap} from 'lit/directives/class-map.js';
+import {iconStyles} from '../../../styles/gr-icon-styles';
+import {expandFileMode} from '../../../utils/file-util';
+import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
+import {provide} from '../../../models/dependency';
+import {grDiffStyles} from './gr-diff-styles';
+import {getDiffLength} from '../../../utils/diff-util';
+
+const NO_NEWLINE_LEFT = 'No newline at end of left file.';
+const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
+
+const LARGE_DIFF_THRESHOLD_LINES = 10000;
+const FULL_CONTEXT = -1;
+
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+/**
+ * 72 is the unofficial length standard for git commit messages.
+ * Derived from the fact that git log/show appends 4 ws in the beginning of
+ * each line when displaying commit messages. To center the commit message
+ * in an 80 char terminal a 4 ws border is added to the rightmost side:
+ * 4 + 72 + 4
+ */
+const COMMIT_MSG_LINE_LENGTH = 72;
+
+export class GrDiff extends LitElement implements GrDiffApi {
+  /**
+   * Fired when the user selects a line.
+   *
+   * @event line-selected
+   */
+
+  /**
+   * Fired if being logged in is required.
+   *
+   * @event show-auth-required
+   */
+
+  /**
+   * Fired when a comment is created
+   *
+   * @event create-comment
+   */
+
+  /**
+   * Fired when rendering, including syntax highlighting, is done. Also fired
+   * when no rendering can be done because required preferences are not set.
+   *
+   * @event render
+   */
+
+  /**
+   * Fired for interaction reporting when a diff context is expanded.
+   * Contains an event.detail with numLines about the number of lines that
+   * were expanded.
+   *
+   * @event diff-context-expanded
+   */
+
+  @query('#diffTable')
+  diffTable?: HTMLTableElement;
+
+  @property({type: Boolean})
+  noAutoRender = false;
+
+  @property({type: String})
+  path?: string;
+
+  @property({type: Object})
+  prefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  renderPrefs: RenderPreferences = {};
+
+  @property({type: Boolean})
+  isImageDiff?: boolean;
+
+  @property({type: Boolean, reflect: true})
+  override hidden = false;
+
+  @property({type: Boolean})
+  noRenderOnPrefsChange?: boolean;
+
+  // Private but used in tests.
+  @state()
+  commentRanges: CommentRangeLayer[] = [];
+
+  // explicitly highlight a range if it is not associated with any comment
+  @property({type: Object})
+  highlightRange?: CommentRange;
+
+  @property({type: Array})
+  coverageRanges: CoverageRange[] = [];
+
+  @property({type: Boolean})
+  lineWrapping = false;
+
+  @property({type: String})
+  viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  @property({type: Object})
+  lineOfInterest?: DisplayLine;
+
+  /**
+   * True when diff is changed, until the content is done rendering.
+   * Use getter/setter loading instead of this.
+   */
+  private _loading = true;
+
+  get loading() {
+    return this._loading;
+  }
+
+  set loading(loading: boolean) {
+    if (this._loading === loading) return;
+    const oldLoading = this._loading;
+    this._loading = loading;
+    fire(this, 'loading-changed', {value: this._loading});
+    this.requestUpdate('loading', oldLoading);
+  }
+
+  @property({type: Boolean})
+  loggedIn = false;
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @state()
+  private diffTableClass = '';
+
+  @property({type: Object})
+  baseImage?: ImageInfo;
+
+  @property({type: Object})
+  revisionImage?: ImageInfo;
+
+  /**
+   * In order to allow multi-select in Safari browsers, a workaround is required
+   * to trigger 'beforeinput' events to get a list of static ranges. This is
+   * obtained by making the content of the diff table "contentEditable".
+   */
+  @property({type: Boolean})
+  override isContentEditable = isSafari();
+
+  /**
+   * Whether the safety check for large diffs when whole-file is set has
+   * been bypassed. If the value is null, then the safety has not been
+   * bypassed. If the value is a number, then that number represents the
+   * context preference to use when rendering the bypassed diff.
+   *
+   * Private but used in tests.
+   */
+  @state()
+  safetyBypass: number | null = null;
+
+  // Private but used in tests.
+  @state()
+  showWarning?: boolean;
+
+  @property({type: String})
+  errorMessage: string | null = null;
+
+  @property({type: Array})
+  blame: BlameInfo[] | null = null;
+
+  @property({type: Boolean})
+  showNewlineWarningLeft = false;
+
+  @property({type: Boolean})
+  showNewlineWarningRight = false;
+
+  @property({type: Boolean})
+  useNewImageDiffUi = false;
+
+  // Private but used in tests.
+  @state()
+  diffLength?: number;
+
+  /**
+   * Observes comment nodes added or removed at any point.
+   * Can be used to unregister upon detachment.
+   */
+  private nodeObserver?: MutationObserver;
+
+  @property({type: Array})
+  layers?: DiffLayer[];
+
+  // Private but used in tests.
+  renderDiffTableTask?: DelayedPromise<void>;
+
+  // Private but used in tests.
+  diffSelection = new GrDiffSelection();
+
+  // Private but used in tests.
+  highlights = new GrDiffHighlight();
+
+  // Private but used in tests.
+  diffBuilder = new GrDiffBuilderElement();
+
+  private diffModel = new DiffModel(undefined);
+
+  static override get styles() {
+    return [
+      iconStyles,
+      sharedStyles,
+      grSyntaxTheme,
+      grRangedCommentTheme,
+      grDiffStyles,
+    ];
+  }
+
+  constructor() {
+    super();
+    provide(this, diffModelToken, () => this.diffModel);
+    this.addEventListener(
+      'create-range-comment',
+      (e: CustomEvent<CreateRangeCommentEventDetail>) =>
+        this.handleCreateRangeComment(e)
+    );
+    this.addEventListener('render-content', () => this.handleRenderContent());
+    this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
+      this.dispatchSelectedLine(e.detail.lineNum, e.detail.side);
+    });
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    if (this.loggedIn) {
+      this.addSelectionListeners();
+    }
+    if (this.diff && this.diffTable) {
+      this.diffSelection.init(this.diff, this.diffTable);
+    }
+    if (this.diffTable && this.diffBuilder) {
+      this.highlights.init(this.diffTable, this.diffBuilder);
+    }
+    this.diffBuilder.init();
+  }
+
+  override disconnectedCallback() {
+    this.removeSelectionListeners();
+    this.renderDiffTableTask?.cancel();
+    this.diffSelection.cleanup();
+    this.highlights.cleanup();
+    this.diffBuilder.cleanup();
+    super.disconnectedCallback();
+  }
+
+  protected override willUpdate(changedProperties: PropertyValues<this>): void {
+    if (
+      changedProperties.has('path') ||
+      changedProperties.has('lineWrapping') ||
+      changedProperties.has('viewMode') ||
+      changedProperties.has('useNewImageDiffUi') ||
+      changedProperties.has('prefs')
+    ) {
+      this.prefsChanged();
+    }
+    if (changedProperties.has('blame')) {
+      this.blameChanged();
+    }
+    if (changedProperties.has('renderPrefs')) {
+      this.renderPrefsChanged();
+    }
+    if (changedProperties.has('loggedIn')) {
+      if (this.loggedIn && this.isConnected) {
+        this.addSelectionListeners();
+      } else {
+        this.removeSelectionListeners();
+      }
+    }
+    if (changedProperties.has('coverageRanges')) {
+      this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+    }
+    if (changedProperties.has('lineOfInterest')) {
+      this.lineOfInterestChanged();
+    }
+  }
+
+  protected override updated(changedProperties: PropertyValues<this>): void {
+    if (changedProperties.has('diff')) {
+      // diffChanged relies on diffTable ahving been rendered.
+      this.diffChanged();
+    }
+  }
+
+  override render() {
+    return html`
+      ${this.renderHeader()} ${this.renderContainer()}
+      ${this.renderNewlineWarning()} ${this.renderLoadingError()}
+      ${this.renderSizeWarning()}
+    `;
+  }
+
+  private renderHeader() {
+    const diffheaderItems = this.computeDiffHeaderItems();
+    if (diffheaderItems.length === 0) return nothing;
+    return html`
+      <div id="diffHeader">
+        ${diffheaderItems.map(item => html`<div>${item}</div>`)}
+      </div>
+    `;
+  }
+
+  private renderContainer() {
+    const cssClasses = {
+      diffContainer: true,
+      unified: this.viewMode === DiffViewMode.UNIFIED,
+      sideBySide: this.viewMode === DiffViewMode.SIDE_BY_SIDE,
+      canComment: this.loggedIn,
+    };
+    return html`
+      <div class=${classMap(cssClasses)} @click=${this.handleTap}>
+        <table
+          id="diffTable"
+          class=${this.diffTableClass}
+          ?contenteditable=${this.isContentEditable}
+        ></table>
+        ${when(
+          this.showNoChangeMessage(),
+          () => html`
+            <div class="whitespace-change-only-message">
+              This file only contains whitespace changes. Modify the whitespace
+              setting to see the changes.
+            </div>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  private renderNewlineWarning() {
+    const newlineWarning = this.computeNewlineWarning();
+    if (!newlineWarning) return nothing;
+    return html`<div class="newlineWarning">${newlineWarning}</div>`;
+  }
+
+  private renderLoadingError() {
+    if (!this.errorMessage) return nothing;
+    return html`<div id="loadingError">${this.errorMessage}</div>`;
+  }
+
+  private renderSizeWarning() {
+    if (!this.showWarning) return nothing;
+    // TODO: Update comment about 'Whole file' as it's not in settings.
+    return html`
+      <div id="sizeWarning">
+        <p>
+          Prevented render because "Whole file" is enabled and this diff is very
+          large (about ${this.diffLength} lines).
+        </p>
+        <gr-button @click=${this.collapseContext}>
+          Render with limited context
+        </gr-button>
+        <gr-button @click=${this.handleFullBypass}>
+          Render anyway (may be slow)
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private addSelectionListeners() {
+    document.addEventListener('selectionchange', this.handleSelectionChange);
+    document.addEventListener('mouseup', this.handleMouseUp);
+  }
+
+  private removeSelectionListeners() {
+    document.removeEventListener('selectionchange', this.handleSelectionChange);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+  }
+
+  getLineNumEls(side: Side): HTMLElement[] {
+    return this.diffBuilder.getLineNumEls(side);
+  }
+
+  // Private but used in tests.
+  showNoChangeMessage() {
+    return (
+      !this.loading &&
+      this.diff &&
+      !this.diff.binary &&
+      this.prefs &&
+      this.prefs.ignore_whitespace !== 'IGNORE_NONE' &&
+      this.diffLength === 0
+    );
+  }
+
+  private readonly handleSelectionChange = () => {
+    // Because of shadow DOM selections, we handle the selectionchange here,
+    // and pass the shadow DOM selection into gr-diff-highlight, where the
+    // corresponding range is determined and normalized.
+    const selection = this.getShadowOrDocumentSelection();
+    this.highlights.handleSelectionChange(selection, false);
+  };
+
+  private readonly handleMouseUp = () => {
+    // To handle double-click outside of text creating comments, we check on
+    // mouse-up if there's a selection that just covers a line change. We
+    // can't do that on selection change since the user may still be dragging.
+    const selection = this.getShadowOrDocumentSelection();
+    this.highlights.handleSelectionChange(selection, true);
+  };
+
+  /** Gets the current selection, preferring the shadow DOM selection. */
+  private getShadowOrDocumentSelection() {
+    // When using native shadow DOM, the selection returned by
+    // document.getSelection() cannot reference the actual DOM elements making
+    // up the diff in Safari because they are in the shadow DOM of the gr-diff
+    // element. This takes the shadow DOM selection if one exists.
+    return this.shadowRoot?.getSelection
+      ? this.shadowRoot.getSelection()
+      : isSafari()
+      ? getContentEditableRange()
+      : document.getSelection();
+  }
+
+  private updateRanges(
+    addedThreadEls: GrDiffThreadElement[],
+    removedThreadEls: GrDiffThreadElement[]
+  ) {
+    function commentRangeFromThreadEl(
+      threadEl: GrDiffThreadElement
+    ): CommentRangeLayer | undefined {
+      const side = getSide(threadEl);
+      if (!side) return undefined;
+      const range = getRange(threadEl);
+      if (!range) return undefined;
+
+      return {side, range, rootId: threadEl.rootId};
+    }
+
+    // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
+    const addedCommentRanges = addedThreadEls
+      .map(commentRangeFromThreadEl)
+      .filter(range => !!range) as CommentRangeLayer[];
+    const removedCommentRanges = removedThreadEls
+      .map(commentRangeFromThreadEl)
+      .filter(range => !!range) as CommentRangeLayer[];
+    for (const removedCommentRange of removedCommentRanges) {
+      const i = this.commentRanges.findIndex(
+        cr =>
+          cr.side === removedCommentRange.side &&
+          rangesEqual(cr.range, removedCommentRange.range)
+      );
+      this.commentRanges.splice(i, 1);
+    }
+
+    if (addedCommentRanges?.length) {
+      this.commentRanges.push(...addedCommentRanges);
+    }
+    if (this.highlightRange) {
+      this.commentRanges.push({
+        side: Side.RIGHT,
+        range: this.highlightRange,
+        rootId: '',
+      });
+    }
+
+    this.diffBuilder.updateCommentRanges(this.commentRanges);
+  }
+
+  /**
+   * The key locations based on the comments and line of interests,
+   * where lines should not be collapsed.
+   *
+   */
+  private computeKeyLocations() {
+    const keyLocations: KeyLocations = {left: {}, right: {}};
+    if (this.lineOfInterest) {
+      const side = this.lineOfInterest.side;
+      keyLocations[side][this.lineOfInterest.lineNum] = true;
+    }
+    const threadEls = [...this.childNodes].filter(isThreadEl);
+
+    for (const threadEl of threadEls) {
+      const side = getSide(threadEl);
+      if (!side) continue;
+      const lineNum = getLine(threadEl);
+      const commentRange = getRange(threadEl);
+      keyLocations[side][lineNum] = true;
+      // Add start_line as well if exists,
+      // the being and end of the range should not be collapsed.
+      if (commentRange?.start_line) {
+        keyLocations[side][commentRange.start_line] = true;
+      }
+    }
+    return keyLocations;
+  }
+
+  // Dispatch events that are handled by the gr-diff-highlight.
+  private redispatchHoverEvents(
+    hoverEl: HTMLElement,
+    threadEl: GrDiffThreadElement
+  ) {
+    hoverEl.addEventListener('mouseenter', () => {
+      fire(threadEl, 'comment-thread-mouseenter', {});
+    });
+    hoverEl.addEventListener('mouseleave', () => {
+      fire(threadEl, 'comment-thread-mouseleave', {});
+    });
+  }
+
+  /** Cancel any remaining diff builder rendering work. */
+  cancel() {
+    this.diffBuilder.cleanup();
+    this.renderDiffTableTask?.cancel();
+  }
+
+  getCursorStops(): Array<HTMLElement | AbortStop> {
+    if (this.hidden && this.noAutoRender) return [];
+
+    // Get rendered stops.
+    const stops: Array<HTMLElement | AbortStop> =
+      this.diffBuilder.getLineNumberRows();
+
+    // If we are still loading this diff, abort after the rendered stops to
+    // avoid skipping over to e.g. the next file.
+    if (this.loading) {
+      stops.push(new AbortStop());
+    }
+    return stops;
+  }
+
+  isRangeSelected() {
+    return !!this.highlights.selectedRange;
+  }
+
+  toggleLeftDiff() {
+    toggleClass(this, 'no-left');
+  }
+
+  private blameChanged() {
+    this.diffBuilder.setBlame(this.blame);
+    if (this.blame) {
+      this.classList.add('showBlame');
+    } else {
+      this.classList.remove('showBlame');
+    }
+  }
+
+  // Private but used in tests.
+  handleTap(e: Event) {
+    const el = e.target as Element;
+
+    if (
+      el.getAttribute('data-value') !== LOST &&
+      (el.classList.contains('lineNum') ||
+        el.classList.contains('lineNumButton'))
+    ) {
+      this.addDraftAtLine(el);
+    } else if (
+      el.tagName === 'HL' ||
+      el.classList.contains('content') ||
+      el.classList.contains('contentText')
+    ) {
+      const target = getLineElByChild(el);
+      if (target) {
+        this.selectLine(target);
+      }
+    }
+  }
+
+  // Private but used in tests.
+  selectLine(el: Element) {
+    const lineNumber = Number(el.getAttribute('data-value'));
+    const side = el.classList.contains('left') ? Side.LEFT : Side.RIGHT;
+    this.dispatchSelectedLine(lineNumber, side);
+  }
+
+  private dispatchSelectedLine(number: LineNumber, side: Side) {
+    fire(this, 'line-selected', {
+      number,
+      side,
+      path: this.path,
+    });
+  }
+
+  addDraftAtLine(el: Element) {
+    this.selectLine(el);
+
+    const lineNum = getLineNumber(el);
+    if (lineNum === null) {
+      fireAlert(this, 'Invalid line number');
+      return;
+    }
+
+    this.createComment(el, lineNum);
+  }
+
+  createRangeComment() {
+    if (!this.isRangeSelected()) {
+      throw Error('Selection is needed for new range comment');
+    }
+    const selectedRange = this.highlights.selectedRange;
+    if (!selectedRange) throw Error('selected range not set');
+    const {side, range} = selectedRange;
+    this.createCommentForSelection(side, range);
+  }
+
+  createCommentForSelection(side: Side, range: CommentRange) {
+    const lineNum = range.end_line;
+    const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
+    if (lineEl) {
+      this.createComment(lineEl, lineNum, side, range);
+    }
+  }
+
+  private handleCreateRangeComment(
+    e: CustomEvent<CreateRangeCommentEventDetail>
+  ) {
+    const range = e.detail.range;
+    const side = e.detail.side;
+    this.createCommentForSelection(side, range);
+  }
+
+  // Private but used in tests.
+  createComment(
+    lineEl: Element,
+    lineNum: LineNumber,
+    side?: Side,
+    range?: CommentRange
+  ) {
+    const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+    if (!contentEl) throw new Error('content el not found for line el');
+    side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
+    fire(this, 'create-comment', {
+      side,
+      lineNum,
+      range,
+    });
+  }
+
+  private getCommentSideByLineAndContent(
+    lineEl: Element,
+    contentEl: Element
+  ): Side {
+    return lineEl.classList.contains(Side.LEFT) ||
+      contentEl.classList.contains('remove')
+      ? Side.LEFT
+      : Side.RIGHT;
+  }
+
+  private lineOfInterestChanged() {
+    if (this.loading) return;
+    if (!this.lineOfInterest) return;
+    const lineNum = this.lineOfInterest.lineNum;
+    if (typeof lineNum !== 'number') return;
+    this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
+  }
+
+  private cleanup() {
+    this.cancel();
+    this.blame = null;
+    this.safetyBypass = null;
+    this.showWarning = false;
+    this.clearDiffContent();
+  }
+
+  private prefsChanged() {
+    if (!this.prefs) return;
+    this.diffModel.updateState({diffPrefs: this.prefs});
+
+    this.blame = null;
+    this.updatePreferenceStyles();
+
+    if (this.diff && !this.noRenderOnPrefsChange) {
+      this.debounceRenderDiffTable();
+    }
+  }
+
+  private updatePreferenceStyles() {
+    assertIsDefined(this.prefs, 'prefs');
+    const lineLength =
+      this.path === COMMIT_MSG_PATH
+        ? COMMIT_MSG_LINE_LENGTH
+        : this.prefs.line_length;
+    const sideBySide = this.viewMode === 'SIDE_BY_SIDE';
+
+    const responsiveMode = getResponsiveMode(this.prefs, this.renderPrefs);
+    const responsive = isResponsive(responsiveMode);
+    this.diffTableClass = responsive ? 'responsive' : '';
+    const lineLimit = `${lineLength}ch`;
+    this.style.setProperty(
+      '--line-limit-marker',
+      responsiveMode === 'FULL_RESPONSIVE' ? lineLimit : '-1px'
+    );
+    this.style.setProperty('--content-width', responsive ? 'none' : lineLimit);
+    if (responsiveMode === 'SHRINK_ONLY') {
+      // Calculating ideal (initial) width for the whole table including
+      // width of each table column (content and line number columns) and
+      // border. We also add a 1px correction as some values are calculated
+      // in 'ch'.
+
+      // We might have 1 to 2 columns for content depending if side-by-side
+      // or unified mode
+      const contentWidth = `${sideBySide ? 2 : 1} * ${lineLimit}`;
+
+      // We always have 2 columns for line number
+      const lineNumberWidth = `2 * ${getLineNumberCellWidth(this.prefs)}px`;
+
+      // border-right in ".section" css definition (in gr-diff_html.ts)
+      const sectionRightBorder = '1px';
+
+      // each sign col has 1ch width.
+      const signColsWidth =
+        sideBySide && this.renderPrefs?.show_sign_col ? '2ch' : '0ch';
+
+      // As some of these calculations are done using 'ch' we end up having <1px
+      // difference between ideal and calculated size for each side leading to
+      // lines using the max columns (e.g. 80) to wrap (decided exclusively by
+      // the browser).This happens even in monospace fonts. Empirically adding
+      // 2px as correction to be sure wrapping won't happen in these cases so it
+      // doesn't block further experimentation with the SHRINK_MODE. This was
+      // previously set to 1px but due to to a more aggressive text wrapping
+      // (via word-break: break-all; - check .contextText) we need to be even
+      // more lenient in some cases. If we find another way to avoid this
+      // correction we will change it.
+      const dontWrapCorrection = '2px';
+      this.style.setProperty(
+        '--diff-max-width',
+        `calc(${contentWidth} + ${lineNumberWidth} + ${signColsWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`
+      );
+    } else {
+      this.style.setProperty('--diff-max-width', 'none');
+    }
+    if (this.prefs.font_size) {
+      this.style.setProperty('--font-size', `${this.prefs.font_size}px`);
+    }
+  }
+
+  private renderPrefsChanged() {
+    this.diffModel.updateState({renderPrefs: this.renderPrefs});
+    if (this.renderPrefs.hide_left_side) {
+      this.classList.add('no-left');
+    }
+    if (this.renderPrefs.disable_context_control_buttons) {
+      this.classList.add('disable-context-control-buttons');
+    }
+    if (this.renderPrefs.hide_line_length_indicator) {
+      this.classList.add('hide-line-length-indicator');
+    }
+    if (this.renderPrefs.show_sign_col) {
+      this.classList.add('with-sign-col');
+    }
+    if (this.prefs) {
+      this.updatePreferenceStyles();
+    }
+    this.diffBuilder.updateRenderPrefs(this.renderPrefs);
+  }
+
+  private diffChanged() {
+    this.loading = true;
+    this.cleanup();
+    if (this.diff) {
+      this.diffLength = this.getDiffLength(this.diff);
+      this.debounceRenderDiffTable();
+      assertIsDefined(this.diffTable, 'diffTable');
+      this.diffSelection.init(this.diff, this.diffTable);
+      this.highlights.init(this.diffTable, this.diffBuilder);
+    }
+  }
+
+  // Implemented so the test can stub it.
+  getDiffLength(diff?: DiffInfo) {
+    return getDiffLength(diff);
+  }
+
+  /**
+   * When called multiple times from the same task, will call
+   * _renderDiffTable only once, in the next task (scheduled via `setTimeout`).
+   *
+   * This should be used instead of calling _renderDiffTable directly to
+   * render the diff in response to an input change, because there may be
+   * multiple inputs changing in the same microtask, but we only want to
+   * render once.
+   */
+  private debounceRenderDiffTable() {
+    // at this point gr-diff might be considered as rendered from the outside
+    // (client), although it was not actually rendered. Clients need to know
+    // when it is safe to perform operations like cursor moves, for example,
+    // and if changing an input actually requires a reload of the diff table.
+    // Since `fire` is synchronous it allows clients to be aware when an
+    // async render is needed and that they can wait for a further `render`
+    // event to actually take further action.
+    fire(this, 'render-required', {});
+    this.renderDiffTableTask = debounceP(
+      this.renderDiffTableTask,
+      async () => await this.renderDiffTable()
+    );
+    this.renderDiffTableTask.catch((e: unknown) => {
+      if (e === DELAYED_CANCELLATION) return;
+      throw e;
+    });
+  }
+
+  // Private but used in tests.
+  async renderDiffTable() {
+    this.unobserveNodes();
+    if (!this.diff || !this.prefs) {
+      fire(this, 'render', {});
+      return;
+    }
+    if (
+      this.prefs.context === -1 &&
+      this.diffLength &&
+      this.diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
+      this.safetyBypass === null
+    ) {
+      this.showWarning = true;
+      fire(this, 'render', {});
+      return;
+    }
+
+    this.showWarning = false;
+
+    const keyLocations = this.computeKeyLocations();
+
+    this.diffModel.setState({
+      diff: this.diff,
+      path: this.path,
+      renderPrefs: this.renderPrefs,
+      diffPrefs: this.prefs,
+    });
+
+    // TODO: Setting tons of public properties like this is obviously a code
+    // smell. We are introducing a diff model for managing all this
+    // data. Then diff builder will only need access to that model.
+    this.diffBuilder.prefs = this.getBypassPrefs();
+    this.diffBuilder.renderPrefs = this.renderPrefs;
+    this.diffBuilder.diff = this.diff;
+    this.diffBuilder.path = this.path;
+    this.diffBuilder.viewMode = this.viewMode;
+    this.diffBuilder.layers = this.layers ?? [];
+    this.diffBuilder.isImageDiff = this.isImageDiff;
+    this.diffBuilder.baseImage = this.baseImage ?? null;
+    this.diffBuilder.revisionImage = this.revisionImage ?? null;
+    this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
+    this.diffBuilder.diffElement = this.diffTable;
+    // `this.commentRanges` are probably empty here, because they will only be
+    // populated by the node observer, which starts observing *after* rendering.
+    this.diffBuilder.updateCommentRanges(this.commentRanges);
+    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+    await this.diffBuilder.render(keyLocations);
+  }
+
+  private handleRenderContent() {
+    this.querySelectorAll('gr-ranged-comment-hint').forEach(element =>
+      element.remove()
+    );
+    this.loading = false;
+    this.observeNodes();
+    // We are just converting 'render-content' into 'render' here. Maybe we
+    // should retire the 'render' event in favor of 'render-content'?
+    fire(this, 'render', {});
+  }
+
+  private observeNodes() {
+    // First stop observing old nodes.
+    this.unobserveNodes();
+    // Then introduce a Mutation observer that watches for children being added
+    // to gr-diff. If those children are `isThreadEl`, namely then they are
+    // processed.
+    this.nodeObserver = new MutationObserver(mutations => {
+      const addedThreadEls = extractAddedNodes(mutations).filter(isThreadEl);
+      const removedThreadEls =
+        extractRemovedNodes(mutations).filter(isThreadEl);
+      this.processNodes(addedThreadEls, removedThreadEls);
+    });
+    this.nodeObserver.observe(this, {childList: true});
+    // Make sure to process existing gr-comment-threads that already exist.
+    this.processNodes([...this.childNodes].filter(isThreadEl), []);
+  }
+
+  private processNodes(
+    addedThreadEls: GrDiffThreadElement[],
+    removedThreadEls: GrDiffThreadElement[]
+  ) {
+    this.updateRanges(addedThreadEls, removedThreadEls);
+    addedThreadEls.forEach(threadEl =>
+      this.redispatchHoverEvents(threadEl, threadEl)
+    );
+    // Removed nodes do not need to be handled because all this code does is
+    // adding a slot for the added thread elements, and the extra slots do
+    // not hurt. It's probably a bigger performance cost to remove them than
+    // to keep them around. Medium term we can even consider to add one slot
+    // for each line from the start.
+    for (const threadEl of addedThreadEls) {
+      const lineNum = getLine(threadEl);
+      const commentSide = getSide(threadEl);
+      const range = getRange(threadEl);
+      if (!commentSide) continue;
+      const lineEl = this.diffBuilder.getLineElByNumber(lineNum, commentSide);
+      // When the line the comment refers to does not exist, log an error
+      // but don't crash. This can happen e.g. if the API does not fully
+      // validate e.g. (robot) comments
+      if (!lineEl) {
+        console.error(
+          'thread attached to line ',
+          commentSide,
+          lineNum,
+          ' which does not exist.'
+        );
+        continue;
+      }
+      const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+      if (!contentEl) continue;
+      if (lineNum === LOST) {
+        this.insertPortedCommentsWithoutRangeMessage(contentEl);
+      }
+
+      const slotAtt = threadEl.getAttribute('slot');
+      if (range && isLongCommentRange(range) && slotAtt) {
+        const longRangeCommentHint = document.createElement(
+          'gr-ranged-comment-hint'
+        );
+        longRangeCommentHint.range = range;
+        longRangeCommentHint.setAttribute('threadElRootId', threadEl.rootId);
+        longRangeCommentHint.setAttribute('slot', slotAtt);
+        this.insertBefore(longRangeCommentHint, threadEl);
+        this.redispatchHoverEvents(longRangeCommentHint, threadEl);
+      }
+    }
+
+    for (const threadEl of removedThreadEls) {
+      this.querySelector(
+        `gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
+      )?.remove();
+    }
+  }
+
+  private unobserveNodes() {
+    if (this.nodeObserver) {
+      this.nodeObserver.disconnect();
+      this.nodeObserver = undefined;
+    }
+    // You only stop observing for comment thread elements when the diff is
+    // completely rendered from scratch. And then comment thread elements
+    // will be (re-)added *after* rendering is done. That is also when we
+    // re-start observing. So it is appropriate to thoroughly clean up
+    // everything that the observer is managing.
+    this.commentRanges = [];
+  }
+
+  private insertPortedCommentsWithoutRangeMessage(lostCell: Element) {
+    const existingMessage = lostCell.querySelector('div.lost-message');
+    if (existingMessage) return;
+
+    const div = document.createElement('div');
+    div.className = 'lost-message';
+    const icon = document.createElement('gr-icon');
+    icon.setAttribute('icon', 'info');
+    div.appendChild(icon);
+    const span = document.createElement('span');
+    span.innerText = 'Original comment position not found in this patchset';
+    div.appendChild(span);
+    lostCell.insertBefore(div, lostCell.firstChild);
+  }
+
+  /**
+   * Get the preferences object including the safety bypass context (if any).
+   */
+  private getBypassPrefs() {
+    assertIsDefined(this.prefs, 'prefs');
+    if (this.safetyBypass !== null) {
+      return {...this.prefs, context: this.safetyBypass};
+    }
+    return this.prefs;
+  }
+
+  clearDiffContent() {
+    this.unobserveNodes();
+    if (!this.diffTable) return;
+    while (this.diffTable.hasChildNodes()) {
+      this.diffTable.removeChild(this.diffTable.lastChild!);
+    }
+  }
+
+  // Private but used in tests.
+  computeDiffHeaderItems() {
+    return (this.diff?.diff_header ?? [])
+      .filter(
+        item =>
+          !(
+            item.startsWith('diff --git ') ||
+            item.startsWith('index ') ||
+            item.startsWith('+++ ') ||
+            item.startsWith('--- ') ||
+            item === 'Binary files differ'
+          )
+      )
+      .map(expandFileMode);
+  }
+
+  private handleFullBypass() {
+    this.safetyBypass = FULL_CONTEXT;
+    this.debounceRenderDiffTable();
+  }
+
+  private collapseContext() {
+    // Uses the default context amount if the preference is for the entire file.
+    this.safetyBypass =
+      this.prefs?.context && this.prefs.context >= 0
+        ? null
+        : createDefaultDiffPrefs().context;
+    this.debounceRenderDiffTable();
+  }
+
+  toggleAllContext() {
+    if (!this.prefs) {
+      return;
+    }
+    if (this.getBypassPrefs().context < 0) {
+      this.collapseContext();
+    } else {
+      this.handleFullBypass();
+    }
+  }
+
+  private computeNewlineWarning(): string | undefined {
+    const messages = [];
+    if (this.showNewlineWarningLeft) {
+      messages.push(NO_NEWLINE_LEFT);
+    }
+    if (this.showNewlineWarningRight) {
+      messages.push(NO_NEWLINE_RIGHT);
+    }
+    if (!messages.length) {
+      return undefined;
+    }
+    return messages.join(' \u2014 '); // \u2014 - '—'
+  }
+}
+
+function extractAddedNodes(mutations: MutationRecord[]) {
+  return mutations.flatMap(mutation => [...mutation.addedNodes]);
+}
+
+function extractRemovedNodes(mutations: MutationRecord[]) {
+  return mutations.flatMap(mutation => [...mutation.removedNodes]);
+}
+
+if (!isNewDiff()) {
+  customElements.define('gr-diff', GrDiff);
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff': LitElement;
+  }
+  interface HTMLElementEventMap {
+    'comment-thread-mouseenter': CustomEvent<{}>;
+    'comment-thread-mouseleave': CustomEvent<{}>;
+    'loading-changed': ValueChangedEvent<boolean>;
+    'render-required': CustomEvent<{}>;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff_test.ts
new file mode 100644
index 0000000..645a64a
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff-old/gr-diff/gr-diff_test.ts
@@ -0,0 +1,4184 @@
+/**
+ * @license
+ * Copyright 2015 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {createDiff} from '../../../test/test-data-generators';
+import './gr-diff';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import '@polymer/paper-button/paper-button';
+import {
+  DiffContent,
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffViewMode,
+  IgnoreWhitespaceType,
+  Side,
+} from '../../../api/diff';
+import {
+  mockPromise,
+  mouseDown,
+  query,
+  queryAll,
+  queryAndAssert,
+  waitEventLoop,
+  waitQueryAndAssert,
+  waitUntil,
+} from '../../../test/test-utils';
+import {AbortStop} from '../../../api/core';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {GrDiff} from './gr-diff';
+import {ImageInfo} from '../../../types/common';
+import {GrRangedCommentHint} from '../../diff/gr-ranged-comment-hint/gr-ranged-comment-hint';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-diff a11y test', () => {
+  test('audit', async () => {
+    assert.isAccessible(await fixture(html`<gr-diff></gr-diff>`));
+  });
+});
+
+suite('gr-diff tests', () => {
+  let element: GrDiff;
+
+  const MINIMAL_PREFS: DiffPreferencesInfo = {
+    tab_size: 2,
+    line_length: 80,
+    font_size: 12,
+    context: 3,
+    ignore_whitespace: 'IGNORE_NONE',
+  };
+
+  setup(async () => {
+    element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+  });
+
+  suite('rendering', () => {
+    test('empty diff', async () => {
+      await element.updateComplete;
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer sideBySide">
+            <table id="diffTable"></table>
+          </div>
+        `
+      );
+    });
+
+    test('a unified diff lit', async () => {
+      element.viewMode = DiffViewMode.UNIFIED;
+      element.prefs = {...MINIMAL_PREFS};
+      element.diff = createDiff();
+      await element.updateComplete;
+      await waitForEventOnce(element, 'render');
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer unified">
+            <table class="selected-right" id="diffTable">
+              <colgroup>
+                <col class="blame gr-diff" />
+                <col class="gr-diff" width="48" />
+                <col class="gr-diff" width="48" />
+                <col class="gr-diff" />
+              </colgroup>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-LOST right-button-LOST right-content-LOST"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="LOST"></td>
+                  <td class="gr-diff left lineNum" data-value="LOST"></td>
+                  <td class="gr-diff lineNum right" data-value="LOST"></td>
+                  <td class="both content gr-diff lost no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-FILE right-button-FILE right-content-FILE"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="FILE"></td>
+                  <td class="gr-diff left lineNum" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff left lineNumButton"
+                      data-value="FILE"
+                      id="left-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff lineNumButton right"
+                      data-value="FILE"
+                      id="right-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="both content file gr-diff no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-1 right-button-1 right-content-1"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="1"></td>
+                  <td class="gr-diff left lineNum" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="1"
+                      id="left-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="1"
+                      id="right-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-1"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-2 right-button-2 right-content-2"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="2"></td>
+                  <td class="gr-diff left lineNum" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="2"
+                      id="left-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="2"
+                      id="right-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-2"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-3 right-button-3 right-content-3"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="3"></td>
+                  <td class="gr-diff left lineNum" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="3"
+                      id="left-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="3"
+                      id="right-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-3"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-4 right-button-4 right-content-4"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="4"></td>
+                  <td class="gr-diff left lineNum" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="4"
+                      id="left-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="4"
+                      id="right-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-4"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-5 right-content-5"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="5">
+                    <button
+                      aria-label="5 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="5"
+                      id="right-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-5"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-6 right-content-6"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="6">
+                    <button
+                      aria-label="6 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="6"
+                      id="right-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-6"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-7 right-content-7"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="7">
+                    <button
+                      aria-label="7 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="7"
+                      id="right-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-7"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-5 right-button-8 right-content-8"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="5"></td>
+                  <td class="gr-diff left lineNum" data-value="5">
+                    <button
+                      aria-label="5 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="5"
+                      id="left-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="8"
+                      id="right-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-8"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-6 right-button-9 right-content-9"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="6"></td>
+                  <td class="gr-diff left lineNum" data-value="6">
+                    <button
+                      aria-label="6 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="6"
+                      id="left-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="9"
+                      id="right-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-9"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-7 right-button-10 right-content-10"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="7"></td>
+                  <td class="gr-diff left lineNum" data-value="7">
+                    <button
+                      aria-label="7 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="7"
+                      id="left-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="10">
+                    <button
+                      aria-label="10 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="10"
+                      id="right-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-8 right-button-11 right-content-11"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="8"></td>
+                  <td class="gr-diff left lineNum" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="8"
+                      id="left-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="11">
+                    <button
+                      aria-label="11 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="11"
+                      id="right-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-9 right-button-12 right-content-12"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="9"></td>
+                  <td class="gr-diff left lineNum" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="9"
+                      id="left-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="12">
+                    <button
+                      aria-label="12 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="12"
+                      id="right-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="left-button-10 left-content-10"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="10"></td>
+                  <td class="gr-diff left lineNum" data-value="10">
+                    <button
+                      aria-label="10 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="10"
+                      id="left-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-11 left-content-11"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="11"></td>
+                  <td class="gr-diff left lineNum" data-value="11">
+                    <button
+                      aria-label="11 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="11"
+                      id="left-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-12 left-content-12"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="12"></td>
+                  <td class="gr-diff left lineNum" data-value="12">
+                    <button
+                      aria-label="12 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="12"
+                      id="left-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-13 left-content-13"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="13"></td>
+                  <td class="gr-diff left lineNum" data-value="13">
+                    <button
+                      aria-label="13 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="13"
+                      id="left-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+                <tr
+                  aria-labelledby="right-button-13 right-content-13"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="13">
+                    <button
+                      aria-label="13 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="13"
+                      id="right-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-14 right-content-14"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="14">
+                    <button
+                      aria-label="14 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="14"
+                      id="right-button-14"
+                      tabindex="-1"
+                    >
+                      14
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-14"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section">
+                <tr
+                  aria-labelledby="left-button-16 left-content-16"
+                  class="diff-row gr-diff remove unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="16"></td>
+                  <td class="gr-diff left lineNum" data-value="16">
+                    <button
+                      aria-label="16 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="16"
+                      id="left-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="gr-diff right"></td>
+                  <td class="content gr-diff left remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-15 right-content-15"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="15">
+                    <button
+                      aria-label="15 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="15"
+                      id="right-button-15"
+                      tabindex="-1"
+                    >
+                      15
+                    </button>
+                  </td>
+                  <td class="add content gr-diff right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-15"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-17 right-button-16 right-content-16"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="17"></td>
+                  <td class="gr-diff left lineNum" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="17"
+                      id="left-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="16">
+                    <button
+                      aria-label="16 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="16"
+                      id="right-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-18 right-button-17 right-content-17"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="18"></td>
+                  <td class="gr-diff left lineNum" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="18"
+                      id="left-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="17"
+                      id="right-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-17"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-19 right-button-18 right-content-18"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="19"></td>
+                  <td class="gr-diff left lineNum" data-value="19">
+                    <button
+                      aria-label="19 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="19"
+                      id="left-button-19"
+                      tabindex="-1"
+                    >
+                      19
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="18"
+                      id="right-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-18"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="contextControl gr-diff section">
+                <tr class="above contextBackground gr-diff unified">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+                <tr class="dividerRow gr-diff show-both">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="dividerCell gr-diff" colspan="3">
+                    <gr-context-controls class="gr-diff" showconfig="both">
+                    </gr-context-controls>
+                  </td>
+                </tr>
+                <tr class="below contextBackground gr-diff unified">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-38 right-button-37 right-content-37"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="38"></td>
+                  <td class="gr-diff left lineNum" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="38"
+                      id="left-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="37">
+                    <button
+                      aria-label="37 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="37"
+                      id="right-button-37"
+                      tabindex="-1"
+                    >
+                      37
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-37"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-39 right-button-38 right-content-38"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="39"></td>
+                  <td class="gr-diff left lineNum" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="39"
+                      id="left-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="38"
+                      id="right-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-38"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-40 right-button-39 right-content-39"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="40"></td>
+                  <td class="gr-diff left lineNum" data-value="40">
+                    <button
+                      aria-label="40 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="40"
+                      id="left-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="39"
+                      id="right-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-39"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-40 right-content-40"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="40">
+                    <button
+                      aria-label="40 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="40"
+                      id="right-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-40"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-41 right-content-41"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="41">
+                    <button
+                      aria-label="41 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="41"
+                      id="right-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-41"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-42 right-content-42"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="42">
+                    <button
+                      aria-label="42 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="42"
+                      id="right-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-42"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-43 right-content-43"
+                  class="add diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff left"></td>
+                  <td class="gr-diff lineNum right" data-value="43">
+                    <button
+                      aria-label="43 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="43"
+                      id="right-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-43"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-41 right-button-44 right-content-44"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="41"></td>
+                  <td class="gr-diff left lineNum" data-value="41">
+                    <button
+                      aria-label="41 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="41"
+                      id="left-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="44"
+                      id="right-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-44"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-42 right-button-45 right-content-45"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="42"></td>
+                  <td class="gr-diff left lineNum" data-value="42">
+                    <button
+                      aria-label="42 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="42"
+                      id="left-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="45"
+                      id="right-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-45"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-43 right-button-46 right-content-46"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="43"></td>
+                  <td class="gr-diff left lineNum" data-value="43">
+                    <button
+                      aria-label="43 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="43"
+                      id="left-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="46">
+                    <button
+                      aria-label="46 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="46"
+                      id="right-button-46"
+                      tabindex="-1"
+                    >
+                      46
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-46"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-44 right-button-47 right-content-47"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="44"></td>
+                  <td class="gr-diff left lineNum" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="44"
+                      id="left-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="47">
+                    <button
+                      aria-label="47 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="47"
+                      id="right-button-47"
+                      tabindex="-1"
+                    >
+                      47
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-47"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-45 right-button-48 right-content-48"
+                  class="both diff-row gr-diff unified"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="45"></td>
+                  <td class="gr-diff left lineNum" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="45"
+                      id="left-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="48">
+                    <button
+                      aria-label="48 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="48"
+                      id="right-button-48"
+                      tabindex="-1"
+                    >
+                      48
+                    </button>
+                  </td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-48"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        `,
+        {
+          ignoreTags: [
+            'gr-context-controls-section',
+            'gr-diff-section',
+            'gr-diff-row',
+            'gr-diff-text',
+            'gr-legacy-text',
+            'slot',
+          ],
+        }
+      );
+    });
+
+    test('a normal diff lit', async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.diff = createDiff();
+      await element.updateComplete;
+      await waitForEventOnce(element, 'render');
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <div class="diffContainer sideBySide">
+            <table class="selected-right" id="diffTable">
+              <colgroup>
+                <col class="blame gr-diff" />
+                <col class="gr-diff left" width="48" />
+                <col class="gr-diff left sign" />
+                <col class="gr-diff left" />
+                <col class="gr-diff right" width="48" />
+                <col class="gr-diff right sign" />
+                <col class="gr-diff right" />
+              </colgroup>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-LOST left-content-LOST right-button-LOST right-content-LOST"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="LOST"></td>
+                  <td class="gr-diff left lineNum" data-value="LOST"></td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left lost no-intraline-info">
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="LOST"></td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff lost no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="FILE"></td>
+                  <td class="gr-diff left lineNum" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff left lineNumButton"
+                      data-value="FILE"
+                      id="left-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content file gr-diff left no-intraline-info">
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="FILE">
+                    <button
+                      aria-label="Add file comment"
+                      class="gr-diff lineNumButton right"
+                      data-value="FILE"
+                      id="right-button-FILE"
+                      tabindex="-1"
+                    >
+                      File
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content file gr-diff no-intraline-info right">
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="1"></td>
+                  <td class="gr-diff left lineNum" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="1"
+                      id="left-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-1"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="1">
+                    <button
+                      aria-label="1 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="1"
+                      id="right-button-1"
+                      tabindex="-1"
+                    >
+                      1
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-1"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-2 left-content-2 right-button-2 right-content-2"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="2"></td>
+                  <td class="gr-diff left lineNum" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="2"
+                      id="left-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-2"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="2">
+                    <button
+                      aria-label="2 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="2"
+                      id="right-button-2"
+                      tabindex="-1"
+                    >
+                      2
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-2"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-3 left-content-3 right-button-3 right-content-3"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="3"></td>
+                  <td class="gr-diff left lineNum" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="3"
+                      id="left-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-3"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="3">
+                    <button
+                      aria-label="3 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="3"
+                      id="right-button-3"
+                      tabindex="-1"
+                    >
+                      3
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-3"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-4 left-content-4 right-button-4 right-content-4"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="4"></td>
+                  <td class="gr-diff left lineNum" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="4"
+                      id="left-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-4"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="4">
+                    <button
+                      aria-label="4 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="4"
+                      id="right-button-4"
+                      tabindex="-1"
+                    >
+                      4
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-4"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-5 right-content-5"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="5">
+                    <button
+                      aria-label="5 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="5"
+                      id="right-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-5"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-6 right-content-6"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="6">
+                    <button
+                      aria-label="6 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="6"
+                      id="right-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-6"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-7 right-content-7"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="7">
+                    <button
+                      aria-label="7 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="7"
+                      id="right-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-7"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-5 left-content-5 right-button-8 right-content-8"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="5"></td>
+                  <td class="gr-diff left lineNum" data-value="5">
+                    <button
+                      aria-label="5 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="5"
+                      id="left-button-5"
+                      tabindex="-1"
+                    >
+                      5
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-5"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="8"
+                      id="right-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-8"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-6 left-content-6 right-button-9 right-content-9"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="6"></td>
+                  <td class="gr-diff left lineNum" data-value="6">
+                    <button
+                      aria-label="6 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="6"
+                      id="left-button-6"
+                      tabindex="-1"
+                    >
+                      6
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-6"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="9"
+                      id="right-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-9"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-7 left-content-7 right-button-10 right-content-10"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="7"></td>
+                  <td class="gr-diff left lineNum" data-value="7">
+                    <button
+                      aria-label="7 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="7"
+                      id="left-button-7"
+                      tabindex="-1"
+                    >
+                      7
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-7"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="10">
+                    <button
+                      aria-label="10 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="10"
+                      id="right-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-8 left-content-8 right-button-11 right-content-11"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="8"></td>
+                  <td class="gr-diff left lineNum" data-value="8">
+                    <button
+                      aria-label="8 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="8"
+                      id="left-button-8"
+                      tabindex="-1"
+                    >
+                      8
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-8"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="11">
+                    <button
+                      aria-label="11 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="11"
+                      id="right-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-9 left-content-9 right-button-12 right-content-12"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="9"></td>
+                  <td class="gr-diff left lineNum" data-value="9">
+                    <button
+                      aria-label="9 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="9"
+                      id="left-button-9"
+                      tabindex="-1"
+                    >
+                      9
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-9"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="12">
+                    <button
+                      aria-label="12 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="12"
+                      id="right-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="left-button-10 left-content-10"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="10"></td>
+                  <td class="gr-diff left lineNum" data-value="10">
+                    <button
+                      aria-label="10 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="10"
+                      id="left-button-10"
+                      tabindex="-1"
+                    >
+                      10
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-10"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-11 left-content-11"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="11"></td>
+                  <td class="gr-diff left lineNum" data-value="11">
+                    <button
+                      aria-label="11 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="11"
+                      id="left-button-11"
+                      tabindex="-1"
+                    >
+                      11
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-11"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-12 left-content-12"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="12"></td>
+                  <td class="gr-diff left lineNum" data-value="12">
+                    <button
+                      aria-label="12 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="12"
+                      id="left-button-12"
+                      tabindex="-1"
+                    >
+                      12
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-12"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-13 left-content-13"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="blank"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="13"></td>
+                  <td class="gr-diff left lineNum" data-value="13">
+                    <button
+                      aria-label="13 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="13"
+                      id="left-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="blankLineNum gr-diff right"></td>
+                  <td class="blank gr-diff no-intraline-info right sign"></td>
+                  <td class="blank gr-diff no-intraline-info right">
+                    <div class="contentText gr-diff" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+                <tr
+                  aria-labelledby="left-button-14 left-content-14 right-button-13 right-content-13"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="14"></td>
+                  <td class="gr-diff left lineNum" data-value="14">
+                    <button
+                      aria-label="14 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="14"
+                      id="left-button-14"
+                      tabindex="-1"
+                    >
+                      14
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-14"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="13">
+                    <button
+                      aria-label="13 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="13"
+                      id="right-button-13"
+                      tabindex="-1"
+                    >
+                      13
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-13"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-15 left-content-15 right-button-14 right-content-14"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="15"></td>
+                  <td class="gr-diff left lineNum" data-value="15">
+                    <button
+                      aria-label="15 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="15"
+                      id="left-button-15"
+                      tabindex="-1"
+                    >
+                      15
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info remove sign">-</td>
+                  <td class="content gr-diff left no-intraline-info remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-15"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="14">
+                    <button
+                      aria-label="14 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="14"
+                      id="right-button-14"
+                      tabindex="-1"
+                    >
+                      14
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-14"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section">
+                <tr
+                  aria-labelledby="left-button-16 left-content-16 right-button-15 right-content-15"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="remove"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="16"></td>
+                  <td class="gr-diff left lineNum" data-value="16">
+                    <button
+                      aria-label="16 removed"
+                      class="gr-diff left lineNumButton"
+                      data-value="16"
+                      id="left-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="gr-diff left remove sign">-</td>
+                  <td class="content gr-diff left remove">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="15">
+                    <button
+                      aria-label="15 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="15"
+                      id="right-button-15"
+                      tabindex="-1"
+                    >
+                      15
+                    </button>
+                  </td>
+                  <td class="add gr-diff right sign">+</td>
+                  <td class="add content gr-diff right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-15"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-17 left-content-17 right-button-16 right-content-16"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="17"></td>
+                  <td class="gr-diff left lineNum" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="17"
+                      id="left-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-17"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="16">
+                    <button
+                      aria-label="16 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="16"
+                      id="right-button-16"
+                      tabindex="-1"
+                    >
+                      16
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-16"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-18 left-content-18 right-button-17 right-content-17"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="18"></td>
+                  <td class="gr-diff left lineNum" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="18"
+                      id="left-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-18"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="17">
+                    <button
+                      aria-label="17 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="17"
+                      id="right-button-17"
+                      tabindex="-1"
+                    >
+                      17
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-17"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-19 left-content-19 right-button-18 right-content-18"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="19"></td>
+                  <td class="gr-diff left lineNum" data-value="19">
+                    <button
+                      aria-label="19 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="19"
+                      id="left-button-19"
+                      tabindex="-1"
+                    >
+                      19
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-19"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="18">
+                    <button
+                      aria-label="18 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="18"
+                      id="right-button-18"
+                      tabindex="-1"
+                    >
+                      18
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-18"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="contextControl gr-diff section">
+                <tr
+                  class="above contextBackground gr-diff side-by-side"
+                  left-type="contextControl"
+                  right-type="contextControl"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+                <tr class="dividerRow gr-diff show-both">
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="gr-diff"></td>
+                  <td class="dividerCell gr-diff" colspan="3">
+                    <gr-context-controls
+                      class="gr-diff"
+                      showconfig="both"
+                    ></gr-context-controls>
+                  </td>
+                </tr>
+                <tr
+                  class="below contextBackground gr-diff side-by-side"
+                  left-type="contextControl"
+                  right-type="contextControl"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                  <td class="contextLineNum gr-diff"></td>
+                  <td class="gr-diff sign"></td>
+                  <td class="gr-diff"></td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-38 left-content-38 right-button-37 right-content-37"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="38"></td>
+                  <td class="gr-diff left lineNum" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="38"
+                      id="left-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-38"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="37">
+                    <button
+                      aria-label="37 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="37"
+                      id="right-button-37"
+                      tabindex="-1"
+                    >
+                      37
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-37"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-39 left-content-39 right-button-38 right-content-38"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="39"></td>
+                  <td class="gr-diff left lineNum" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="39"
+                      id="left-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-39"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="38">
+                    <button
+                      aria-label="38 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="38"
+                      id="right-button-38"
+                      tabindex="-1"
+                    >
+                      38
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-38"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-40 left-content-40 right-button-39 right-content-39"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="40"></td>
+                  <td class="gr-diff left lineNum" data-value="40">
+                    <button
+                      aria-label="40 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="40"
+                      id="left-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-40"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="39">
+                    <button
+                      aria-label="39 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="39"
+                      id="right-button-39"
+                      tabindex="-1"
+                    >
+                      39
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-39"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="delta gr-diff section total">
+                <tr
+                  aria-labelledby="right-button-40 right-content-40"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="40">
+                    <button
+                      aria-label="40 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="40"
+                      id="right-button-40"
+                      tabindex="-1"
+                    >
+                      40
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-40"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-41 right-content-41"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="41">
+                    <button
+                      aria-label="41 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="41"
+                      id="right-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-41"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-42 right-content-42"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="42">
+                    <button
+                      aria-label="42 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="42"
+                      id="right-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-42"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="right-button-43 right-content-43"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="blank"
+                  right-type="add"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="0"></td>
+                  <td class="blankLineNum gr-diff left"></td>
+                  <td class="blank gr-diff left no-intraline-info sign"></td>
+                  <td class="blank gr-diff left no-intraline-info">
+                    <div class="contentText gr-diff" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="43">
+                    <button
+                      aria-label="43 added"
+                      class="gr-diff lineNumButton right"
+                      data-value="43"
+                      id="right-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="add gr-diff no-intraline-info right sign">+</td>
+                  <td class="add content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-43"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+              <tbody class="both gr-diff section">
+                <tr
+                  aria-labelledby="left-button-41 left-content-41 right-button-44 right-content-44"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="41"></td>
+                  <td class="gr-diff left lineNum" data-value="41">
+                    <button
+                      aria-label="41 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="41"
+                      id="left-button-41"
+                      tabindex="-1"
+                    >
+                      41
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-41"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="44"
+                      id="right-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-44"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-42 left-content-42 right-button-45 right-content-45"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="42"></td>
+                  <td class="gr-diff left lineNum" data-value="42">
+                    <button
+                      aria-label="42 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="42"
+                      id="left-button-42"
+                      tabindex="-1"
+                    >
+                      42
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-42"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="45"
+                      id="right-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-45"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-43 left-content-43 right-button-46 right-content-46"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="43"></td>
+                  <td class="gr-diff left lineNum" data-value="43">
+                    <button
+                      aria-label="43 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="43"
+                      id="left-button-43"
+                      tabindex="-1"
+                    >
+                      43
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-43"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="46">
+                    <button
+                      aria-label="46 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="46"
+                      id="right-button-46"
+                      tabindex="-1"
+                    >
+                      46
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-46"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-44 left-content-44 right-button-47 right-content-47"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="44"></td>
+                  <td class="gr-diff left lineNum" data-value="44">
+                    <button
+                      aria-label="44 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="44"
+                      id="left-button-44"
+                      tabindex="-1"
+                    >
+                      44
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-44"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="47">
+                    <button
+                      aria-label="47 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="47"
+                      id="right-button-47"
+                      tabindex="-1"
+                    >
+                      47
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-47"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+                <tr
+                  aria-labelledby="left-button-45 left-content-45 right-button-48 right-content-48"
+                  class="diff-row gr-diff side-by-side"
+                  left-type="both"
+                  right-type="both"
+                  tabindex="-1"
+                >
+                  <td class="blame gr-diff" data-line-number="45"></td>
+                  <td class="gr-diff left lineNum" data-value="45">
+                    <button
+                      aria-label="45 unmodified"
+                      class="gr-diff left lineNumButton"
+                      data-value="45"
+                      id="left-button-45"
+                      tabindex="-1"
+                    >
+                      45
+                    </button>
+                  </td>
+                  <td class="gr-diff left no-intraline-info sign"></td>
+                  <td class="both content gr-diff left no-intraline-info">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="left"
+                      id="left-content-45"
+                    ></div>
+                    <div class="thread-group" data-side="left"></div>
+                  </td>
+                  <td class="gr-diff lineNum right" data-value="48">
+                    <button
+                      aria-label="48 unmodified"
+                      class="gr-diff lineNumButton right"
+                      data-value="48"
+                      id="right-button-48"
+                      tabindex="-1"
+                    >
+                      48
+                    </button>
+                  </td>
+                  <td class="gr-diff no-intraline-info right sign"></td>
+                  <td class="both content gr-diff no-intraline-info right">
+                    <div
+                      class="contentText gr-diff"
+                      data-side="right"
+                      id="right-content-48"
+                    ></div>
+                    <div class="thread-group" data-side="right"></div>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        `,
+        {
+          ignoreTags: [
+            'gr-context-controls-section',
+            'gr-diff-section',
+            'gr-diff-row',
+            'gr-diff-text',
+            'gr-legacy-text',
+            'slot',
+          ],
+        }
+      );
+    });
+  });
+
+  suite('selectionchange event handling', () => {
+    let handleSelectionChangeStub: sinon.SinonSpy;
+
+    const emulateSelection = function () {
+      document.dispatchEvent(new CustomEvent('selectionchange'));
+    };
+
+    setup(async () => {
+      handleSelectionChangeStub = sinon.spy(
+        element.highlights,
+        'handleSelectionChange'
+      );
+    });
+
+    test('enabled if logged in', async () => {
+      element.loggedIn = true;
+      await element.updateComplete;
+      emulateSelection();
+      assert.isTrue(handleSelectionChangeStub.called);
+    });
+
+    test('ignored if logged out', async () => {
+      element.loggedIn = false;
+      await element.updateComplete;
+      emulateSelection();
+      assert.isFalse(handleSelectionChangeStub.called);
+    });
+  });
+
+  test('cancel', () => {
+    const cleanupStub = sinon.stub(element.diffBuilder, 'cleanup');
+    element.cancel();
+    assert.isTrue(cleanupStub.calledOnce);
+  });
+
+  test('line limit with line_wrapping', async () => {
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
+    await element.updateComplete;
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '80ch');
+  });
+
+  test('line limit without line_wrapping', async () => {
+    element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
+    await element.updateComplete;
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '-1px');
+  });
+
+  suite('FULL_RESPONSIVE mode', () => {
+    setup(async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
+      await element.updateComplete;
+    });
+
+    test('line limit is based on line_length', async () => {
+      element.prefs = {...element.prefs!, line_length: 100};
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--line-limit-marker', element),
+        '100ch'
+      );
+    });
+
+    test('content-width should not be defined', () => {
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+  });
+
+  suite('SHRINK_ONLY mode', () => {
+    setup(async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
+      await element.updateComplete;
+    });
+
+    test('content-width should not be defined', () => {
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+
+    test('max-width considers two content columns in side-by-side', async () => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('max-width considers one content column in unified', async () => {
+      element.viewMode = DiffViewMode.UNIFIED;
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('max-width considers font-size', async () => {
+      element.prefs = {...element.prefs!, font_size: 13};
+      await element.updateComplete;
+      // Each line number column: 4 * 13 = 52px
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)'
+      );
+    });
+
+    test('sign cols are considered if show_sign_col is true', async () => {
+      element.renderPrefs = {...element.renderPrefs, show_sign_col: true};
+      await element.updateComplete;
+      assert.equal(
+        getComputedStyleValue('--diff-max-width', element),
+        'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)'
+      );
+    });
+  });
+
+  suite('not logged in', () => {
+    setup(async () => {
+      element.loggedIn = false;
+      await element.updateComplete;
+    });
+
+    test('toggleLeftDiff', () => {
+      element.toggleLeftDiff();
+      assert.isTrue(element.classList.contains('no-left'));
+      element.toggleLeftDiff();
+      assert.isFalse(element.classList.contains('no-left'));
+    });
+
+    suite('binary diffs', () => {
+      test('render binary diff', async () => {
+        element.prefs = {
+          ...MINIMAL_PREFS,
+        };
+        element.diff = {
+          meta_a: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+          meta_b: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+          change_type: 'MODIFIED',
+          intraline_status: 'OK',
+          diff_header: [],
+          content: [],
+          binary: true,
+        };
+        await waitForEventOnce(element, 'render');
+
+        assert.shadowDom.equal(
+          element,
+          /* HTML */ `
+            <div class="diffContainer sideBySide">
+              <gr-diff-section class="left-FILE right-FILE"> </gr-diff-section>
+              <gr-diff-row class="left-FILE right-FILE"> </gr-diff-row>
+              <table class="selected-right" id="diffTable">
+                <colgroup>
+                  <col class="blame gr-diff" />
+                  <col class="gr-diff left" width="48" />
+                  <col class="gr-diff left sign" />
+                  <col class="gr-diff left" />
+                  <col class="gr-diff right" width="48" />
+                  <col class="gr-diff right sign" />
+                  <col class="gr-diff right" />
+                </colgroup>
+                <tbody class="binary-diff gr-diff"></tbody>
+                <tbody class="both gr-diff section">
+                  <tr
+                    aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
+                    class="diff-row gr-diff side-by-side"
+                    left-type="both"
+                    right-type="both"
+                    tabindex="-1"
+                  >
+                    <td class="blame gr-diff" data-line-number="FILE"></td>
+                    <td class="gr-diff left lineNum" data-value="FILE">
+                      <button
+                        aria-label="Add file comment"
+                        class="gr-diff left lineNumButton"
+                        data-value="FILE"
+                        id="left-button-FILE"
+                        tabindex="-1"
+                      >
+                        File
+                      </button>
+                    </td>
+                    <td class="gr-diff left no-intraline-info sign"></td>
+                    <td
+                      class="both content file gr-diff left no-intraline-info"
+                    >
+                      <div class="thread-group" data-side="left">
+                        <slot name="left-FILE"> </slot>
+                      </div>
+                    </td>
+                    <td class="gr-diff lineNum right" data-value="FILE">
+                      <button
+                        aria-label="Add file comment"
+                        class="gr-diff lineNumButton right"
+                        data-value="FILE"
+                        id="right-button-FILE"
+                        tabindex="-1"
+                      >
+                        File
+                      </button>
+                    </td>
+                    <td class="gr-diff no-intraline-info right sign"></td>
+                    <td
+                      class="both content file gr-diff no-intraline-info right"
+                    >
+                      <div class="thread-group" data-side="right">
+                        <slot name="right-FILE"> </slot>
+                      </div>
+                    </td>
+                  </tr>
+                </tbody>
+                <tbody class="binary-diff gr-diff">
+                  <tr class="gr-diff">
+                    <td class="gr-diff" colspan="5">
+                      <span> Difference in binary files </span>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+          `
+        );
+      });
+    });
+
+    suite('image diffs', () => {
+      let mockFile1: ImageInfo;
+      let mockFile2: ImageInfo;
+      setup(() => {
+        mockFile1 = {
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
+          type: 'image/bmp',
+        };
+        mockFile2 = {
+          body:
+            'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
+          type: 'image/bmp',
+        };
+
+        element.isImageDiff = true;
+        element.prefs = {
+          context: 10,
+          cursor_blink_rate: 0,
+          font_size: 12,
+          ignore_whitespace: 'IGNORE_NONE',
+          line_length: 100,
+          line_wrapping: false,
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          tab_size: 8,
+        };
+      });
+
+      test('render image diff', async () => {
+        element.baseImage = mockFile1;
+        element.revisionImage = mockFile2;
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        assert.lightDom.equal(
+          imageDiffSection,
+          /* HTML */ `
+            <tbody class="gr-diff image-diff">
+              <tr class="gr-diff">
+                <td class="blank gr-diff left lineNum"></td>
+                <td class="gr-diff left">
+                  <img
+                    class="gr-diff left"
+                    src="data:image/bmp;base64,${mockFile1.body}"
+                  />
+                </td>
+                <td class="blank gr-diff lineNum right"></td>
+                <td class="gr-diff right">
+                  <img
+                    class="gr-diff right"
+                    src="data:image/bmp;base64,${mockFile2.body}"
+                  />
+                </td>
+              </tr>
+              <tr class="gr-diff">
+                <td class="blank gr-diff left lineNum"></td>
+                <td class="gr-diff left">
+                  <label class="gr-diff">
+                    <span class="gr-diff label"> image/bmp </span>
+                  </label>
+                </td>
+                <td class="blank gr-diff lineNum right"></td>
+                <td class="gr-diff right">
+                  <label class="gr-diff">
+                    <span class="gr-diff label"> image/bmp </span>
+                  </label>
+                </td>
+              </tr>
+            </tbody>
+          `
+        );
+        const endpoint = queryAndAssert(element, 'tbody.endpoint');
+        assert.dom.equal(
+          endpoint,
+          /* HTML */ `
+            <tbody class="gr-diff endpoint">
+              <tr class="gr-diff">
+                <gr-endpoint-decorator class="gr-diff" name="image-diff">
+                  <gr-endpoint-param class="gr-diff" name="baseImage">
+                  </gr-endpoint-param>
+                  <gr-endpoint-param class="gr-diff" name="revisionImage">
+                  </gr-endpoint-param>
+                </gr-endpoint-decorator>
+              </tr>
+            </tbody>
+          `
+        );
+      });
+
+      test('renders image diffs with a different file name', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot2.jpg',
+            'index 2adc47d..f9c2f2c 100644',
+            '--- a/carrot.jpg',
+            '+++ b/carrot2.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+
+        element.baseImage = mockFile1;
+        element.baseImage._name = mockDiff.meta_a!.name;
+        element.revisionImage = mockFile2;
+        element.revisionImage._name = mockDiff.meta_b!.name;
+        element.diff = mockDiff;
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftLabel = queryAndAssert(imageDiffSection, 'td.left label');
+        const rightLabel = queryAndAssert(imageDiffSection, 'td.right label');
+        assert.dom.equal(
+          leftLabel,
+          /* HTML */ `
+            <label class="gr-diff">
+              <span class="gr-diff name"> carrot.jpg </span>
+              <br class="gr-diff" />
+              <span class="gr-diff label"> image/bmp </span>
+            </label>
+          `
+        );
+        assert.dom.equal(
+          rightLabel,
+          /* HTML */ `
+            <label class="gr-diff">
+              <span class="gr-diff name"> carrot2.jpg </span>
+              <br class="gr-diff" />
+              <span class="gr-diff label"> image/bmp </span>
+            </label>
+          `
+        );
+      });
+
+      test('renders added image', async () => {
+        const mockDiff: DiffInfo = {
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'ADDED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index 0000000..f9c2f2c 100644',
+            '--- /dev/null',
+            '+++ b/carrot.jpg',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        element.revisionImage = mockFile2;
+        element.diff = mockDiff;
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftImage = query(imageDiffSection, 'td.left img');
+        const rightImage = queryAndAssert(imageDiffSection, 'td.right img');
+        assert.isNotOk(leftImage);
+        assert.dom.equal(
+          rightImage,
+          /* HTML */ `
+            <img
+              class="gr-diff right"
+              src="data:image/bmp;base64,${mockFile2.body}"
+            />
+          `
+        );
+      });
+
+      test('renders removed image', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftImage = queryAndAssert(imageDiffSection, 'td.left img');
+        const rightImage = query(imageDiffSection, 'td.right img');
+        assert.isNotOk(rightImage);
+        assert.dom.equal(
+          leftImage,
+          /* HTML */ `
+            <img
+              class="gr-diff left"
+              src="data:image/bmp;base64,${mockFile1.body}"
+            />
+          `
+        );
+      });
+
+      test('does not render disallowed image type', async () => {
+        const mockDiff: DiffInfo = {
+          meta_a: {
+            name: 'carrot.jpg',
+            content_type: 'image/jpeg-evil',
+            lines: 560,
+          },
+          intraline_status: 'OK',
+          change_type: 'DELETED',
+          diff_header: [
+            'diff --git a/carrot.jpg b/carrot.jpg',
+            'index f9c2f2c..0000000 100644',
+            '--- a/carrot.jpg',
+            '+++ /dev/null',
+            'Binary files differ',
+          ],
+          content: [{skip: 66}],
+          binary: true,
+        };
+        mockFile1.type = 'image/jpeg-evil';
+        element.baseImage = mockFile1;
+        element.diff = mockDiff;
+
+        await waitForEventOnce(element, 'render');
+        const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+        const leftImage = query(imageDiffSection, 'td.left img');
+        assert.isNotOk(leftImage);
+      });
+    });
+
+    test('handleTap lineNum', async () => {
+      const addDraftStub = sinon.stub(element, 'addDraftAtLine');
+      const el = document.createElement('div');
+      el.className = 'lineNum';
+      const promise = mockPromise();
+      el.addEventListener('click', e => {
+        element.handleTap(e);
+        assert.isTrue(addDraftStub.called);
+        assert.equal(addDraftStub.lastCall.args[0], el);
+        promise.resolve();
+      });
+      el.click();
+      await promise;
+    });
+
+    test('handleTap content', async () => {
+      const content = document.createElement('div');
+      const lineEl = document.createElement('div');
+      lineEl.className = 'lineNum';
+      const row = document.createElement('div');
+      row.appendChild(lineEl);
+      row.appendChild(content);
+
+      const selectStub = sinon.stub(element, 'selectLine');
+
+      content.className = 'content';
+      const promise = mockPromise();
+      content.addEventListener('click', e => {
+        element.handleTap(e);
+        assert.isTrue(selectStub.called);
+        assert.equal(selectStub.lastCall.args[0], lineEl);
+        promise.resolve();
+      });
+      content.click();
+      await promise;
+    });
+
+    suite('getCursorStops', () => {
+      async function setupDiff() {
+        element.diff = createDiff();
+        element.prefs = {
+          context: 10,
+          tab_size: 8,
+          font_size: 12,
+          line_length: 100,
+          cursor_blink_rate: 0,
+          line_wrapping: false,
+
+          show_line_endings: true,
+          show_tabs: true,
+          show_whitespace_errors: true,
+          syntax_highlighting: true,
+          ignore_whitespace: 'IGNORE_NONE',
+        };
+        await element.updateComplete;
+        element.renderDiffTable();
+      }
+
+      test('returns [] when hidden and noAutoRender', async () => {
+        element.noAutoRender = true;
+        await setupDiff();
+        element.loading = false;
+        await element.updateComplete;
+        element.hidden = true;
+        await element.updateComplete;
+        assert.equal(element.getCursorStops().length, 0);
+      });
+
+      test('returns one stop per line and one for the file row', async () => {
+        await setupDiff();
+        element.loading = false;
+        await element.updateComplete;
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        const LOST_ROW = 1;
+        assert.equal(
+          element.getCursorStops().length,
+          ROWS + FILE_ROW + LOST_ROW
+        );
+      });
+
+      test('returns an additional AbortStop when still loading', async () => {
+        await setupDiff();
+        element.loading = true;
+        await element.updateComplete;
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        const LOST_ROW = 1;
+        const actual = element.getCursorStops();
+        assert.equal(actual.length, ROWS + FILE_ROW + LOST_ROW + 1);
+        assert.isTrue(actual[actual.length - 1] instanceof AbortStop);
+      });
+    });
+  });
+
+  suite('logged in', async () => {
+    let fakeLineEl: HTMLElement;
+    setup(async () => {
+      element.loggedIn = true;
+
+      fakeLineEl = {
+        getAttribute: sinon.stub().returns(42),
+        classList: {
+          contains: sinon.stub().returns(true),
+        },
+      } as unknown as HTMLElement;
+      await element.updateComplete;
+    });
+
+    test('addDraftAtLine', () => {
+      sinon.stub(element, 'selectLine');
+      const createCommentStub = sinon.stub(element, 'createComment');
+      element.addDraftAtLine(fakeLineEl);
+      assert.isTrue(createCommentStub.calledWithExactly(fakeLineEl, 42));
+    });
+
+    test('adds long range comment hint', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          a: ['asdf'],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
+      await setupSampleDiff({content});
+
+      element.appendChild(threadEl);
+
+      const hint = await waitQueryAndAssert<GrRangedCommentHint>(
+        element,
+        'gr-ranged-comment-hint'
+      );
+      assert.deepEqual(hint.range, range);
+    });
+
+    test('no duplicate range hint for same thread', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 12,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const firstHint = document.createElement('gr-ranged-comment-hint');
+      firstHint.range = range;
+      firstHint.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          a: ['asdf'],
+        },
+        {
+          ab: Array(13).fill('text'),
+        },
+      ];
+      await setupSampleDiff({content});
+
+      element.appendChild(firstHint);
+      element.appendChild(threadEl);
+
+      assert.equal(
+        element.querySelectorAll('gr-ranged-comment-hint').length,
+        1
+      );
+    });
+
+    test('removes long range comment hint when comment is discarded', async () => {
+      const range = {
+        start_line: 1,
+        end_line: 7,
+        start_character: 0,
+        end_character: 0,
+      };
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '1');
+      threadEl.setAttribute('range', JSON.stringify(range));
+      threadEl.setAttribute('slot', 'right-1');
+      const content = [
+        {
+          ab: Array(8).fill('text'),
+        },
+      ];
+      await setupSampleDiff({content});
+
+      element.appendChild(threadEl);
+      await waitUntil(() => element.commentRanges.length === 1);
+
+      threadEl.remove();
+      await waitUntil(() => element.commentRanges.length === 0);
+
+      assert.isEmpty(element.querySelectorAll('gr-ranged-comment-hint'));
+    });
+
+    suite('change in preferences', () => {
+      setup(async () => {
+        element.diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+          diff_header: [],
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          content: [{skip: 66}],
+        };
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+      });
+
+      test('change in preferences re-renders diff', async () => {
+        const stub = sinon.stub(element, 'renderDiffTable');
+        element.prefs = {
+          ...MINIMAL_PREFS,
+        };
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+      });
+
+      test('adding/removing property in preferences re-renders diff', async () => {
+        const stub = sinon.stub(element, 'renderDiffTable');
+        const newPrefs1: DiffPreferencesInfo = {
+          ...MINIMAL_PREFS,
+          line_wrapping: true,
+        };
+        element.prefs = newPrefs1;
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+        stub.reset();
+
+        const newPrefs2 = {...newPrefs1};
+        delete newPrefs2.line_wrapping;
+        element.prefs = newPrefs2;
+        await element.updateComplete;
+        await element.renderDiffTableTask?.flush();
+        assert.isTrue(stub.called);
+      });
+
+      test(
+        'change in preferences does not re-renders diff with ' +
+          'noRenderOnPrefsChange',
+        async () => {
+          const stub = sinon.stub(element, 'renderDiffTable');
+          element.noRenderOnPrefsChange = true;
+          element.prefs = {
+            ...MINIMAL_PREFS,
+            context: 12,
+          };
+          await element.updateComplete;
+          await element.renderDiffTableTask?.flush();
+          assert.isFalse(stub.called);
+        }
+      );
+    });
+  });
+
+  suite('diff header', () => {
+    setup(async () => {
+      element.diff = {
+        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
+        diff_header: [],
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        content: [{skip: 66}],
+      };
+      await element.updateComplete;
+    });
+
+    test('hidden', async () => {
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('diff --git a/test.jpg b/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('index 2adc47d..f9c2f2c 100644');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('--- a/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('+++ b/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('test');
+      assert.equal(element.computeDiffHeaderItems().length, 1);
+      element.requestUpdate('diff');
+      await element.updateComplete;
+
+      const header = queryAndAssert(element, '#diffHeader');
+      assert.equal(header.textContent?.trim(), 'test');
+    });
+
+    test('binary files', () => {
+      element.diff!.binary = true;
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('diff --git a/test.jpg b/test.jpg');
+      assert.equal(element.computeDiffHeaderItems().length, 0);
+      element.diff?.diff_header?.push('test');
+      assert.equal(element.computeDiffHeaderItems().length, 1);
+      element.diff?.diff_header?.push('Binary files differ');
+      assert.equal(element.computeDiffHeaderItems().length, 1);
+    });
+  });
+
+  suite('safety and bypass', () => {
+    let renderStub: sinon.SinonStub;
+
+    setup(async () => {
+      renderStub = sinon.stub(element.diffBuilder, 'render').callsFake(() => {
+        assertIsDefined(element.diffTable);
+        const diffTable = element.diffTable;
+        diffTable.dispatchEvent(
+          new CustomEvent('render', {bubbles: true, composed: true})
+        );
+        return Promise.resolve();
+      });
+      sinon.stub(element, 'getDiffLength').returns(10000);
+      element.diff = createDiff();
+      element.noRenderOnPrefsChange = true;
+      await element.updateComplete;
+    });
+
+    test('large render w/ context = 10', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 10};
+      element.renderDiffTable();
+      await waitForEventOnce(element, 'render');
+
+      assert.isTrue(renderStub.called);
+      assert.isFalse(element.showWarning);
+    });
+
+    test('large render w/ whole file and bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      element.safetyBypass = 10;
+      element.renderDiffTable();
+      await waitForEventOnce(element, 'render');
+
+      assert.isTrue(renderStub.called);
+      assert.isFalse(element.showWarning);
+    });
+
+    test('large render w/ whole file and no bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+      element.renderDiffTable();
+      await waitForEventOnce(element, 'render');
+
+      assert.isFalse(renderStub.called);
+      assert.isTrue(element.showWarning);
+    });
+
+    test('toggles expand context using bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+
+      element.toggleAllContext();
+      element.renderDiffTable();
+      await element.updateComplete;
+
+      assert.equal(element.prefs.context, 3);
+      assert.equal(element.safetyBypass, -1);
+      assert.equal(element.diffBuilder.prefs.context, -1);
+    });
+
+    test('toggles collapse context from bypass', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: 3};
+      element.safetyBypass = -1;
+
+      element.toggleAllContext();
+      element.renderDiffTable();
+      await element.updateComplete;
+
+      assert.equal(element.prefs.context, 3);
+      assert.isNull(element.safetyBypass);
+      assert.equal(element.diffBuilder.prefs.context, 3);
+    });
+
+    test('toggles collapse context from pref using default', async () => {
+      element.prefs = {...MINIMAL_PREFS, context: -1};
+
+      element.toggleAllContext();
+      element.renderDiffTable();
+      await element.updateComplete;
+
+      assert.equal(element.prefs.context, -1);
+      assert.equal(element.safetyBypass, 10);
+      assert.equal(element.diffBuilder.prefs.context, 10);
+    });
+  });
+
+  suite('blame', () => {
+    test('unsetting', async () => {
+      element.blame = [];
+      const setBlameSpy = sinon.spy(element.diffBuilder, 'setBlame');
+      element.classList.add('showBlame');
+      element.blame = null;
+      await element.updateComplete;
+      assert.isTrue(setBlameSpy.calledWithExactly(null));
+      assert.isFalse(element.classList.contains('showBlame'));
+    });
+
+    test('setting', async () => {
+      element.blame = [
+        {
+          author: 'test-author',
+          time: 12345,
+          commit_msg: '',
+          id: 'commit id',
+          ranges: [{start: 1, end: 2}],
+        },
+      ];
+      await element.updateComplete;
+      assert.isTrue(element.classList.contains('showBlame'));
+    });
+  });
+
+  suite('trailing newline warnings', () => {
+    const NO_NEWLINE_LEFT = 'No newline at end of left file.';
+    const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
+
+    const getWarning = (element: GrDiff) => {
+      const warningElement = query(element, '.newlineWarning');
+      return warningElement?.textContent ?? '';
+    };
+
+    setup(async () => {
+      element.showNewlineWarningLeft = false;
+      element.showNewlineWarningRight = false;
+      await element.updateComplete;
+    });
+
+    test('shows combined warning if both sides set to warn', async () => {
+      element.showNewlineWarningLeft = true;
+      element.showNewlineWarningRight = true;
+      await element.updateComplete;
+      assert.include(
+        getWarning(element),
+        NO_NEWLINE_LEFT + ' \u2014 ' + NO_NEWLINE_RIGHT
+      ); // \u2014 - '—'
+    });
+
+    suite('showNewlineWarningLeft', () => {
+      test('show warning if true', async () => {
+        element.showNewlineWarningLeft = true;
+        await element.updateComplete;
+        assert.include(getWarning(element), NO_NEWLINE_LEFT);
+      });
+
+      test('hide warning if false', async () => {
+        element.showNewlineWarningLeft = false;
+        await element.updateComplete;
+        assert.notInclude(getWarning(element), NO_NEWLINE_LEFT);
+      });
+    });
+
+    suite('showNewlineWarningRight', () => {
+      test('show warning if true', async () => {
+        element.showNewlineWarningRight = true;
+        await element.updateComplete;
+        assert.include(getWarning(element), NO_NEWLINE_RIGHT);
+      });
+
+      test('hide warning if false', async () => {
+        element.showNewlineWarningRight = false;
+        await element.updateComplete;
+        assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
+      });
+    });
+  });
+
+  suite('key locations', () => {
+    let renderStub: sinon.SinonStub;
+
+    setup(async () => {
+      element.prefs = {...MINIMAL_PREFS};
+      element.diff = createDiff();
+      renderStub = sinon.stub(element.diffBuilder, 'render');
+      await element.updateComplete;
+    });
+
+    test('lineOfInterest is a key location', () => {
+      element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
+      element.renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {789: true},
+        right: {},
+      });
+    });
+
+    test('line comments are key locations', async () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'right');
+      threadEl.setAttribute('line-num', '3');
+      element.appendChild(threadEl);
+      await element.updateComplete;
+
+      element.renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {},
+        right: {3: true},
+      });
+    });
+
+    test('file comments are key locations', async () => {
+      const threadEl = document.createElement('div');
+      threadEl.className = 'comment-thread';
+      threadEl.setAttribute('diff-side', 'left');
+      element.appendChild(threadEl);
+      await element.updateComplete;
+
+      element.renderDiffTable();
+      assert.isTrue(renderStub.called);
+      assert.deepEqual(renderStub.lastCall.args[0], {
+        left: {FILE: true},
+        right: {},
+      });
+    });
+  });
+  const setupSampleDiff = async function (params: {
+    content: DiffContent[];
+    ignore_whitespace?: IgnoreWhitespaceType;
+    binary?: boolean;
+  }) {
+    const {ignore_whitespace, content} = params;
+    // binary can't be undefined, use false if not set
+    const binary = params.binary || false;
+    element.prefs = {
+      ignore_whitespace: ignore_whitespace || 'IGNORE_ALL',
+      context: 10,
+      cursor_blink_rate: 0,
+      font_size: 12,
+
+      line_length: 100,
+      line_wrapping: false,
+      show_line_endings: true,
+      show_tabs: true,
+      show_whitespace_errors: true,
+      syntax_highlighting: true,
+      tab_size: 8,
+    };
+    element.diff = {
+      intraline_status: 'OK',
+      change_type: 'MODIFIED',
+      diff_header: [
+        'diff --git a/carrot.js b/carrot.js',
+        'index 2adc47d..f9c2f2c 100644',
+        '--- a/carrot.js',
+        '+++ b/carrot.jjs',
+        'file differ',
+      ],
+      content,
+      binary,
+    };
+    await element.updateComplete;
+    await element.renderDiffTableTask;
+  };
+
+  test('clear diff table content as soon as diff changes', async () => {
+    const content = [
+      {
+        a: ['all work and no play make andybons a dull boy'],
+      },
+      {
+        b: ['Non eram nescius, Brute, cum, quae summis ingeniis '],
+      },
+    ];
+    function diffTableHasContent() {
+      assertIsDefined(element.diffTable);
+      const diffTable = element.diffTable;
+      return diffTable.innerText.includes(content[0].a?.[0] ?? '');
+    }
+    await setupSampleDiff({content});
+    await waitUntil(diffTableHasContent);
+    element.diff = {...element.diff!};
+    await element.updateComplete;
+    // immediately cleaned up
+    assertIsDefined(element.diffTable);
+    const diffTable = element.diffTable;
+    assert.equal(diffTable.innerHTML, '');
+    element.renderDiffTable();
+    await element.updateComplete;
+    // rendered again
+    await waitUntil(diffTableHasContent);
+  });
+
+  suite('selection test', () => {
+    test('user-select set correctly on side-by-side view', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({content});
+      await waitEventLoop();
+
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      mouseDown(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+
+    test('user-select set correctly on unified view', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({content});
+      element.viewMode = DiffViewMode.UNIFIED;
+      await element.updateComplete;
+      const diffLine = queryAll<HTMLElement>(element, '.contentText')[2];
+      assert.equal(getComputedStyle(diffLine).userSelect, 'none');
+      mouseDown(diffLine);
+      assert.equal(getComputedStyle(diffLine).userSelect, 'text');
+    });
+  });
+
+  suite('whitespace changes only message', () => {
+    test('show the message if ignore_whitespace is criteria matches', async () => {
+      await setupSampleDiff({content: [{skip: 100}]});
+      element.loading = false;
+      assert.isTrue(element.showNoChangeMessage());
+    });
+
+    test('do not show the message for binary files', async () => {
+      await setupSampleDiff({content: [{skip: 100}], binary: true});
+      element.loading = false;
+      assert.isFalse(element.showNoChangeMessage());
+    });
+
+    test('do not show the message if still loading', async () => {
+      await setupSampleDiff({content: [{skip: 100}]});
+      element.loading = true;
+      assert.isFalse(element.showNoChangeMessage());
+    });
+
+    test('do not show the message if contains valid changes', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({content});
+      element.loading = false;
+      assert.equal(element.diffLength, 3);
+      assert.isFalse(element.showNoChangeMessage());
+    });
+
+    test('do not show message if ignore whitespace is disabled', async () => {
+      const content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+      await setupSampleDiff({ignore_whitespace: 'IGNORE_NONE', content});
+      element.loading = false;
+      assert.isFalse(element.showNoChangeMessage());
+    });
+  });
+
+  test('getDiffLength', () => {
+    const diff = createDiff();
+    assert.equal(element.getDiffLength(diff), 52);
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
index 79c40de..36e1f1a 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
@@ -5,15 +5,14 @@
  */
 import '../../../elements/shared/gr-button/gr-button';
 import {html, LitElement} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {property, state} from 'lit/decorators.js';
 import {DiffInfo, DiffViewMode, RenderPreferences} from '../../../api/diff';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {diffClasses, isNewDiff} from '../gr-diff/gr-diff-utils';
 import {getShowConfig} from './gr-context-controls';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {when} from 'lit/directives/when.js';
 
-@customElement('gr-context-controls-section')
 export class GrContextControlsSection extends LitElement {
   /** Should context controls be rendered for expanding above the section? */
   @property({type: Boolean}) showAbove = false;
@@ -125,8 +124,17 @@
   }
 }
 
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+  customElements.define(
+    'gr-context-controls-section',
+    GrContextControlsSection
+  );
+}
+
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-context-controls-section': GrContextControlsSection;
+    // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+    'gr-context-controls-section': LitElement;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index 4a2fee5..43c8113 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -21,7 +21,7 @@
 import {DiffInfo} from '../../../types/diff';
 import {assertIsDefined} from '../../../utils/common-util';
 import {css, html, LitElement, TemplateResult} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {property} from 'lit/decorators.js';
 import {subscribe} from '../../../elements/lit/subscription-controller';
 
 import {
@@ -32,6 +32,7 @@
 } from '../../../api/diff';
 
 import {GrDiffGroup, hideInContextControl} from '../gr-diff/gr-diff-group';
+import {isNewDiff} from '../gr-diff/gr-diff-utils';
 
 declare global {
   interface HTMLElementEventMap {
@@ -82,7 +83,6 @@
   return 'both';
 }
 
-@customElement('gr-context-controls')
 export class GrContextControls extends LitElement {
   @property({type: Object}) renderPreferences?: RenderPreferences;
 
@@ -365,6 +365,11 @@
         });
       } else {
         fire(this, 'diff-context-expanded', {
+          numLines: this.numLines(),
+          buttonType: type,
+          expandedLines: linesToExpand,
+        });
+        fire(this, 'diff-context-expanded-internal-new', {
           contextGroup: this.group,
           groups,
           numLines: this.numLines(),
@@ -511,8 +516,14 @@
   }
 }
 
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+  customElements.define('gr-context-controls', GrContextControls);
+}
+
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-context-controls': GrContextControls;
+    // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+    'gr-context-controls': LitElement;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
index 8e2f432..7f5827c 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
@@ -8,9 +8,14 @@
 import './gr-context-controls';
 import {GrContextControls} from './gr-context-controls';
 
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffFileMetaInfo, DiffInfo, SyntaxBlock} from '../../../api/diff';
+import {
+  DiffFileMetaInfo,
+  DiffInfo,
+  GrDiffLineType,
+  SyntaxBlock,
+} from '../../../api/diff';
 import {fixture, html, assert} from '@open-wc/testing';
 import {waitEventLoop} from '../../../test/test-utils';
 
@@ -18,7 +23,10 @@
   let element: GrContextControls;
 
   setup(async () => {
-    element = document.createElement('gr-context-controls');
+    // TODO(newdiff-cleanup): Remove cast when newdiff migration is complete.
+    element = document.createElement(
+      'gr-context-controls'
+    ) as GrContextControls;
     element.diff = {content: []} as any as DiffInfo;
     element.renderPreferences = {};
     const div = await fixture(html`<div></div>`);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
index cc45e1e..7ace605 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -8,6 +8,7 @@
 import {createElementDiff} from '../gr-diff/gr-diff-utils';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {html, render} from 'lit';
+import {FILE} from '../../../api/diff';
 
 export class GrDiffBuilderBinary extends GrDiffBuilder {
   constructor(
@@ -20,8 +21,8 @@
 
   override buildSectionElement(group: GrDiffGroup): HTMLElement {
     const section = createElementDiff('tbody', 'binary-diff');
-    // Do not create a diff row for 'LOST'.
-    if (group.lines[0].beforeNumber !== 'FILE') return section;
+    // Do not create a diff row for LOST.
+    if (group.lines[0].beforeNumber !== FILE) return section;
     return super.buildSectionElement(group);
   }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
index 1f7ffd3..eeb07d8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -5,13 +5,13 @@
  */
 import {ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {RenderPreferences, Side} from '../../../api/diff';
+import {FILE, RenderPreferences, Side} from '../../../api/diff';
 import '../gr-diff-image-viewer/gr-image-viewer';
 import {html, LitElement, nothing} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {property, query, state} from 'lit/decorators.js';
 import {GrDiffBuilder} from './gr-diff-builder';
-import {createElementDiff} from '../gr-diff/gr-diff-utils';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {isNewDiff, createElementDiff} from '../gr-diff/gr-diff-utils';
 
 // MIME types for images we allow showing. Do not include SVG, it can contain
 // arbitrary JavaScript.
@@ -32,8 +32,8 @@
 
   override buildSectionElement(group: GrDiffGroup): HTMLElement {
     const section = createElementDiff('tbody');
-    // Do not create a diff row for 'LOST'.
-    if (group.lines[0].beforeNumber !== 'FILE') return section;
+    // Do not create a diff row for LOST.
+    if (group.lines[0].beforeNumber !== FILE) return section;
     return super.buildSectionElement(group);
   }
 
@@ -45,7 +45,10 @@
   }
 
   private createImageDiffNew() {
-    const imageDiff = document.createElement('gr-diff-image-new');
+    // TODO(newdiff-cleanup): Remove cast when newdiff migration is complete.
+    const imageDiff = document.createElement(
+      'gr-diff-image-new'
+    ) as GrDiffImageNew;
     imageDiff.automaticBlink = this.autoBlink();
     imageDiff.baseImage = this.baseImage ?? undefined;
     imageDiff.revisionImage = this.revisionImage ?? undefined;
@@ -53,7 +56,10 @@
   }
 
   private createImageDiffOld() {
-    const imageDiff = document.createElement('gr-diff-image-old');
+    // TODO(newdiff-cleanup): Remove cast when newdiff migration is complete.
+    const imageDiff = document.createElement(
+      'gr-diff-image-old'
+    ) as GrDiffImageOld;
     imageDiff.baseImage = this.baseImage ?? undefined;
     imageDiff.revisionImage = this.revisionImage ?? undefined;
     return imageDiff;
@@ -75,7 +81,6 @@
   }
 }
 
-@customElement('gr-diff-image-new')
 class GrDiffImageNew extends LitElement {
   @property() baseImage?: ImageInfo;
 
@@ -113,7 +118,6 @@
   }
 }
 
-@customElement('gr-diff-image-old')
 class GrDiffImageOld extends LitElement {
   @property() baseImage?: ImageInfo;
 
@@ -264,9 +268,16 @@
     : '';
 }
 
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+  customElements.define('gr-diff-image-new', GrDiffImageNew);
+  customElements.define('gr-diff-image-old', GrDiffImageOld);
+}
+
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-diff-image-new': GrDiffImageNew;
-    'gr-diff-image-old': GrDiffImageOld;
+    // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+    'gr-diff-image-new': LitElement;
+    'gr-diff-image-old': LitElement;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index f38ba5c..bcc54d4 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -9,9 +9,9 @@
   ContentLoadNeededEventDetail,
   DiffContextExpandedExternalDetail,
   DiffViewMode,
+  LineNumber,
   RenderPreferences,
 } from '../../../api/diff';
-import {LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup} from '../gr-diff/gr-diff-group';
 import {BlameInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
@@ -30,12 +30,12 @@
   /** The context control group that should be replaced by `groups`. */
   contextGroup: GrDiffGroup;
   groups: GrDiffGroup[];
-  numLines: number;
 }
 
 declare global {
   interface HTMLElementEventMap {
-    'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
+    'diff-context-expanded-internal-new': CustomEvent<DiffContextExpandedEventDetail>;
+    'diff-context-expanded': CustomEvent<DiffContextExpandedExternalDetail>;
     'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
index 9acda81..51024da 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {html, LitElement, nothing, TemplateResult} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {property, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {createRef, Ref, ref} from 'lit/directives/ref.js';
 import {
@@ -12,16 +12,18 @@
   Side,
   LineNumber,
   DiffLayer,
+  GrDiffLineType,
+  LOST,
+  FILE,
 } from '../../../api/diff';
 import {BlameInfo} from '../../../types/common';
 import {assertIsDefined} from '../../../utils/common-util';
 import {fire} from '../../../utils/event-util';
 import {getBaseUrl} from '../../../utils/url-util';
 import './gr-diff-text';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {diffClasses, isResponsive} from '../gr-diff/gr-diff-utils';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {diffClasses, isNewDiff, isResponsive} from '../gr-diff/gr-diff-utils';
 
-@customElement('gr-diff-row')
 export class GrDiffRow extends LitElement {
   contentLeftRef: Ref<LitElement> = createRef();
 
@@ -281,8 +283,8 @@
     lineNumber: LineNumber,
     side: Side
   ) {
-    if (this.hideFileCommentButton && lineNumber === 'FILE') return;
-    if (lineNumber === 'LOST') return;
+    if (this.hideFileCommentButton && lineNumber === FILE) return;
+    if (lineNumber === LOST) return;
     // .lineNumButton has `white-space: pre`, so prettier must not add spaces.
     // prettier-ignore
     return html`
@@ -298,18 +300,18 @@
           fire(this, 'line-mouse-enter', {lineNum: lineNumber, side})}
         @mouseleave=${() =>
           fire(this, 'line-mouse-leave', {lineNum: lineNumber, side})}
-      >${lineNumber === 'FILE' ? 'File' : lineNumber.toString()}</button>
+      >${lineNumber === FILE ? 'File' : lineNumber.toString()}</button>
     `;
   }
 
   private computeLineNumberAriaLabel(line: GrDiffLine, lineNumber: LineNumber) {
-    if (lineNumber === 'FILE') return 'Add file comment';
+    if (lineNumber === FILE) return 'Add file comment';
 
     // Add aria-labels for valid line numbers.
     // For unified diff, this method will be called with number set to 0 for
     // the empty line number column for added/removed lines. This should not
     // be announced to the screenreader.
-    if (lineNumber === 'LOST' || lineNumber <= 0) return undefined;
+    if (lineNumber === LOST || lineNumber <= 0) return undefined;
 
     switch (line.type) {
       case GrDiffLineType.REMOVE:
@@ -336,8 +338,8 @@
     const extras: string[] = [line.type, side];
     if (line.type !== GrDiffLineType.BLANK) extras.push('content');
     if (!line.hasIntralineInfo) extras.push('no-intraline-info');
-    if (line.beforeNumber === 'FILE') extras.push('file');
-    if (line.beforeNumber === 'LOST') extras.push('lost');
+    if (line.beforeNumber === FILE) extras.push('file');
+    if (line.beforeNumber === LOST) extras.push('lost');
 
     // .content has `white-space: pre`, so prettier must not add spaces.
     // prettier-ignore
@@ -437,7 +439,7 @@
   private renderText(side: Side) {
     const line = this.line(side);
     const lineNumber = this.lineNumber(side);
-    if (lineNumber === 'FILE' || lineNumber === 'LOST') return;
+    if (typeof lineNumber !== 'number') return;
 
     // Note that `this.layersApplied` will wipe away the <gr-diff-text>, and
     // another rendering cycle will be initiated in `updated()`.
@@ -467,8 +469,14 @@
   }
 }
 
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+  customElements.define('gr-diff-row', GrDiffRow);
+}
+
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-diff-row': GrDiffRow;
+    // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+    'gr-diff-row': LitElement;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
index e5d3d2e..e02d62b 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {html, LitElement} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {property, state} from 'lit/decorators.js';
 import {
   DiffInfo,
   DiffLayer,
@@ -16,7 +16,7 @@
 } from '../../../api/diff';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {
-  countLines,
+  isNewDiff,
   diffClasses,
   getResponsiveMode,
 } from '../gr-diff/gr-diff-utils';
@@ -27,8 +27,8 @@
 import './gr-diff-row';
 import {when} from 'lit/directives/when.js';
 import {fire} from '../../../utils/event-util';
+import {countLines} from '../../../utils/diff-util';
 
-@customElement('gr-diff-section')
 export class GrDiffSection extends LitElement {
   @property({type: Object})
   group?: GrDiffGroup;
@@ -243,8 +243,14 @@
   }
 }
 
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+  customElements.define('gr-diff-section', GrDiffSection);
+}
+
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-diff-section': GrDiffSection;
+    // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+    'gr-diff-section': LitElement;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
index c1b13ac..2acedc8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
@@ -4,9 +4,9 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {LitElement, html, TemplateResult} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {property} from 'lit/decorators.js';
 import {styleMap} from 'lit/directives/style-map.js';
-import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {isNewDiff, diffClasses} from '../gr-diff/gr-diff-utils';
 
 const SURROGATE_PAIR = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
@@ -25,7 +25,6 @@
  * performance. And be aware that building longer lived local state is not
  * useful here.
  */
-@customElement('gr-diff-text')
 export class GrDiffText extends LitElement {
   /**
    * The browser API for handling selection does not (yet) work for selection
@@ -145,8 +144,14 @@
   }
 }
 
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+  customElements.define('gr-diff-text', GrDiffText);
+}
+
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-diff-text': GrDiffText;
+    // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+    'gr-diff-text': LitElement;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
index 8fd03bb..5651dcf 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -4,8 +4,12 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import {Side, TokenHighlightEventDetails} from '../../../api/diff';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {
+  GrDiffLineType,
+  Side,
+  TokenHighlightEventDetails,
+} from '../../../api/diff';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {HOVER_DELAY_MS, TokenHighlightLayer} from './token-highlight-layer';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {html, render} from 'lit';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 9e3640b..6a32afb 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -8,6 +8,7 @@
 import {
   DiffViewMode,
   GrDiffCursor as GrDiffCursorApi,
+  GrDiffLineType,
   LineNumber,
   LineSelectedEventDetail,
 } from '../../../api/diff';
@@ -17,7 +18,6 @@
   GrCursorManager,
   isTargetable,
 } from '../../../elements/shared/gr-cursor-manager/gr-cursor-manager';
-import {GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
 import {fire} from '../../../utils/event-util';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
index f319a3c..3e1ce66 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
@@ -3,7 +3,6 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../../test/common-test-setup';
 import {GrAnnotation} from './gr-annotation';
 import {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 69c0f5c..0d9250c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -11,7 +11,6 @@
 import {Side} from '../../../constants/constants';
 import {CommentRange} from '../../../types/common';
 import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
-import {FILE} from '../gr-diff/gr-diff-line';
 import {
   getLineElByChild,
   getLineNumberByChild,
@@ -308,7 +307,7 @@
     const side = getSideByLineEl(lineEl);
     if (!side) return null;
     const line = getLineNumberByChild(lineEl);
-    if (!line || line === FILE || line === 'LOST') return null;
+    if (typeof line !== 'number') return null;
     const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentTd) return null;
     const contentText = contentTd.querySelector('.contentText');
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
index 8fbda14..d2e997c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -8,18 +8,26 @@
 import {
   DiffInfo,
   DiffPreferencesInfo,
+  DisplayLine,
   RenderPreferences,
 } from '../../../api/diff';
 import {define} from '../../../models/dependency';
 import {Model} from '../../../models/model';
 import {isDefined} from '../../../types/types';
 import {select} from '../../../utils/observable-util';
+import {
+  GrDiffCommentThread,
+  KeyLocations,
+  computeKeyLocations,
+} from '../gr-diff/gr-diff-utils';
 
 export interface DiffState {
   diff: DiffInfo;
   path?: string;
   renderPrefs: RenderPreferences;
   diffPrefs: DiffPreferencesInfo;
+  lineOfInterest?: DisplayLine;
+  comments: GrDiffCommentThread[];
 }
 
 export const diffModelToken = define<DiffModel>('diff-model');
@@ -44,4 +52,10 @@
     this.state$.pipe(filter(isDefined)),
     diffState => diffState.diffPrefs
   );
+
+  readonly keyLocations$: Observable<KeyLocations> = select(
+    this.state$.pipe(filter(isDefined)),
+    diffState =>
+      computeKeyLocations(diffState.lineOfInterest, diffState.comments ?? [])
+  );
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 05e5d3b..483a4da 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -3,13 +3,7 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {
-  GrDiffLine,
-  GrDiffLineType,
-  FILE,
-  Highlights,
-  LineNumber,
-} from '../gr-diff/gr-diff-line';
+import {GrDiffLine, Highlights} from '../gr-diff/gr-diff-line';
 import {
   GrDiffGroup,
   GrDiffGroupType,
@@ -18,8 +12,10 @@
 import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {assert, assertIsDefined} from '../../../utils/common-util';
+import {assert} from '../../../utils/common-util';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {FILE, GrDiffLineType, LineNumber} from '../../../api/diff';
+import {KeyLocations} from '../gr-diff/gr-diff-utils';
 
 const WHOLE_FILE = -1;
 
@@ -37,11 +33,6 @@
   keyLocation: boolean;
 }
 
-export interface KeyLocations {
-  left: {[key: string]: boolean};
-  right: {[key: string]: boolean};
-}
-
 /**
  * The maximum size for an addition or removal chunk before it is broken down
  * into a series of chunks that are this size at most.
@@ -61,6 +52,14 @@
   clearGroups(): void;
 }
 
+/** Interface for listening to the output of the processor. */
+export interface ProcessingOptions {
+  context: number;
+  keyLocations?: KeyLocations;
+  asyncThreshold?: number;
+  isBinary?: boolean;
+}
+
 /**
  * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
  *
@@ -87,13 +86,15 @@
  *    the rest is not.
  */
 export class GrDiffProcessor {
-  context = 3;
+  // visible for testing
+  context: number;
 
-  consumer?: GroupConsumer;
+  // visible for testing
+  keyLocations: KeyLocations;
 
-  keyLocations: KeyLocations = {left: {}, right: {}};
+  private asyncThreshold: number;
 
-  asyncThreshold = 64;
+  private isBinary: boolean;
 
   // visible for testing
   isScrolling?: boolean;
@@ -106,6 +107,17 @@
 
   private resetIsScrollingTask?: DelayedTask;
 
+  constructor(
+    private consumer: GroupConsumer | undefined,
+    options: ProcessingOptions
+  ) {
+    this.consumer = consumer;
+    this.context = options.context;
+    this.asyncThreshold = options.asyncThreshold ?? 64;
+    this.keyLocations = options.keyLocations ?? {left: {}, right: {}};
+    this.isBinary = options.isBinary ?? false;
+  }
+
   private readonly handleWindowScroll = () => {
     this.isScrolling = true;
     this.resetIsScrollingTask = debounce(
@@ -122,18 +134,16 @@
    * @return A promise that resolves with an
    * array of GrDiffGroups when the diff is completely processed.
    */
-  process(chunks: DiffContent[], isBinary: boolean) {
+  process(chunks: DiffContent[]) {
     assert(this.isStarted === false, 'diff processor cannot be started twice');
-    this.isStarted = true;
 
     window.addEventListener('scroll', this.handleWindowScroll);
 
-    assertIsDefined(this.consumer, 'consumer');
-    this.consumer.clearGroups();
-    this.consumer.addGroup(this.makeGroup('LOST'));
-    this.consumer.addGroup(this.makeGroup(FILE));
+    this.consumer?.clearGroups();
+    this.consumer?.addGroup(this.makeGroup('LOST'));
+    this.consumer?.addGroup(this.makeGroup(FILE));
 
-    if (isBinary) return Promise.resolve();
+    if (this.isBinary) return Promise.resolve();
 
     return new Promise<void>(resolve => {
       const state = {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index adcfff8..706c208 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -5,11 +5,17 @@
  */
 import '../../../test/common-test-setup';
 import './gr-diff-processor';
-import {GrDiffLineType, FILE, GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {GrDiffProcessor, State} from './gr-diff-processor';
+import {
+  GrDiffProcessor,
+  GroupConsumer,
+  ProcessingOptions,
+  State,
+} from './gr-diff-processor';
 import {DiffContent} from '../../../types/diff';
 import {assert} from '@open-wc/testing';
+import {FILE, GrDiffLineType} from '../../../api/diff';
 
 suite('gr-diff-processor tests', () => {
   const WHOLE_FILE = -1;
@@ -20,24 +26,27 @@
     'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
     'fugit assum per.';
 
-  let element: GrDiffProcessor;
+  let processor: GrDiffProcessor;
+  let options: ProcessingOptions = {
+    context: 4,
+  };
   let groups: GrDiffGroup[];
+  const consumer: GroupConsumer = {
+    addGroup(group: GrDiffGroup) {
+      groups.push(group);
+    },
+    clearGroups() {
+      groups = [];
+    },
+  };
 
   setup(() => {});
 
   suite('not logged in', () => {
     setup(() => {
       groups = [];
-      element = new GrDiffProcessor();
-      element.consumer = {
-        addGroup(group: GrDiffGroup) {
-          groups.push(group);
-        },
-        clearGroups() {
-          groups = [];
-        },
-      };
-      element.context = 4;
+      options = {context: 4};
+      processor = new GrDiffProcessor(consumer, options);
     });
 
     test('process loaded content', () => {
@@ -58,7 +67,7 @@
         },
       ];
 
-      return element.process(content, false).then(() => {
+      return processor.process(content).then(() => {
         groups.shift(); // remove portedThreadsWithoutRangeGroup
         assert.equal(groups.length, 4);
 
@@ -119,7 +128,7 @@
     test('first group is for file', () => {
       const content = [{b: ['foo']}];
 
-      return element.process(content, false).then(() => {
+      return processor.process(content).then(() => {
         groups.shift(); // remove portedThreadsWithoutRangeGroup
 
         assert.equal(groups[0].type, GrDiffGroupType.BOTH);
@@ -132,7 +141,8 @@
 
     suite('context groups', () => {
       test('at the beginning, larger than context', () => {
-        element.context = 10;
+        options.context = 10;
+        processor = new GrDiffProcessor(consumer, options);
         const content = [
           {
             ab: Array.from<string>({length: 100}).fill(
@@ -142,28 +152,28 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content, false).then(() => {
-          groups.shift(); // remove portedThreadsWithoutRangeGroup
+        return processor.process(content).then(() => {
+          // group[0] is the LOST group
+          // group[1] is the FILE group
 
-          // group[0] is the file group
-
-          assert.equal(groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
-          assert.instanceOf(groups[1].contextGroups[0], GrDiffGroup);
-          assert.equal(groups[1].contextGroups[0].lines.length, 90);
-          for (const l of groups[1].contextGroups[0].lines) {
+          assert.equal(groups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.instanceOf(groups[2].contextGroups[0], GrDiffGroup);
+          assert.equal(groups[2].contextGroups[0].lines.length, 90);
+          for (const l of groups[2].contextGroups[0].lines) {
             assert.equal(l.text, 'all work and no play make jack a dull boy');
           }
 
-          assert.equal(groups[2].type, GrDiffGroupType.BOTH);
-          assert.equal(groups[2].lines.length, 10);
-          for (const l of groups[2].lines) {
+          assert.equal(groups[3].type, GrDiffGroupType.BOTH);
+          assert.equal(groups[3].lines.length, 10);
+          for (const l of groups[3].lines) {
             assert.equal(l.text, 'all work and no play make jack a dull boy');
           }
         });
       });
 
       test('at the beginning with skip chunks', async () => {
-        element.context = 10;
+        options.context = 10;
+        processor = new GrDiffProcessor(consumer, options);
         const content = [
           {
             ab: Array.from<string>({length: 20}).fill(
@@ -175,7 +185,7 @@
           {a: ['some other content']},
         ];
 
-        await element.process(content, false);
+        await processor.process(content);
 
         groups.shift(); // remove portedThreadsWithoutRangeGroup
 
@@ -215,7 +225,8 @@
       });
 
       test('at the beginning, smaller than context', () => {
-        element.context = 10;
+        options.context = 10;
+        processor = new GrDiffProcessor(consumer, options);
         const content = [
           {
             ab: Array.from<string>({length: 5}).fill(
@@ -225,7 +236,7 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content, false).then(() => {
+        return processor.process(content).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -239,7 +250,8 @@
       });
 
       test('at the end, larger than context', () => {
-        element.context = 10;
+        options.context = 10;
+        processor = new GrDiffProcessor(consumer, options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -249,7 +261,7 @@
           },
         ];
 
-        return element.process(content, false).then(() => {
+        return processor.process(content).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -271,7 +283,7 @@
       });
 
       test('at the end, smaller than context', () => {
-        element.context = 10;
+        options.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -281,7 +293,7 @@
           },
         ];
 
-        return element.process(content, false).then(() => {
+        return processor.process(content).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -296,7 +308,8 @@
       });
 
       test('for interleaved ab and common: true chunks', () => {
-        element.context = 10;
+        options.context = 10;
+        processor = new GrDiffProcessor(consumer, options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -334,7 +347,7 @@
           },
         ];
 
-        return element.process(content, false).then(() => {
+        return processor.process(content).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -411,7 +424,8 @@
       });
 
       test('in the middle, larger than context', () => {
-        element.context = 10;
+        options.context = 10;
+        processor = new GrDiffProcessor(consumer, options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -422,7 +436,7 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content, false).then(() => {
+        return processor.process(content).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -450,7 +464,8 @@
       });
 
       test('in the middle, smaller than context', () => {
-        element.context = 10;
+        options.context = 10;
+        processor = new GrDiffProcessor(consumer, options);
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {
@@ -461,7 +476,7 @@
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content, false).then(() => {
+        return processor.process(content).then(() => {
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
           // group[0] is the file group
@@ -477,7 +492,8 @@
     });
 
     test('in the middle with skip chunks', async () => {
-      element.context = 10;
+      options.context = 10;
+      processor = new GrDiffProcessor(consumer, options);
       const content = [
         {a: ['all work and no play make andybons a dull boy']},
         {
@@ -494,7 +510,7 @@
         {a: ['all work and no play make andybons a dull boy']},
       ];
 
-      await element.process(content, false);
+      await processor.process(content);
 
       groups.shift(); // remove portedThreadsWithoutRangeGroup
 
@@ -530,7 +546,8 @@
     });
 
     test('works with skip === 0', async () => {
-      element.context = 3;
+      options.context = 3;
+      processor = new GrDiffProcessor(consumer, options);
       const content = [
         {
           skip: 0,
@@ -546,14 +563,15 @@
           ],
         },
       ];
-      await element.process(content, false);
+      await processor.process(content);
     });
 
     test('break up common diff chunks', () => {
-      element.keyLocations = {
+      options.keyLocations = {
         left: {1: true},
         right: {10: true},
       };
+      processor = new GrDiffProcessor(consumer, options);
 
       const content = [
         {
@@ -574,7 +592,7 @@
           ],
         },
       ];
-      const result = element.splitCommonChunksWithKeyLocations(content);
+      const result = processor.splitCommonChunksWithKeyLocations(content);
       assert.deepEqual(result, [
         {
           ab: ['copy'],
@@ -602,8 +620,8 @@
         .fill(0)
         .map(() => `${Math.random()}`);
       const content = [{ab}];
-      element.context = -1;
-      const result = element.splitLargeChunks(content);
+      processor.context = -1;
+      const result = processor.splitLargeChunks(content);
       assert.equal(result.length, 2);
       assert.deepEqual(result[0].ab, content[0].ab.slice(0, maxGroupSize));
       assert.deepEqual(result[1].ab, content[0].ab.slice(maxGroupSize));
@@ -615,8 +633,8 @@
       const content = Array(size)
         .fill(0)
         .map(() => `${Math.random()}`);
-      element.context = 5;
-      const splitContent = element
+      processor.context = 5;
+      const splitContent = processor
         .splitLargeChunks([{a: [], b: content}])
         .map(r => r.b);
       assert.equal(splitContent.length, 3);
@@ -631,8 +649,8 @@
       const content = Array(size)
         .fill(0)
         .map(() => `${Math.random()}`);
-      element.context = 5;
-      const splitContent = element
+      processor.context = 5;
+      const splitContent = processor
         .splitLargeChunks([{a: content, b: []}])
         .map(r => r.a);
       assert.equal(splitContent.length, 3);
@@ -646,8 +664,8 @@
       const content = Array(size)
         .fill(0)
         .map(() => `${Math.random()}`);
-      element.context = 5;
-      const splitContent = element
+      processor.context = 5;
+      const splitContent = processor
         .splitLargeChunks([
           {
             a: content,
@@ -665,8 +683,8 @@
         .fill(0)
         .map(() => `${Math.random()}`);
       const content = [{ab}];
-      element.context = 4;
-      const result = element.splitCommonChunksWithKeyLocations(content);
+      processor.context = 4;
+      const result = processor.splitCommonChunksWithKeyLocations(content);
       assert.equal(result.length, 1);
       assert.deepEqual(result[0].ab, content[0].ab);
       assert.isFalse(result[0].keyLocation);
@@ -686,7 +704,7 @@
         [42, 26],
       ];
 
-      let results = element.convertIntralineInfos(content, highlights);
+      let results = processor.convertIntralineInfos(content, highlights);
       assert.deepEqual(results, [
         {
           contentIndex: 0,
@@ -703,7 +721,7 @@
           startIndex: 75,
         },
       ]);
-      const lines = element.linesFromRows(
+      const lines = processor.linesFromRows(
         GrDiffLineType.BOTH,
         content,
         0,
@@ -735,7 +753,7 @@
         [12, 67],
         [14, 29],
       ];
-      results = element.convertIntralineInfos(content, highlights);
+      results = processor.convertIntralineInfos(content, highlights);
       assert.deepEqual(results, [
         {
           contentIndex: 0,
@@ -766,7 +784,7 @@
 
       content = ['🙈 a', '🙉 b', '🙊 c'];
       highlights = [[2, 7]];
-      results = element.convertIntralineInfos(content, highlights);
+      results = processor.convertIntralineInfos(content, highlights);
       assert.deepEqual(results, [
         {
           contentIndex: 0,
@@ -786,23 +804,25 @@
 
     test('isScrolling paused', () => {
       const content = Array(200).fill({ab: ['', '']});
-      element.isScrolling = true;
-      element.process(content, false);
+      processor.isScrolling = true;
+      processor.process(content);
       // Just the FILE and LOST groups.
       assert.equal(groups.length, 2);
     });
 
     test('isScrolling unpaused', () => {
       const content = Array(200).fill({ab: ['', '']});
-      element.isScrolling = false;
-      element.process(content, false);
+      processor.isScrolling = false;
+      processor.process(content);
       // More groups have been processed. How many does not matter here.
       assert.isAtLeast(groups.length, 3);
     });
 
     test('image diffs', () => {
       const content = Array(200).fill({ab: ['', '']});
-      element.process(content, true);
+      options.isBinary = true;
+      processor = new GrDiffProcessor(consumer, options);
+      processor.process(content);
       assert.equal(groups.length, 2);
 
       // Image diffs don't process content, just the 'FILE' line.
@@ -817,13 +837,13 @@
       });
 
       test('WHOLE_FILE', () => {
-        element.context = WHOLE_FILE;
+        processor.context = WHOLE_FILE;
         const state: State = {
           lineNums: {left: 10, right: 100},
           chunkIndex: 1,
         };
         const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
-        const result = element.processNext(state, chunks);
+        const result = processor.processNext(state, chunks);
 
         // Results in one, uncollapsed group with all rows.
         assert.equal(result.groups.length, 1);
@@ -851,7 +871,7 @@
       });
 
       test('WHOLE_FILE with skip chunks still get collapsed', () => {
-        element.context = WHOLE_FILE;
+        processor.context = WHOLE_FILE;
         const lineNums = {left: 10, right: 100};
         const state = {
           lineNums,
@@ -859,7 +879,7 @@
         };
         const skip = 10000;
         const chunks = [{a: ['foo']}, {skip}, {ab: rows}, {a: ['bar']}];
-        const result = element.processNext(state, chunks);
+        const result = processor.processNext(state, chunks);
         // Results in one, uncollapsed group with all rows.
         assert.equal(result.groups.length, 1);
         assert.equal(result.groups[0].type, GrDiffGroupType.CONTEXT_CONTROL);
@@ -893,21 +913,21 @@
       });
 
       test('with context', () => {
-        element.context = 10;
+        processor.context = 10;
         const state = {
           lineNums: {left: 10, right: 100},
           chunkIndex: 1,
         };
         const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
-        const result = element.processNext(state, chunks);
-        const expectedCollapseSize = rows.length - 2 * element.context;
+        const result = processor.processNext(state, chunks);
+        const expectedCollapseSize = rows.length - 2 * processor.context;
 
         assert.equal(result.groups.length, 3, 'Results in three groups');
 
         // The first and last are uncollapsed context, whereas the middle has
         // a single context-control line.
-        assert.equal(result.groups[0].lines.length, element.context);
-        assert.equal(result.groups[2].lines.length, element.context);
+        assert.equal(result.groups[0].lines.length, processor.context);
+        assert.equal(result.groups[2].lines.length, processor.context);
 
         // The collapsed group has the hidden lines as its context group.
         assert.equal(
@@ -917,19 +937,19 @@
       });
 
       test('first', () => {
-        element.context = 10;
+        processor.context = 10;
         const state = {
           lineNums: {left: 10, right: 100},
           chunkIndex: 0,
         };
         const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
-        const result = element.processNext(state, chunks);
-        const expectedCollapseSize = rows.length - element.context;
+        const result = processor.processNext(state, chunks);
+        const expectedCollapseSize = rows.length - processor.context;
 
         assert.equal(result.groups.length, 2, 'Results in two groups');
 
         // Only the first group is collapsed.
-        assert.equal(result.groups[1].lines.length, element.context);
+        assert.equal(result.groups[1].lines.length, processor.context);
 
         // The collapsed group has the hidden lines as its context group.
         assert.equal(
@@ -941,13 +961,13 @@
       test('few-rows', () => {
         // Only ten rows.
         rows = rows.slice(0, 10);
-        element.context = 10;
+        processor.context = 10;
         const state = {
           lineNums: {left: 10, right: 100},
           chunkIndex: 0,
         };
         const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
-        const result = element.processNext(state, chunks);
+        const result = processor.processNext(state, chunks);
 
         // Results in one uncollapsed group with all rows.
         assert.equal(result.groups.length, 1, 'Results in one group');
@@ -956,13 +976,13 @@
 
       test('no single line collapse', () => {
         rows = rows.slice(0, 7);
-        element.context = 3;
+        processor.context = 3;
         const state = {
           lineNums: {left: 10, right: 100},
           chunkIndex: 1,
         };
         const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
-        const result = element.processNext(state, chunks);
+        const result = processor.processNext(state, chunks);
 
         // Results in one uncollapsed group with all rows.
         assert.equal(result.groups.length, 1, 'Results in one group');
@@ -978,13 +998,13 @@
             lineNums: {left: 10, right: 100},
             chunkIndex: 0,
           };
-          element.context = 10;
+          processor.context = 10;
           chunks = [{ab: rows}, {ab: ['foo'], keyLocation: true}, {ab: rows}];
         });
 
         test('context before', () => {
           state.chunkIndex = 0;
-          const result = element.processNext(state, chunks);
+          const result = processor.processNext(state, chunks);
 
           // The first chunk is split into two groups:
           // 1) A context-control, hiding everything but the context before
@@ -995,14 +1015,14 @@
           // The collapsed group has the hidden lines as its context group.
           assert.equal(
             result.groups[0].contextGroups[0].lines.length,
-            rows.length - element.context
+            rows.length - processor.context
           );
-          assert.equal(result.groups[1].lines.length, element.context);
+          assert.equal(result.groups[1].lines.length, processor.context);
         });
 
         test('key location itself', () => {
           state.chunkIndex = 1;
-          const result = element.processNext(state, chunks);
+          const result = processor.processNext(state, chunks);
 
           // The second chunk results in a single group, that is just the
           // line with the key location
@@ -1014,18 +1034,18 @@
 
         test('context after', () => {
           state.chunkIndex = 2;
-          const result = element.processNext(state, chunks);
+          const result = processor.processNext(state, chunks);
 
           // The last chunk is split into two groups:
           // 1) The context after the key location.
           // 1) A context-control, hiding everything but the context after the
           //    key location.
           assert.equal(result.groups.length, 2);
-          assert.equal(result.groups[0].lines.length, element.context);
+          assert.equal(result.groups[0].lines.length, processor.context);
           // The collapsed group has the hidden lines as its context group.
           assert.equal(
             result.groups[1].contextGroups[0].lines.length,
-            rows.length - element.context
+            rows.length - processor.context
           );
         });
       });
@@ -1040,7 +1060,7 @@
 
       test('linesFromRows', () => {
         const startLineNum = 10;
-        let result = element.linesFromRows(
+        let result = processor.linesFromRows(
           GrDiffLineType.ADD,
           rows,
           startLineNum + 1
@@ -1057,7 +1077,7 @@
         );
         assert.notOk(result[result.length - 1].beforeNumber);
 
-        result = element.linesFromRows(
+        result = processor.linesFromRows(
           GrDiffLineType.REMOVE,
           rows,
           startLineNum + 1
@@ -1078,17 +1098,17 @@
 
     suite('breakdown*', () => {
       test('breakdownChunk breaks down additions', () => {
-        const breakdownSpy = sinon.spy(element, 'breakdown');
+        const breakdownSpy = sinon.spy(processor, 'breakdown');
         const chunk = {b: ['blah', 'blah', 'blah']};
-        const result = element.breakdownChunk(chunk);
+        const result = processor.breakdownChunk(chunk);
         assert.deepEqual(result, [chunk]);
         assert.isTrue(breakdownSpy.called);
       });
 
       test('breakdownChunk keeps due_to_rebase for broken down additions', () => {
-        sinon.spy(element, 'breakdown');
+        sinon.spy(processor, 'breakdown');
         const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-        const result = element.breakdownChunk(chunk);
+        const result = processor.breakdownChunk(chunk);
         for (const subResult of result) {
           assert.isTrue(subResult.due_to_rebase);
         }
@@ -1100,7 +1120,7 @@
         );
         const size = 3;
 
-        const result = element.breakdown(array, size);
+        const result = processor.breakdown(array, size);
 
         for (const subResult of result) {
           assert.isAtMost(subResult.length, size);
@@ -1116,7 +1136,7 @@
         const size = 10;
         const expected = [array];
 
-        const result = element.breakdown(array, size);
+        const result = processor.breakdown(array, size);
 
         assert.deepEqual(result, expected);
       });
@@ -1126,7 +1146,7 @@
         const size = 10;
         const expected: string[][] = [];
 
-        const result = element.breakdown(array, size);
+        const result = processor.breakdown(array, size);
 
         assert.deepEqual(result, expected);
       });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index 6d80d78..771e298 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -3,9 +3,8 @@
  * Copyright 2016 Google LLC
  * SPDX-License-Identifier: Apache-2.0
  */
-import {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
-import {LineRange, Side} from '../../../api/diff';
-import {LineNumber} from './gr-diff-line';
+import {BLANK_LINE, GrDiffLine} from './gr-diff-line';
+import {GrDiffLineType, LineNumber, LineRange, Side} from '../../../api/diff';
 import {assertIsDefined, assert} from '../../../utils/common-util';
 import {untilRendered} from '../../../utils/dom-util';
 import {isDefined} from '../../../types/types';
@@ -133,12 +132,10 @@
     for (const line of group.lines) {
       if (
         (line.beforeNumber &&
-          line.beforeNumber !== 'FILE' &&
-          line.beforeNumber !== 'LOST' &&
+          typeof line.beforeNumber === 'number' &&
           line.beforeNumber < leftSplit) ||
         (line.afterNumber &&
-          line.afterNumber !== 'FILE' &&
-          line.afterNumber !== 'LOST' &&
+          typeof line.afterNumber === 'number' &&
           line.afterNumber < rightSplit)
       ) {
         before.push(line);
@@ -435,7 +432,7 @@
   }
 
   containsLine(side: Side, line: LineNumber) {
-    if (line === 'FILE' || line === 'LOST') {
+    if (typeof line !== 'number') {
       // For FILE and LOST, beforeNumber and afterNumber are the same
       return this.lines[0]?.beforeNumber === line;
     }
@@ -462,14 +459,8 @@
   }
 
   private _updateRangeWithNewLine(line: GrDiffLine) {
-    if (
-      line.beforeNumber === 'FILE' ||
-      line.afterNumber === 'FILE' ||
-      line.beforeNumber === 'LOST' ||
-      line.afterNumber === 'LOST'
-    ) {
-      return;
-    }
+    if (typeof line.beforeNumber !== 'number') return;
+    if (typeof line.afterNumber !== 'number') return;
 
     if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
       if (
@@ -505,8 +496,7 @@
     // untilRendered() promise.
     if (
       this.skip !== undefined ||
-      lineNumber === 'LOST' ||
-      lineNumber === 'FILE' ||
+      typeof lineNumber !== 'number' ||
       this.type === GrDiffGroupType.CONTEXT_CONTROL
     ) {
       return Promise.resolve();
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index 7ead68f..bbbb4ad 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -4,14 +4,14 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line';
+import {GrDiffLine, BLANK_LINE} from './gr-diff-line';
 import {
   GrDiffGroup,
   GrDiffGroupType,
   hideInContextControl,
 } from './gr-diff-group';
 import {assert} from '@open-wc/testing';
-import {Side} from '../../../api/diff';
+import {FILE, GrDiffLineType, LOST, Side} from '../../../api/diff';
 
 suite('gr-diff-group tests', () => {
   test('delta line pairs', () => {
@@ -297,18 +297,18 @@
 
     test('FILE', () => {
       const lines: GrDiffLine[] = [];
-      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE'));
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, FILE, FILE));
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.equal(group.startLine(Side.LEFT), 'FILE');
-      assert.equal(group.startLine(Side.RIGHT), 'FILE');
+      assert.equal(group.startLine(Side.LEFT), FILE);
+      assert.equal(group.startLine(Side.RIGHT), FILE);
     });
 
     test('LOST', () => {
       const lines: GrDiffLine[] = [];
-      lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'LOST', 'LOST'));
+      lines.push(new GrDiffLine(GrDiffLineType.BOTH, LOST, LOST));
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.equal(group.startLine(Side.LEFT), 'LOST');
-      assert.equal(group.startLine(Side.RIGHT), 'LOST');
+      assert.equal(group.startLine(Side.LEFT), LOST);
+      assert.equal(group.startLine(Side.RIGHT), LOST);
     });
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
index 338a275..1a89207 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
@@ -4,17 +4,13 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {
+  FILE,
   GrDiffLine as GrDiffLineApi,
   GrDiffLineType,
   LineNumber,
   Side,
 } from '../../../api/diff';
 
-export {GrDiffLineType};
-export type {LineNumber};
-
-export const FILE = 'FILE';
-
 export class GrDiffLine implements GrDiffLineApi {
   constructor(
     readonly type: GrDiffLineType,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index 669537e..d309556 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -4,12 +4,14 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {BlameInfo, CommentRange} from '../../../types/common';
-import {FILE, LineNumber} from './gr-diff-line';
 import {Side} from '../../../constants/constants';
-import {DiffInfo} from '../../../types/diff';
 import {
   DiffPreferencesInfo,
   DiffResponsiveMode,
+  DisplayLine,
+  FILE,
+  LOST,
+  LineNumber,
   RenderPreferences,
 } from '../../../api/diff';
 import {getBaseUrl} from '../../../utils/url-util';
@@ -36,22 +38,10 @@
  */
 export const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-// If any line of the diff is more than the character limit, then disable
-// syntax highlighting for the entire file.
-export const SYNTAX_MAX_LINE_LENGTH = 500;
-
-export function countLines(diff?: DiffInfo, side?: Side) {
-  if (!diff?.content || !side) return 0;
-  return diff.content.reduce((sum, chunk) => {
-    const sideChunk = side === Side.LEFT ? chunk.a : chunk.b;
-    return sum + (sideChunk?.length ?? chunk.ab?.length ?? chunk.skip ?? 0);
-  }, 0);
-}
-
-export function isFileUnchanged(diff: DiffInfo) {
-  return !diff.content.some(
-    content => (content.a && !content.common) || (content.b && !content.common)
-  );
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+export function isNewDiff() {
+  const flags = new Set(window.ENABLED_EXPERIMENTS ?? []);
+  return flags.has('UiFeature__new_diff');
 }
 
 export function getResponsiveMode(
@@ -103,9 +93,7 @@
 }
 
 export function lineNumberToNumber(lineNumber?: LineNumber | null): number {
-  if (!lineNumber) return 0;
-  if (lineNumber === 'LOST') return 0;
-  if (lineNumber === 'FILE') return 0;
+  if (typeof lineNumber !== 'number') return 0;
   return lineNumber;
 }
 
@@ -138,15 +126,15 @@
   const lineNumberStr = lineEl.getAttribute('data-value');
   if (!lineNumberStr) return null;
   if (lineNumberStr === FILE) return FILE;
-  if (lineNumberStr === 'LOST') return 'LOST';
+  if (lineNumberStr === LOST) return LOST;
   const lineNumber = Number(lineNumberStr);
   return Number.isInteger(lineNumber) ? lineNumber : null;
 }
 
 export function getLine(threadEl: HTMLElement): LineNumber {
   const lineAtt = threadEl.getAttribute('line-num');
-  if (lineAtt === 'LOST') return lineAtt;
-  if (!lineAtt || lineAtt === 'FILE') return FILE;
+  if (lineAtt === LOST) return lineAtt;
+  if (!lineAtt || lineAtt === FILE) return FILE;
   const line = Number(lineAtt);
   if (isNaN(line)) throw new Error(`cannot parse line number: ${lineAtt}`);
   if (line < 1) throw new Error(`line number smaller than 1: ${line}`);
@@ -172,6 +160,87 @@
   return range;
 }
 
+/**
+ * This is all the data that gr-diff extracts from comment thread elements.
+ * Otherwise gr-diff treats such elements as a black box.
+ */
+export interface GrDiffCommentThread {
+  side: Side;
+  line: LineNumber;
+  range?: CommentRange;
+  rootId?: string;
+}
+
+export function toCommentThreadModel(
+  threadEl: HTMLElement
+): GrDiffCommentThread | undefined {
+  if (!isThreadEl(threadEl)) return undefined;
+  const side = getSide(threadEl);
+  const line = getLine(threadEl);
+  const range = getRange(threadEl);
+  if (!side) return undefined;
+  if (!line) return undefined;
+  return {side, line, range, rootId: threadEl.rootId};
+}
+
+export interface KeyLocations {
+  left: {[key: string]: boolean};
+  right: {[key: string]: boolean};
+}
+
+export function computeKeyLocations(
+  lineOfInterest: DisplayLine | undefined,
+  comments: GrDiffCommentThread[]
+) {
+  const keyLocations: KeyLocations = {left: {}, right: {}};
+
+  if (lineOfInterest) {
+    keyLocations[lineOfInterest.side][lineOfInterest.lineNum] = true;
+  }
+
+  for (const comment of comments) {
+    keyLocations[comment.side][comment.line] = true;
+    if (comment.range?.start_line) {
+      keyLocations[comment.side][comment.range.start_line] = true;
+    }
+  }
+
+  return keyLocations;
+}
+
+export function compareComments(
+  c1: GrDiffCommentThread,
+  c2: GrDiffCommentThread
+): number {
+  if (c1.side !== c2.side) {
+    return c1.side === Side.RIGHT ? 1 : -1;
+  }
+
+  if (c1.line !== c2.line) {
+    if (c1.line === FILE && c2.line !== FILE) return -1;
+    if (c1.line !== FILE && c2.line === FILE) return 1;
+    if (c1.line === LOST && c2.line !== LOST) return -1;
+    if (c1.line !== LOST && c2.line === LOST) return 1;
+    return (c1.line as number) - (c2.line as number);
+  }
+
+  if (c1.rootId !== c2.rootId) {
+    if (!c1.rootId) return -1;
+    if (!c2.rootId) return 1;
+    return c1.rootId > c2.rootId ? 1 : -1;
+  }
+
+  if (c1.range && c2.range) {
+    const r1 = JSON.stringify(c1.range);
+    const r2 = JSON.stringify(c2.range);
+    return r1 > r2 ? 1 : -1;
+  }
+  if (c1.range) return 1;
+  if (c2.range) return -1;
+
+  return 0;
+}
+
 // TODO: This type should be exposed to gr-diff clients in a separate type file.
 // For Gerrit these are instances of GrCommentThread, but other gr-diff users
 // have different HTML elements in use for comment threads.
@@ -189,20 +258,6 @@
 }
 
 /**
- * @return whether any of the lines in diff are longer
- * than SYNTAX_MAX_LINE_LENGTH.
- */
-export function anyLineTooLong(diff?: DiffInfo) {
-  if (!diff) return false;
-  return diff.content.some(section => {
-    const lines = section.ab
-      ? section.ab
-      : (section.a || []).concat(section.b || []);
-    return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
-  });
-}
-
-/**
  * Simple helper method for creating element classes in the context of
  * gr-diff. This is just a super simple convenience function.
  */
@@ -380,18 +435,3 @@
 
   return blameNode;
 }
-
-/**
- * Get the approximate length of the diff as the sum of the maximum
- * length of the chunks.
- */
-export function getDiffLength(diff?: DiffInfo) {
-  if (!diff) return 0;
-  return diff.content.reduce((sum, sec) => {
-    if (sec.ab) {
-      return sum + sec.ab.length;
-    } else {
-      return sum + Math.max(sec.a?.length ?? 0, sec.b?.length ?? 0);
-    }
-  }, 0);
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 2438bcb..7e6e7fc 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -4,16 +4,19 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {assert} from '@open-wc/testing';
-import {DiffInfo} from '../../../api/diff';
 import '../../../test/common-test-setup';
-import {createDiff} from '../../../test/test-data-generators';
 import {
   createElementDiff,
   formatText,
   createTabWrapper,
-  isFileUnchanged,
   getRange,
+  computeKeyLocations,
+  GrDiffCommentThread,
+  toCommentThreadModel,
+  compareComments,
+  GrDiffThreadElement,
 } from './gr-diff-utils';
+import {FILE, LOST, Side} from '../../../api/diff';
 
 const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
 
@@ -165,38 +168,6 @@
     expectTextLength('\t\t\t\t\t', 20, 100);
   });
 
-  test('isFileUnchanged', () => {
-    let diff: DiffInfo = {
-      ...createDiff(),
-      content: [
-        {a: ['abcd'], ab: ['ef']},
-        {b: ['ancd'], a: ['xx']},
-      ],
-    };
-    assert.equal(isFileUnchanged(diff), false);
-    diff = {
-      ...createDiff(),
-      content: [{ab: ['abcd']}, {ab: ['ancd']}],
-    };
-    assert.equal(isFileUnchanged(diff), true);
-    diff = {
-      ...createDiff(),
-      content: [
-        {a: ['abcd'], ab: ['ef'], common: true},
-        {b: ['ancd'], ab: ['xx']},
-      ],
-    };
-    assert.equal(isFileUnchanged(diff), false);
-    diff = {
-      ...createDiff(),
-      content: [
-        {a: ['abcd'], ab: ['ef'], common: true},
-        {b: ['ancd'], ab: ['xx'], common: true},
-      ],
-    };
-    assert.equal(isFileUnchanged(diff), true);
-  });
-
   test('getRange returns undefined with start_line = 0', () => {
     const range = {
       start_line: 0,
@@ -212,4 +183,151 @@
     threadEl.setAttribute('slot', 'right-1');
     assert.isUndefined(getRange(threadEl));
   });
+
+  suite('key locations', () => {
+    test('lineOfInterest is a key location', () => {
+      const lineOfInterest = {lineNum: 789, side: Side.LEFT};
+      assert.deepEqual(computeKeyLocations(lineOfInterest, []), {
+        left: {789: true},
+        right: {},
+      });
+    });
+
+    test('line comments are key locations', async () => {
+      const comments: GrDiffCommentThread[] = [{side: Side.RIGHT, line: 3}];
+      assert.deepEqual(computeKeyLocations(undefined, comments), {
+        left: {},
+        right: {3: true},
+      });
+    });
+
+    test('file comments are key locations', async () => {
+      const comments: GrDiffCommentThread[] = [{side: Side.LEFT, line: FILE}];
+      assert.deepEqual(computeKeyLocations(undefined, comments), {
+        left: {FILE: true},
+        right: {},
+      });
+    });
+
+    test('lots of key locations', () => {
+      const lineOfInterest = {lineNum: 789, side: Side.LEFT};
+      const comments: GrDiffCommentThread[] = [
+        {side: Side.LEFT, line: FILE},
+        {side: Side.LEFT, line: 2},
+        {side: Side.LEFT, line: 111},
+        {side: Side.RIGHT, line: LOST},
+        {side: Side.RIGHT, line: 13},
+        {side: Side.RIGHT, line: 19},
+      ];
+      assert.deepEqual(computeKeyLocations(lineOfInterest, comments), {
+        left: {FILE: true, 2: true, 111: true, 789: true},
+        right: {LOST: true, 13: true, 19: true},
+      });
+    });
+  });
+
+  suite('toCommentThreadModel', () => {
+    test('simple example', () => {
+      const el = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      el.className = 'comment-thread';
+      el.setAttribute('diff-side', 'left');
+      el.setAttribute('line-num', '3');
+      el.rootId = 'ab12';
+
+      assert.deepEqual(toCommentThreadModel(el), {
+        line: 3,
+        side: Side.LEFT,
+        range: undefined,
+        rootId: 'ab12',
+      });
+    });
+
+    test('FILE default', () => {
+      const el = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      el.className = 'comment-thread';
+      el.setAttribute('diff-side', 'left');
+      el.rootId = 'ab12';
+
+      assert.deepEqual(toCommentThreadModel(el), {
+        line: FILE,
+        side: Side.LEFT,
+        range: undefined,
+        rootId: 'ab12',
+      });
+    });
+
+    test('undefined', () => {
+      const el = document.createElement(
+        'div'
+      ) as unknown as GrDiffThreadElement;
+      assert.isUndefined(toCommentThreadModel(el));
+      el.className = 'comment-thread';
+      assert.isUndefined(toCommentThreadModel(el));
+      el.setAttribute('line-num', '3');
+      assert.isUndefined(toCommentThreadModel(el));
+    });
+  });
+
+  suite('compare comments', () => {
+    test('sort array of comments', () => {
+      const comments: GrDiffCommentThread[] = [
+        {side: Side.RIGHT, line: 3},
+        {side: Side.RIGHT, line: 2},
+        {side: Side.RIGHT, line: 1},
+        {side: Side.RIGHT, line: LOST},
+        {side: Side.RIGHT, line: FILE},
+        {side: Side.LEFT, line: 3},
+        {side: Side.LEFT, line: 2},
+        {
+          side: Side.LEFT,
+          line: 1,
+          rootId: 'b',
+          range: {
+            start_line: 1,
+            start_character: 0,
+            end_line: 5,
+            end_character: 14,
+          },
+        },
+        {
+          side: Side.LEFT,
+          line: 1,
+          rootId: 'b',
+          range: {
+            start_line: 1,
+            start_character: 0,
+            end_line: 2,
+            end_character: 4,
+          },
+        },
+        {side: Side.LEFT, line: 1, rootId: 'b'},
+        {side: Side.LEFT, line: 1, rootId: 'a'},
+        {side: Side.LEFT, line: 1},
+        {side: Side.LEFT, line: LOST},
+      ];
+      const commentsOrdered: GrDiffCommentThread[] = [
+        comments[12],
+        comments[11],
+        comments[10],
+        comments[9],
+        comments[8],
+        comments[7],
+        comments[6],
+        comments[5],
+        comments[4],
+        comments[3],
+        comments[2],
+        comments[1],
+        comments[0],
+      ];
+      assert.sameOrderedMembers(
+        comments.sort(compareComments),
+        commentsOrdered
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 3929330..1f212b0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -6,13 +6,11 @@
 import '../../../styles/shared-styles';
 import '../../../elements/shared/gr-button/gr-button';
 import '../../../elements/shared/gr-icon/gr-icon';
-import '../gr-diff-builder/gr-diff-builder-element';
 import '../gr-diff-highlight/gr-diff-highlight';
 import '../gr-diff-selection/gr-diff-selection';
 import '../gr-syntax-themes/gr-syntax-theme';
 import '../gr-ranged-comment-themes/gr-ranged-comment-theme';
 import '../gr-ranged-comment-hint/gr-ranged-comment-hint';
-import {LineNumber} from './gr-diff-line';
 import {
   getLine,
   getLineElByChild,
@@ -25,37 +23,44 @@
   rangesEqual,
   getResponsiveMode,
   isResponsive,
-  getDiffLength,
-} from './gr-diff-utils';
+  isNewDiff,
+  getSideByLineEl,
+  compareComments,
+  toCommentThreadModel,
+  KeyLocations,
+} from '../gr-diff/gr-diff-utils';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {
   CreateRangeCommentEventDetail,
   GrDiffHighlight,
 } from '../gr-diff-highlight/gr-diff-highlight';
+import {CoverageRange, DiffLayer, isDefined} from '../../../types/types';
 import {
-  GrDiffBuilderElement,
-  getLineNumberCellWidth,
-} from '../gr-diff-builder/gr-diff-builder-element';
-import {CoverageRange, DiffLayer} from '../../../types/types';
-import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+  CommentRangeLayer,
+  GrRangedCommentLayer,
+} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {
   createDefaultDiffPrefs,
   DiffViewMode,
   Side,
 } from '../../../constants/constants';
-import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {
+  GrDiffProcessor,
+  ProcessingOptions,
+} from '../gr-diff-processor/gr-diff-processor';
 import {fire, fireAlert} from '../../../utils/event-util';
 import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
 import {getContentEditableRange} from '../../../utils/safari-selection-util';
 import {AbortStop} from '../../../api/core';
 import {
-  CreateCommentEventDetail as CreateCommentEventDetailApi,
   RenderPreferences,
   GrDiff as GrDiffApi,
   DisplayLine,
+  LineNumber,
+  LOST,
 } from '../../../api/diff';
-import {isSafari, toggleClass} from '../../../utils/dom-util';
+import {isHtmlElement, isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {
   debounceP,
@@ -63,7 +68,7 @@
   DELAYED_CANCELLATION,
 } from '../../../utils/async-util';
 import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {property, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {html, LitElement, nothing, PropertyValues} from 'lit';
 import {when} from 'lit/directives/when.js';
@@ -75,6 +80,26 @@
 import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
 import {provide} from '../../../models/dependency';
 import {grDiffStyles} from './gr-diff-styles';
+import {getDiffLength} from '../../../utils/diff-util';
+import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
+import {
+  GrDiffBuilder,
+  isImageDiffBuilder,
+  isBinaryDiffBuilder,
+  DiffContextExpandedEventDetail,
+} from '../gr-diff-builder/gr-diff-builder';
+import {GrDiffBuilderBinary} from '../gr-diff-builder/gr-diff-builder-binary';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from './gr-diff-group';
+import {GrDiffLine} from './gr-diff-line';
+import {subscribe} from '../../../elements/lit/subscription-controller';
+
+const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -92,11 +117,6 @@
  */
 const COMMIT_MSG_LINE_LENGTH = 72;
 
-export interface CreateCommentEventDetail extends CreateCommentEventDetailApi {
-  path: string;
-}
-
-@customElement('gr-diff')
 export class GrDiff extends LitElement implements GrDiffApi {
   /**
    * Fired when the user selects a line.
@@ -268,11 +288,42 @@
   // Private but used in tests.
   highlights = new GrDiffHighlight();
 
-  // Private but used in tests.
-  diffBuilder = new GrDiffBuilderElement();
-
   private diffModel = new DiffModel(undefined);
 
+  // visible for testing
+  builder?: GrDiffBuilder;
+
+  /**
+   * All layers, both from the outside and the default ones. See `layers` for
+   * the property that can be set from the outside.
+   */
+  // visible for testing
+  layersInternal: DiffLayer[] = [];
+
+  // visible for testing
+  showTabs?: boolean;
+
+  // visible for testing
+  showTrailingWhitespace?: boolean;
+
+  private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
+
+  private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
+
+  private rangeLayer?: GrRangedCommentLayer;
+
+  // visible for testing
+  processor?: GrDiffProcessor;
+
+  /**
+   * Groups are mostly just passed on to the diff builder (this.builder). But
+   * we also keep track of them here for being able to fire a `render-content`
+   * event when .element of each group has rendered.
+   */
+  private groups: GrDiffGroup[] = [];
+
+  private keyLocations: KeyLocations = {left: {}, right: {}};
+
   static override get styles() {
     return [
       iconStyles,
@@ -286,6 +337,11 @@
   constructor() {
     super();
     provide(this, diffModelToken, () => this.diffModel);
+    subscribe(
+      this,
+      () => this.diffModel.keyLocations$,
+      keyLocations => (this.keyLocations = keyLocations)
+    );
     this.addEventListener(
       'create-range-comment',
       (e: CustomEvent<CreateRangeCommentEventDetail>) =>
@@ -305,10 +361,10 @@
     if (this.diff && this.diffTable) {
       this.diffSelection.init(this.diff, this.diffTable);
     }
-    if (this.diffTable && this.diffBuilder) {
-      this.highlights.init(this.diffTable, this.diffBuilder);
+    if (this.diffTable) {
+      this.highlights.init(this.diffTable, this);
     }
-    this.diffBuilder.init();
+    this.diffBuilderInit();
   }
 
   override disconnectedCallback() {
@@ -316,12 +372,27 @@
     this.renderDiffTableTask?.cancel();
     this.diffSelection.cleanup();
     this.highlights.cleanup();
-    this.diffBuilder.cleanup();
+    this.diffBuilderCleanup();
     super.disconnectedCallback();
   }
 
   protected override willUpdate(changedProperties: PropertyValues<this>): void {
     if (
+      changedProperties.has('diff') ||
+      changedProperties.has('path') ||
+      changedProperties.has('renderPrefs') ||
+      changedProperties.has('prefs') ||
+      changedProperties.has('lineOfInterest')
+    ) {
+      this.diffModel.updateState({
+        diff: this.diff,
+        path: this.path,
+        renderPrefs: this.renderPrefs,
+        diffPrefs: this.prefs,
+        lineOfInterest: this.lineOfInterest,
+      });
+    }
+    if (
       changedProperties.has('path') ||
       changedProperties.has('lineWrapping') ||
       changedProperties.has('viewMode') ||
@@ -344,7 +415,7 @@
       }
     }
     if (changedProperties.has('coverageRanges')) {
-      this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+      this.updateCoverageRanges(this.coverageRanges);
     }
     if (changedProperties.has('lineOfInterest')) {
       this.lineOfInterestChanged();
@@ -443,10 +514,6 @@
     document.removeEventListener('mouseup', this.handleMouseUp);
   }
 
-  getLineNumEls(side: Side): HTMLElement[] {
-    return this.diffBuilder.getLineNumEls(side);
-  }
-
   // Private but used in tests.
   showNoChangeMessage() {
     return (
@@ -530,35 +597,7 @@
       });
     }
 
-    this.diffBuilder.updateCommentRanges(this.commentRanges);
-  }
-
-  /**
-   * The key locations based on the comments and line of interests,
-   * where lines should not be collapsed.
-   *
-   */
-  private computeKeyLocations() {
-    const keyLocations: KeyLocations = {left: {}, right: {}};
-    if (this.lineOfInterest) {
-      const side = this.lineOfInterest.side;
-      keyLocations[side][this.lineOfInterest.lineNum] = true;
-    }
-    const threadEls = [...this.childNodes].filter(isThreadEl);
-
-    for (const threadEl of threadEls) {
-      const side = getSide(threadEl);
-      if (!side) continue;
-      const lineNum = getLine(threadEl);
-      const commentRange = getRange(threadEl);
-      keyLocations[side][lineNum] = true;
-      // Add start_line as well if exists,
-      // the being and end of the range should not be collapsed.
-      if (commentRange?.start_line) {
-        keyLocations[side][commentRange.start_line] = true;
-      }
-    }
-    return keyLocations;
+    this.updateCommentRanges(this.commentRanges);
   }
 
   // Dispatch events that are handled by the gr-diff-highlight.
@@ -576,7 +615,7 @@
 
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
-    this.diffBuilder.cleanup();
+    this.diffBuilderCleanup();
     this.renderDiffTableTask?.cancel();
   }
 
@@ -584,8 +623,7 @@
     if (this.hidden && this.noAutoRender) return [];
 
     // Get rendered stops.
-    const stops: Array<HTMLElement | AbortStop> =
-      this.diffBuilder.getLineNumberRows();
+    const stops: Array<HTMLElement | AbortStop> = this.getLineNumberRows();
 
     // If we are still loading this diff, abort after the rendered stops to
     // avoid skipping over to e.g. the next file.
@@ -604,7 +642,7 @@
   }
 
   private blameChanged() {
-    this.diffBuilder.setBlame(this.blame);
+    this.setBlame(this.blame);
     if (this.blame) {
       this.classList.add('showBlame');
     } else {
@@ -617,7 +655,7 @@
     const el = e.target as Element;
 
     if (
-      el.getAttribute('data-value') !== 'LOST' &&
+      el.getAttribute('data-value') !== LOST &&
       (el.classList.contains('lineNum') ||
         el.classList.contains('lineNumButton'))
     ) {
@@ -673,7 +711,7 @@
 
   createCommentForSelection(side: Side, range: CommentRange) {
     const lineNum = range.end_line;
-    const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
+    const lineEl = this.getLineElByNumber(lineNum, side);
     if (lineEl) {
       this.createComment(lineEl, lineNum, side, range);
     }
@@ -694,12 +732,10 @@
     side?: Side,
     range?: CommentRange
   ) {
-    const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+    const contentEl = this.getContentTdByLineEl(lineEl);
     if (!contentEl) throw new Error('content el not found for line el');
     side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
-    assertIsDefined(this.path, 'path');
     fire(this, 'create-comment', {
-      path: this.path,
       side,
       lineNum,
       range,
@@ -721,7 +757,7 @@
     if (!this.lineOfInterest) return;
     const lineNum = this.lineOfInterest.lineNum;
     if (typeof lineNum !== 'number') return;
-    this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
+    this.unhideLine(lineNum, this.lineOfInterest.side);
   }
 
   private cleanup() {
@@ -734,7 +770,6 @@
 
   private prefsChanged() {
     if (!this.prefs) return;
-    this.diffModel.updateState({diffPrefs: this.prefs});
 
     this.blame = null;
     this.updatePreferenceStyles();
@@ -805,7 +840,6 @@
   }
 
   private renderPrefsChanged() {
-    this.diffModel.updateState({renderPrefs: this.renderPrefs});
     if (this.renderPrefs.hide_left_side) {
       this.classList.add('no-left');
     }
@@ -821,7 +855,7 @@
     if (this.prefs) {
       this.updatePreferenceStyles();
     }
-    this.diffBuilder.updateRenderPrefs(this.renderPrefs);
+    this.updateRenderPrefs(this.renderPrefs);
   }
 
   private diffChanged() {
@@ -832,7 +866,7 @@
       this.debounceRenderDiffTable();
       assertIsDefined(this.diffTable, 'diffTable');
       this.diffSelection.init(this.diff, this.diffTable);
-      this.highlights.init(this.diffTable, this.diffBuilder);
+      this.highlights.init(this.diffTable, this);
     }
   }
 
@@ -877,7 +911,7 @@
       return;
     }
     if (
-      this.prefs.context === -1 &&
+      this.getBypassPrefs().context === -1 &&
       this.diffLength &&
       this.diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
       this.safetyBypass === null
@@ -889,34 +923,9 @@
 
     this.showWarning = false;
 
-    const keyLocations = this.computeKeyLocations();
-
-    this.diffModel.setState({
-      diff: this.diff,
-      path: this.path,
-      renderPrefs: this.renderPrefs,
-      diffPrefs: this.prefs,
-    });
-
-    // TODO: Setting tons of public properties like this is obviously a code
-    // smell. We are introducing a diff model for managing all this
-    // data. Then diff builder will only need access to that model.
-    this.diffBuilder.prefs = this.getBypassPrefs();
-    this.diffBuilder.renderPrefs = this.renderPrefs;
-    this.diffBuilder.diff = this.diff;
-    this.diffBuilder.path = this.path;
-    this.diffBuilder.viewMode = this.viewMode;
-    this.diffBuilder.layers = this.layers ?? [];
-    this.diffBuilder.isImageDiff = this.isImageDiff;
-    this.diffBuilder.baseImage = this.baseImage ?? null;
-    this.diffBuilder.revisionImage = this.revisionImage ?? null;
-    this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
-    this.diffBuilder.diffElement = this.diffTable;
-    // `this.commentRanges` are probably empty here, because they will only be
-    // populated by the node observer, which starts observing *after* rendering.
-    this.diffBuilder.updateCommentRanges(this.commentRanges);
-    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
-    await this.diffBuilder.render(keyLocations);
+    this.updateCommentRanges(this.commentRanges);
+    this.updateCoverageRanges(this.coverageRanges);
+    await this.legacyRender();
   }
 
   private handleRenderContent() {
@@ -951,6 +960,13 @@
     addedThreadEls: GrDiffThreadElement[],
     removedThreadEls: GrDiffThreadElement[]
   ) {
+    this.diffModel.updateState({
+      comments: [...this.childNodes]
+        .filter(isHtmlElement)
+        .map(toCommentThreadModel)
+        .filter(isDefined)
+        .sort(compareComments),
+    });
     this.updateRanges(addedThreadEls, removedThreadEls);
     addedThreadEls.forEach(threadEl =>
       this.redispatchHoverEvents(threadEl, threadEl)
@@ -965,7 +981,7 @@
       const commentSide = getSide(threadEl);
       const range = getRange(threadEl);
       if (!commentSide) continue;
-      const lineEl = this.diffBuilder.getLineElByNumber(lineNum, commentSide);
+      const lineEl = this.getLineElByNumber(lineNum, commentSide);
       // When the line the comment refers to does not exist, log an error
       // but don't crash. This can happen e.g. if the API does not fully
       // validate e.g. (robot) comments
@@ -978,9 +994,9 @@
         );
         continue;
       }
-      const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+      const contentEl = this.getContentTdByLineEl(lineEl);
       if (!contentEl) continue;
-      if (lineNum === 'LOST') {
+      if (lineNum === LOST) {
         this.insertPortedCommentsWithoutRangeMessage(contentEl);
       }
 
@@ -1035,7 +1051,8 @@
   /**
    * Get the preferences object including the safety bypass context (if any).
    */
-  private getBypassPrefs() {
+  // visible for testing
+  getBypassPrefs() {
     assertIsDefined(this.prefs, 'prefs');
     if (this.safetyBypass !== null) {
       return {...this.prefs, context: this.safetyBypass};
@@ -1046,9 +1063,7 @@
   clearDiffContent() {
     this.unobserveNodes();
     if (!this.diffTable) return;
-    while (this.diffTable.hasChildNodes()) {
-      this.diffTable.removeChild(this.diffTable.lastChild!);
-    }
+    this.diffTable.innerHTML = '';
   }
 
   // Private but used in tests.
@@ -1105,6 +1120,422 @@
     }
     return messages.join(' \u2014 '); // \u2014 - '—'
   }
+
+  private updateCommentRanges(ranges: CommentRangeLayer[]) {
+    this.rangeLayer?.updateRanges(ranges);
+  }
+
+  private updateCoverageRanges(rs: CoverageRange[]) {
+    this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
+    this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
+  }
+
+  legacyRender(): Promise<void> {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffTable, 'diff table');
+    assertIsDefined(this.prefs, 'prefs');
+
+    // Setting up annotation layers must happen after plugins are
+    // installed, and |render| satisfies the requirement, however,
+    // |attached| doesn't because in the diff view page, the element is
+    // attached before plugins are installed.
+    this.setupAnnotationLayers();
+
+    this.showTabs = this.prefs.show_tabs;
+    this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
+
+    this.diffBuilderCleanup();
+    this.builder = this.getDiffBuilder();
+    this.diffBuilderInit();
+
+    this.diffTable.innerHTML = '';
+    this.builder.addColumns(this.diffTable, getLineNumberCellWidth(this.prefs));
+
+    const options: ProcessingOptions = {
+      context: this.getBypassPrefs().context,
+      keyLocations: this.keyLocations,
+      isBinary: !!(this.isImageDiff || this.diff.binary),
+    };
+    if (this.renderPrefs?.num_lines_rendered_at_once) {
+      options.asyncThreshold = this.renderPrefs.num_lines_rendered_at_once;
+    }
+    this.processor = new GrDiffProcessor(this, options);
+
+    fire(this.diffTable, 'render-start', {});
+    return (
+      this.processor
+        .process(this.diff.content)
+        .then(async () => {
+          if (isImageDiffBuilder(this.builder)) {
+            this.builder.renderImageDiff();
+          } else if (isBinaryDiffBuilder(this.builder)) {
+            this.builder.renderBinaryDiff();
+          }
+          await this.untilGroupsRendered();
+          fire(this.diffTable, 'render-content', {});
+        })
+        // Mocha testing does not like uncaught rejections, so we catch
+        // the cancels which are expected and should not throw errors in
+        // tests.
+        .catch(e => {
+          if (!e.isCanceled) return Promise.reject(e);
+          return;
+        })
+    );
+  }
+
+  // visible for testing
+  async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
+    return Promise.all(groups.map(g => g.waitUntilRendered()));
+  }
+
+  private onDiffContextExpanded = (
+    e: CustomEvent<DiffContextExpandedEventDetail>
+  ) => {
+    // Don't stop propagation. The host may listen for reporting or
+    // resizing.
+    this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+  };
+
+  // visible for testing
+  setupAnnotationLayers() {
+    this.rangeLayer = new GrRangedCommentLayer();
+
+    const layers: DiffLayer[] = [
+      this.createTrailingWhitespaceLayer(),
+      this.createIntralineLayer(),
+      this.createTabIndicatorLayer(),
+      this.createSpecialCharacterIndicatorLayer(),
+      this.rangeLayer,
+      this.coverageLayerLeft,
+      this.coverageLayerRight,
+    ];
+
+    if (this.layers) {
+      layers.push(...this.layers);
+    }
+    this.layersInternal = layers;
+  }
+
+  getContentTdByLine(lineNumber: LineNumber, side?: Side) {
+    if (!this.builder) return undefined;
+    return this.builder.getContentTdByLine(lineNumber, side);
+  }
+
+  getContentTdByLineEl(lineEl?: Element): Element | undefined {
+    if (!lineEl) return undefined;
+    const line = getLineNumber(lineEl);
+    if (!line) return undefined;
+    const side = getSideByLineEl(lineEl);
+    return this.getContentTdByLine(line, side);
+  }
+
+  getLineElByNumber(lineNumber: LineNumber, side?: Side) {
+    if (!this.builder) return undefined;
+    return this.builder.getLineElByNumber(lineNumber, side);
+  }
+
+  getLineNumberRows() {
+    if (!this.builder) return [];
+    return this.builder.getLineNumberRows();
+  }
+
+  getLineNumEls(side: Side) {
+    if (!this.builder) return [];
+    return this.builder.getLineNumEls(side);
+  }
+
+  /**
+   * When the line is hidden behind a context expander, expand it.
+   *
+   * @param lineNum A line number to expand. Using number here because other
+   *   special case line numbers are never hidden, so it does not make sense
+   *   to expand them.
+   * @param side The side the line number refer to.
+   */
+  unhideLine(lineNum: number, side: Side) {
+    assertIsDefined(this.prefs, 'prefs');
+    if (!this.builder) return;
+    const group = this.builder.findGroup(side, lineNum);
+    // Cannot unhide a line that is not part of the diff.
+    if (!group) return;
+    // If it's already visible, great!
+    if (group.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+    const lineRange = group.lineRange[side];
+    const lineOffset = lineNum - lineRange.start_line;
+    const newGroups = [];
+    const groups = hideInContextControl(
+      group.contextGroups,
+      0,
+      lineOffset - 1 - this.prefs.context
+    );
+    // If there is a context group, it will be the first group because we
+    // start hiding from 0 offset
+    if (groups[0].type === GrDiffGroupType.CONTEXT_CONTROL) {
+      newGroups.push(groups.shift()!);
+    }
+    newGroups.push(
+      ...hideInContextControl(
+        groups,
+        lineOffset + 1 + this.prefs.context,
+        // Both ends inclusive, so difference is the offset of the last line.
+        // But we need to pass the first line not to hide, which is the element
+        // after.
+        lineRange.end_line - lineRange.start_line + 1
+      )
+    );
+    this.replaceGroup(group, newGroups);
+  }
+
+  /**
+   * Replace the group of a context control section by rendering the provided
+   * groups instead. This happens in response to expanding a context control
+   * group.
+   *
+   * @param contextGroup The context control group to replace
+   * @param newGroups The groups that are replacing the context control group
+   */
+  private replaceGroup(
+    contextGroup: GrDiffGroup,
+    newGroups: readonly GrDiffGroup[]
+  ) {
+    if (!this.builder) return;
+    fire(this.diffTable, 'render-start', {});
+    this.builder.replaceGroup(contextGroup, newGroups);
+    this.groups = this.groups.filter(g => g !== contextGroup);
+    this.groups.push(...newGroups);
+    this.untilGroupsRendered(newGroups).then(() => {
+      fire(this.diffTable, 'render-content', {});
+    });
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component re-connects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with cleanup(), which is called
+   * when gr-diff disconnects.
+   */
+  private diffBuilderInit() {
+    this.cleanup();
+    this.diffTable?.addEventListener(
+      'diff-context-expanded-internal-new',
+      this.onDiffContextExpanded
+    );
+    this.builder?.init();
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component disconnects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with init(), which is called when
+   * gr-diff re-connects.
+   */
+  private diffBuilderCleanup() {
+    this.processor?.cancel();
+    this.builder?.cleanup();
+    this.diffTable?.removeEventListener(
+      'diff-context-expanded-internal-new',
+      this.onDiffContextExpanded
+    );
+  }
+
+  // visible for testing
+  handlePreferenceError(pref: string): never {
+    const message =
+      `The value of the '${pref}' user preference is ` +
+      'invalid. Fix in diff preferences';
+    assertIsDefined(this.diffTable, 'diff table');
+    fireAlert(this.diffTable, message);
+    throw Error(`Invalid preference value: ${pref}`);
+  }
+
+  // visible for testing
+  getDiffBuilder(): GrDiffBuilder {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffTable, 'diff table');
+    assertIsDefined(this.prefs, 'prefs');
+    if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
+      this.handlePreferenceError('tab size');
+    }
+
+    if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
+      this.handlePreferenceError('diff width');
+    }
+
+    const localPrefs = {...this.prefs};
+    if (this.path === COMMIT_MSG_PATH) {
+      // override line_length for commit msg the same way as
+      // in gr-diff
+      localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
+    }
+
+    let builder = null;
+    if (this.isImageDiff) {
+      builder = new GrDiffBuilderImage(
+        this.diff,
+        localPrefs,
+        this.diffTable,
+        this.baseImage ?? null,
+        this.revisionImage ?? null,
+        this.renderPrefs,
+        this.useNewImageDiffUi
+      );
+    } else if (this.diff.binary) {
+      return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffTable);
+    } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      this.renderPrefs = {
+        ...this.renderPrefs,
+        view_mode: DiffViewMode.SIDE_BY_SIDE,
+      };
+      builder = new GrDiffBuilder(
+        this.diff,
+        localPrefs,
+        this.diffTable,
+        this.layersInternal,
+        this.renderPrefs
+      );
+    } else if (this.viewMode === DiffViewMode.UNIFIED) {
+      this.renderPrefs = {
+        ...this.renderPrefs,
+        view_mode: DiffViewMode.UNIFIED,
+      };
+      builder = new GrDiffBuilder(
+        this.diff,
+        localPrefs,
+        this.diffTable,
+        this.layersInternal,
+        this.renderPrefs
+      );
+    }
+    if (!builder) {
+      throw Error(`Unsupported diff view mode: ${this.viewMode}`);
+    }
+    return builder;
+  }
+
+  /**
+   * Called when the processor starts converting the diff information from the
+   * server into chunks.
+   */
+  clearGroups() {
+    if (!this.builder) return;
+    this.groups = [];
+    this.builder.clearGroups();
+  }
+
+  /**
+   * Called when the processor is done converting a chunk of the diff.
+   */
+  addGroup(group: GrDiffGroup) {
+    if (!this.builder) return;
+    this.builder.addGroups([group]);
+    this.groups.push(group);
+  }
+
+  // visible for testing
+  createIntralineLayer(): DiffLayer {
+    return {
+      // Take a DIV.contentText element and a line object with intraline
+      // differences to highlight and apply them to the element as
+      // annotations.
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        const HL_CLASS = 'gr-diff intraline';
+        for (const highlight of line.highlights) {
+          // The start and end indices could be the same if a highlight is
+          // meant to start at the end of a line and continue onto the
+          // next one. Ignore it.
+          if (highlight.startIndex === highlight.endIndex) {
+            continue;
+          }
+
+          // If endIndex isn't present, continue to the end of the line.
+          const endIndex =
+            highlight.endIndex === undefined
+              ? GrAnnotation.getStringLength(line.text)
+              : highlight.endIndex;
+
+          GrAnnotation.annotateElement(
+            contentEl,
+            highlight.startIndex,
+            endIndex - highlight.startIndex,
+            HL_CLASS
+          );
+        }
+      },
+    };
+  }
+
+  // visible for testing
+  createTabIndicatorLayer(): DiffLayer {
+    const show = () => this.showTabs;
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        // If visible tabs are disabled, do nothing.
+        if (!show()) {
+          return;
+        }
+
+        // Find and annotate the locations of tabs.
+        annotateSymbols(contentEl, line, '\t', 'tab-indicator');
+      },
+    };
+  }
+
+  private createSpecialCharacterIndicatorLayer(): DiffLayer {
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        // Find and annotate the locations of soft hyphen (\u00AD)
+        annotateSymbols(contentEl, line, '\u00AD', 'special-char-indicator');
+        // Find and annotate Stateful Unicode directional controls
+        annotateSymbols(
+          contentEl,
+          line,
+          /[\u202A-\u202E\u2066-\u2069]/,
+          'special-char-warning'
+        );
+      },
+    };
+  }
+
+  // visible for testing
+  createTrailingWhitespaceLayer(): DiffLayer {
+    const show = () => this.showTrailingWhitespace;
+
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        if (!show()) {
+          return;
+        }
+
+        const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+        if (match) {
+          // Normalize string positions in case there is unicode before or
+          // within the match.
+          const index = GrAnnotation.getStringLength(
+            line.text.substr(0, match.index)
+          );
+          const length = GrAnnotation.getStringLength(match[0]);
+          GrAnnotation.annotateElement(
+            contentEl,
+            index,
+            length,
+            'gr-diff trailing-whitespace'
+          );
+        }
+      },
+    };
+  }
+
+  setBlame(blame: BlameInfo[] | null) {
+    if (!this.builder) return;
+    this.builder.setBlame(blame ?? []);
+  }
+
+  updateRenderPrefs(renderPrefs: RenderPreferences) {
+    this.builder?.updateRenderPrefs(renderPrefs);
+  }
 }
 
 function extractAddedNodes(mutations: MutationRecord[]) {
@@ -1115,14 +1546,54 @@
   return mutations.flatMap(mutation => [...mutation.removedNodes]);
 }
 
+function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
+  return prefs.font_size * 4;
+}
+
+function annotateSymbols(
+  contentEl: HTMLElement,
+  line: GrDiffLine,
+  separator: string | RegExp,
+  className: string
+) {
+  const split = line.text.split(separator);
+  if (!split || split.length < 2) {
+    return;
+  }
+  for (let i = 0, pos = 0; i < split.length - 1; i++) {
+    // Skip forward by the length of the content
+    pos += split[i].length;
+
+    GrAnnotation.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
+
+    pos++;
+  }
+}
+
+// TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+if (isNewDiff()) {
+  customElements.define('gr-diff', GrDiff);
+}
+
 declare global {
   interface HTMLElementTagNameMap {
-    'gr-diff': GrDiff;
+    // TODO(newdiff-cleanup): Replace once newdiff migration is completed.
+    'gr-diff': LitElement;
   }
   interface HTMLElementEventMap {
     'comment-thread-mouseenter': CustomEvent<{}>;
     'comment-thread-mouseleave': CustomEvent<{}>;
     'loading-changed': ValueChangedEvent<boolean>;
     'render-required': CustomEvent<{}>;
+    /**
+     * Fired when the diff begins rendering - both for full renders and for
+     * partial rerenders.
+     */
+    'render-start': CustomEvent<{}>;
+    /**
+     * Fired when the diff finishes rendering text content - both for full
+     * renders and for partial rerenders.
+     */
+    'render-content': CustomEvent<{}>;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index f0826ad..99db4e9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -4,15 +4,21 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import {createDiff} from '../../../test/test-data-generators';
+import {
+  createConfig,
+  createDiff,
+  createEmptyDiff,
+} from '../../../test/test-data-generators';
 import './gr-diff';
-import {getComputedStyleValue} from '../../../utils/dom-util';
+import {getComputedStyleValue, querySelectorAll} from '../../../utils/dom-util';
 import '@polymer/paper-button/paper-button';
 import {
   DiffContent,
   DiffInfo,
+  DiffLayer,
   DiffPreferencesInfo,
   DiffViewMode,
+  GrDiffLineType,
   IgnoreWhitespaceType,
   Side,
 } from '../../../api/diff';
@@ -22,6 +28,8 @@
   query,
   queryAll,
   queryAndAssert,
+  stubBaseUrl,
+  stubRestApi,
   waitEventLoop,
   waitQueryAndAssert,
   waitUntil,
@@ -33,6 +41,13 @@
 import {GrRangedCommentHint} from '../gr-ranged-comment-hint/gr-ranged-comment-hint';
 import {assertIsDefined} from '../../../utils/common-util';
 import {fixture, html, assert} from '@open-wc/testing';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder';
+import {GrDiffRow} from '../gr-diff-builder/gr-diff-row';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine} from './gr-diff-line';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -3021,12 +3036,6 @@
     });
   });
 
-  test('cancel', () => {
-    const cleanupStub = sinon.stub(element.diffBuilder, 'cleanup');
-    element.cancel();
-    assert.isTrue(cleanupStub.calledOnce);
-  });
-
   test('line limit with line_wrapping', async () => {
     element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
     await element.updateComplete;
@@ -3796,10 +3805,9 @@
     let renderStub: sinon.SinonStub;
 
     setup(async () => {
-      renderStub = sinon.stub(element.diffBuilder, 'render').callsFake(() => {
+      renderStub = sinon.stub(element, 'legacyRender').callsFake(() => {
         assertIsDefined(element.diffTable);
-        const diffTable = element.diffTable;
-        diffTable.dispatchEvent(
+        element.diffTable.dispatchEvent(
           new CustomEvent('render', {bubbles: true, composed: true})
         );
         return Promise.resolve();
@@ -3847,7 +3855,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.equal(element.safetyBypass, -1);
-      assert.equal(element.diffBuilder.prefs.context, -1);
+      assert.equal(element.getBypassPrefs().context, -1);
     });
 
     test('toggles collapse context from bypass', async () => {
@@ -3860,7 +3868,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.isNull(element.safetyBypass);
-      assert.equal(element.diffBuilder.prefs.context, 3);
+      assert.equal(element.getBypassPrefs().context, 3);
     });
 
     test('toggles collapse context from pref using default', async () => {
@@ -3872,14 +3880,14 @@
 
       assert.equal(element.prefs.context, -1);
       assert.equal(element.safetyBypass, 10);
-      assert.equal(element.diffBuilder.prefs.context, 10);
+      assert.equal(element.getBypassPrefs().context, 10);
     });
   });
 
   suite('blame', () => {
     test('unsetting', async () => {
       element.blame = [];
-      const setBlameSpy = sinon.spy(element.diffBuilder, 'setBlame');
+      const setBlameSpy = sinon.spy(element, 'setBlame');
       element.classList.add('showBlame');
       element.blame = null;
       await element.updateComplete;
@@ -3956,57 +3964,6 @@
     });
   });
 
-  suite('key locations', () => {
-    let renderStub: sinon.SinonStub;
-
-    setup(async () => {
-      element.prefs = {...MINIMAL_PREFS};
-      element.diff = createDiff();
-      renderStub = sinon.stub(element.diffBuilder, 'render');
-      await element.updateComplete;
-    });
-
-    test('lineOfInterest is a key location', () => {
-      element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
-      element.renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {789: true},
-        right: {},
-      });
-    });
-
-    test('line comments are key locations', async () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'right');
-      threadEl.setAttribute('line-num', '3');
-      element.appendChild(threadEl);
-      await element.updateComplete;
-
-      element.renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {},
-        right: {3: true},
-      });
-    });
-
-    test('file comments are key locations', async () => {
-      const threadEl = document.createElement('div');
-      threadEl.className = 'comment-thread';
-      threadEl.setAttribute('diff-side', 'left');
-      element.appendChild(threadEl);
-      await element.updateComplete;
-
-      element.renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
-        left: {FILE: true},
-        right: {},
-      });
-    });
-  });
   const setupSampleDiff = async function (params: {
     content: DiffContent[];
     ignore_whitespace?: IgnoreWhitespaceType;
@@ -4057,8 +4014,7 @@
     ];
     function diffTableHasContent() {
       assertIsDefined(element.diffTable);
-      const diffTable = element.diffTable;
-      return diffTable.innerText.includes(content[0].a?.[0] ?? '');
+      return element.diffTable.innerText.includes(content[0].a?.[0] ?? '');
     }
     await setupSampleDiff({content});
     await waitUntil(diffTableHasContent);
@@ -4066,8 +4022,7 @@
     await element.updateComplete;
     // immediately cleaned up
     assertIsDefined(element.diffTable);
-    const diffTable = element.diffTable;
-    assert.equal(diffTable.innerHTML, '');
+    assert.equal(element.diffTable.innerHTML, '');
     element.renderDiffTable();
     await element.updateComplete;
     // rendered again
@@ -4182,3 +4137,598 @@
     assert.equal(element.getDiffLength(diff), 52);
   });
 });
+
+suite('former gr-diff-builder tests', () => {
+  let element: GrDiff;
+  let builder: GrDiffBuilder;
+  let diffTable: HTMLTableElement;
+
+  const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
+    builder = new GrDiffBuilder(
+      createEmptyDiff(),
+      {...createDefaultDiffPrefs(), ...prefs},
+      diffTable
+    );
+  };
+
+  const line = (text: string) => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.text = text;
+    return line;
+  };
+
+  setup(async () => {
+    element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+    element.diff = createEmptyDiff();
+    await element.updateComplete;
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+    stubBaseUrl('/r');
+    setBuilderPrefs({});
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+    test(`line_length used for regular files under ${mode}`, () => {
+      element.path = '/a.txt';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder();
+      assert.equal(builder.prefs.line_length, 50);
+    });
+
+    test(`line_length ignored for commit msg under ${mode}`, () => {
+      element.path = '/COMMIT_MSG';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder();
+      assert.equal(builder.prefs.line_length, 72);
+    });
+  });
+
+  test('_handlePreferenceError throws with invalid preference', () => {
+    element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
+    assert.throws(() => element.getDiffBuilder());
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    element.diffTable!.addEventListener('show-alert', errorStub);
+    assert.throws(() => element.handlePreferenceError('tab size'));
+    assert.equal(
+      errorStub.lastCall.args[0].detail.message,
+      "The value of the 'tab size' user preference is invalid. " +
+        'Fix in diff preferences'
+    );
+  });
+
+  suite('intraline differences', () => {
+    let el: HTMLElement;
+    let str: string;
+    let annotateElementSpy: sinon.SinonSpy;
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    function slice(str: string, start: number, end?: number) {
+      return Array.from(str).slice(start, end).join('');
+    }
+
+    setup(async () => {
+      el = await fixture(html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `);
+      str = el.textContent ?? '';
+      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+      layer = element.createIntralineLayer();
+    });
+
+    test('annotate no highlights', () => {
+      layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
+
+      // The content is unchanged.
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(str, el.childNodes[0].textContent);
+    });
+
+    test('annotate with highlights', () => {
+      const l = line(str);
+      l.highlights = [
+        {contentIndex: 0, startIndex: 6, endIndex: 12},
+        {contentIndex: 0, startIndex: 18, endIndex: 22},
+      ];
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12, 18);
+      const str3 = slice(str, 18, 22);
+      const str4 = slice(str, 22);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 5);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+
+      assert.notInstanceOf(el.childNodes[3], Text);
+      assert.equal(el.childNodes[3].textContent, str3);
+
+      assert.instanceOf(el.childNodes[4], Text);
+      assert.equal(el.childNodes[4].textContent, str4);
+    });
+
+    test('annotate without endIndex', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28}];
+
+      const str0 = slice(str, 0, 28);
+      const str1 = slice(str, 28);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+
+    test('annotate ignores empty highlights', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+    });
+
+    test('annotate handles unicode', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 3);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+    });
+
+    test('annotate handles unicode w/o endIndex', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6);
+      const numHighlightedChars = GrAnnotation.getStringLength(str1);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.calledWith(el, 6, numHighlightedChars));
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+  });
+
+  suite('tab indicators', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTabs = true;
+      layer = element.createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no tabs', () => {
+      const str = 'lorem ipsum no tabs';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates tab at beginning', () => {
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTabs = false;
+
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates multiple in beginning', () => {
+      const str = '\t\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 2);
+
+      let args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+
+      args = annotateElementStub.getCalls()[1].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 1, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('annotates intermediate tabs', () => {
+      const str = 'lorem\tupsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 5, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+  });
+
+  suite('layers', () => {
+    let initialLayersCount = 0;
+    let withLayerCount = 0;
+    setup(() => {
+      const layers: DiffLayer[] = [];
+      element.layers = layers;
+      element.showTrailingWhitespace = true;
+      element.setupAnnotationLayers();
+      initialLayersCount = element.layersInternal.length;
+    });
+
+    test('no layers', () => {
+      element.setupAnnotationLayers();
+      assert.equal(element.layersInternal.length, initialLayersCount);
+    });
+
+    suite('with layers', () => {
+      const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
+      setup(() => {
+        element.layers = layers;
+        element.showTrailingWhitespace = true;
+        element.setupAnnotationLayers();
+        withLayerCount = element.layersInternal.length;
+      });
+      test('with layers', () => {
+        element.setupAnnotationLayers();
+        assert.equal(element.layersInternal.length, withLayerCount);
+        assert.equal(initialLayersCount + layers.length, withLayerCount);
+      });
+    });
+  });
+
+  suite('trailing whitespace', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTrailingWhitespace = true;
+      layer = element.createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no trailing whitespace', () => {
+      const str = 'lorem ipsum blah blah';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates trailing spaces', () => {
+      const str = 'lorem ipsum   ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates trailing tabs', () => {
+      const str = 'lorem ipsum\t\t\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates mixed trailing whitespace', () => {
+      const str = 'lorem ipsum\t \t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('unicode preceding trailing whitespace', () => {
+      const str = '💢\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 1);
+      assert.equal(annotateElementStub.lastCall.args[2], 1);
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTrailingWhitespace = false;
+      const str = 'lorem upsum\t \t ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let content: DiffContent[] = [];
+
+    setup(() => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+    });
+
+    test('text', async () => {
+      element.diff = {...createEmptyDiff(), content};
+      await waitForEventOnce(element.diffTable!, 'render-content');
+      assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 4);
+    });
+
+    test('image', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.isImageDiff = true;
+      await waitForEventOnce(element.diffTable!, 'render-content');
+      assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 4);
+    });
+
+    test('binary', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      await waitForEventOnce(element.diffTable!, 'render-content');
+      assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 3);
+    });
+  });
+
+  suite('context hiding and expanding', () => {
+    let dispatchStub: sinon.SinonStub;
+
+    setup(async () => {
+      dispatchStub = sinon.stub(element.diffTable!, 'dispatchEvent');
+      element.diff = {
+        ...createEmptyDiff(),
+        content: [
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
+          {a: ['before'], b: ['after']},
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
+        ],
+      };
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: 1,
+      };
+      await element.updateComplete;
+      element.legacyRender();
+      // Make sure all listeners are installed.
+      await element.untilGroupsRendered();
+    });
+
+    test('hides lines behind two context controls', () => {
+      const contextControls = element.diffTable!.querySelectorAll(
+        'gr-context-controls'
+      );
+      assert.equal(contextControls.length, 2);
+
+      const diffRows = element.diffTable!.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 10');
+      assert.include(diffRows[3].textContent, 'before');
+      assert.include(diffRows[3].textContent, 'after');
+      assert.include(diffRows[4].textContent, 'unchanged 11');
+    });
+
+    test('clicking +x common lines expands those lines', async () => {
+      const contextControls = element.diffTable!.querySelectorAll(
+        'gr-context-controls'
+      );
+      const topExpandCommonButton =
+        contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
+          '.showContext'
+        )[0];
+      assert.isOk(topExpandCommonButton);
+      assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+      let diffRows = element.diffTable!.querySelectorAll('.diff-row');
+      // 5 lines:
+      // FILE, LOST, the changed line plus one line of context in each direction
+      assert.equal(diffRows.length, 5);
+
+      topExpandCommonButton!.click();
+
+      await waitUntil(() => {
+        diffRows = element.diffTable!.querySelectorAll<GrDiffRow>('.diff-row');
+        return diffRows.length === 14;
+      });
+      // 14 lines: The 5 above plus the 9 unchanged lines that were expanded
+      assert.equal(diffRows.length, 14);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 6');
+      assert.include(diffRows[8].textContent, 'unchanged 7');
+      assert.include(diffRows[9].textContent, 'unchanged 8');
+      assert.include(diffRows[10].textContent, 'unchanged 9');
+      assert.include(diffRows[11].textContent, 'unchanged 10');
+      assert.include(diffRows[12].textContent, 'before');
+      assert.include(diffRows[12].textContent, 'after');
+      assert.include(diffRows[13].textContent, 'unchanged 11');
+    });
+
+    test('unhideLine shows the line with context', async () => {
+      dispatchStub.reset();
+      element.unhideLine(4, Side.LEFT);
+
+      await waitUntil(() => {
+        const rows =
+          element.diffTable!.querySelectorAll<GrDiffRow>('.diff-row');
+        return rows.length === 2 + 5 + 1 + 1 + 1;
+      });
+
+      const diffRows = element.diffTable!.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
+      // Because context expanders do not hide <3 lines, lines 1-2 will also
+      // be shown.
+      // Lines 6-9 continue to be hidden
+      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 10');
+      assert.include(diffRows[8].textContent, 'before');
+      assert.include(diffRows[8].textContent, 'after');
+      assert.include(diffRows[9].textContent, 'unchanged 11');
+
+      await element.untilGroupsRendered();
+      const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 38eecfa..e2837ab 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -4,12 +4,13 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {strToClassName} from '../../../utils/dom-util';
 import {Side} from '../../../constants/constants';
 import {CommentRange} from '../../../types/common';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {isLongCommentRange} from '../gr-diff/gr-diff-utils';
+import {GrDiffLineType} from '../../../api/diff';
 
 /**
  * Enhanced CommentRange by UI state. Interface for incoming ranges set from the
@@ -192,7 +193,7 @@
   // visible for testing
   getRangesForLine(line: GrDiffLine, side: Side): CommentRangeLineLayer[] {
     const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
-    if (lineNum === 'FILE' || lineNum === 'LOST') return [];
+    if (typeof lineNum !== 'number') return [];
     const ranges: CommentRangeLineLayer[] = this.rangesMap[side][lineNum] || [];
     return ranges.map(range => {
       // Make a copy, so that the normalization below does not mess with
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 7feda47..b90d6f7 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -11,8 +11,8 @@
   GrRangedCommentLayer,
 } from './gr-ranged-comment-layer';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {Side} from '../../../api/diff';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType, Side} from '../../../api/diff';
 import {SinonStub} from 'sinon';
 import {assert} from '@open-wc/testing';
 
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index da08a1f..baa2ab4 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -4,7 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
 import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {Side} from '../../../constants/constants';
@@ -13,6 +13,8 @@
 import {HighlightService} from '../../../services/highlight/highlight-service';
 import {Provider} from '../../../models/dependency';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {GrDiffLineType} from '../../../api/diff';
+import {assert} from '../../../utils/common-util';
 
 const LANGUAGE_MAP = new Map<string, string>([
   ['application/dart', 'dart'],
@@ -183,8 +185,8 @@
 
   annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
     if (!this.enabled) return;
-    if (line.beforeNumber === FILE || line.afterNumber === FILE) return;
-    if (line.beforeNumber === 'LOST' || line.afterNumber === 'LOST') return;
+    if (typeof line.beforeNumber !== 'number') return;
+    if (typeof line.afterNumber !== 'number') return;
 
     let side: Side | undefined;
     if (
@@ -203,6 +205,7 @@
 
     const isLeft = side === Side.LEFT;
     const lineNumber = isLeft ? line.beforeNumber : line.afterNumber;
+    assert(typeof lineNumber === 'number', 'lineNumber must be a number');
     const rangesPerLine = isLeft ? this.leftRanges : this.rightRanges;
     const ranges = rangesPerLine[lineNumber - 1]?.ranges ?? [];
 
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index c7111f6..6461892 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -281,6 +281,12 @@
         viewModelState?.patchNum || latestPatchN
     );
 
+  /** The user can enter edit mode without an `EDIT` patchset existing yet. */
+  public readonly editMode$ = select(
+    combineLatest([this.viewModel.edit$, this.patchNum$]),
+    ([edit, patchNum]) => !!edit || patchNum === EDIT
+  );
+
   /**
    * Emits the base patchset number. This is identical to the
    * `viewModel.basePatchNum$`, but has some special logic for merges.
@@ -405,7 +411,6 @@
 
   private fireShowChange() {
     return combineLatest([
-      this.viewModel.childView$,
       this.change$,
       this.basePatchNum$,
       this.patchNum$,
@@ -413,15 +418,11 @@
     ])
       .pipe(
         filter(
-          ([childView, change, basePatchNum, patchNum, mergeable]) =>
-            childView === ChangeChildView.OVERVIEW &&
-            !!change &&
-            !!basePatchNum &&
-            !!patchNum &&
-            mergeable !== undefined
+          ([change, basePatchNum, patchNum, mergeable]) =>
+            !!change && !!basePatchNum && !!patchNum && mergeable !== undefined
         )
       )
-      .subscribe(([_, change, basePatchNum, patchNum, mergeable]) => {
+      .subscribe(([change, basePatchNum, patchNum, mergeable]) => {
         this.pluginLoader.jsApiService.handleShowChange({
           change,
           basePatchNum,
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index dc7d9c3..db9187a 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -22,6 +22,7 @@
   waitUntilObserved,
 } from '../../test/test-utils';
 import {
+  BasePatchSetNum,
   CommitId,
   EDIT,
   NumericChangeId,
@@ -221,7 +222,7 @@
     });
   });
 
-  test('fireShowChange', async () => {
+  test('fireShowChange from overview', async () => {
     await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
     const pluginLoader = testResolver(pluginLoaderToken);
     const jsApiService = pluginLoader.jsApiService;
@@ -229,6 +230,30 @@
 
     changeViewModel.updateState({
       childView: ChangeChildView.OVERVIEW,
+      basePatchNum: 2 as BasePatchSetNum,
+      patchNum: 3 as PatchSetNumber,
+    });
+    changeModel.updateState({
+      change: createParsedChange(),
+      mergeable: true,
+    });
+
+    assert.isTrue(showChangeStub.calledOnce);
+    const detail: ShowChangeDetail = showChangeStub.lastCall.firstArg;
+    assert.equal(detail.change?._number, createParsedChange()._number);
+    assert.equal(detail.patchNum, 3 as PatchSetNumber);
+    assert.equal(detail.basePatchNum, 2 as BasePatchSetNum);
+    assert.equal(detail.info.mergeable, true);
+  });
+
+  test('fireShowChange from diff', async () => {
+    await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
+    const pluginLoader = testResolver(pluginLoaderToken);
+    const jsApiService = pluginLoader.jsApiService;
+    const showChangeStub = sinon.stub(jsApiService, 'handleShowChange');
+
+    changeViewModel.updateState({
+      childView: ChangeChildView.DIFF,
       patchNum: 1 as PatchSetNumber,
     });
     changeModel.updateState({
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 026e5e5..86c4b49 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -517,3 +517,12 @@
 export function secondaryLinks(result?: CheckResultApi): Link[] {
   return (result?.links ?? []).filter(link => !link.primary);
 }
+
+export function computeIsExpandable(result?: CheckResultApi) {
+  if (!result?.summary) return false;
+  const hasMessage = !!result?.message;
+  const hasMultipleLinks = (result?.links ?? []).length > 1;
+  const hasPointers = (result?.codePointers ?? []).length > 0;
+  const hasFixes = (result?.fixes ?? []).length > 0;
+  return hasMessage || hasMultipleLinks || hasPointers || hasFixes;
+}
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
index 822435d..83b4cd6 100644
--- a/polygerrit-ui/app/models/checks/checks-util_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -10,6 +10,7 @@
   ALL_ATTEMPTS,
   AttemptChoice,
   LATEST_ATTEMPT,
+  computeIsExpandable,
   rectifyFix,
   sortAttemptChoices,
   stringToAttemptChoice,
@@ -17,6 +18,12 @@
 import {Fix, Replacement} from '../../api/checks';
 import {PROVIDED_FIX_ID} from '../../utils/comment-util';
 import {CommentRange} from '../../api/rest-api';
+import {
+  createCheckFix,
+  createCheckLink,
+  createCheckResult,
+  createRange,
+} from '../../test/test-data-generators';
 
 suite('checks-util tests', () => {
   setup(() => {});
@@ -107,4 +114,62 @@
     ];
     assert.deepEqual(unsorted.sort(sortAttemptChoices), sortedExpected);
   });
+
+  suite('computeIsExpandable', () => {
+    test('no message', () => {
+      assert.isFalse(computeIsExpandable(createCheckResult()));
+    });
+
+    test('no summary', () => {
+      assert.isFalse(
+        computeIsExpandable({
+          ...createCheckResult(),
+          message: 'asdf',
+          summary: undefined as unknown as string,
+        })
+      );
+    });
+
+    test('has message', () => {
+      assert.isTrue(
+        computeIsExpandable({...createCheckResult(), message: 'asdf'})
+      );
+    });
+
+    test('has just one link', () => {
+      assert.isFalse(
+        computeIsExpandable({
+          ...createCheckResult(),
+          links: [createCheckLink()],
+        })
+      );
+    });
+
+    test('has more than one link', () => {
+      assert.isTrue(
+        computeIsExpandable({
+          ...createCheckResult(),
+          links: [createCheckLink(), createCheckLink()],
+        })
+      );
+    });
+
+    test('has code pointer', () => {
+      assert.isTrue(
+        computeIsExpandable({
+          ...createCheckResult(),
+          codePointers: [{path: 'asdf', range: createRange()}],
+        })
+      );
+    });
+
+    test('has fix', () => {
+      assert.isTrue(
+        computeIsExpandable({
+          ...createCheckResult(),
+          fixes: [createCheckFix()],
+        })
+      );
+    });
+  });
 });
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index eca8b7c..c9b0bd9 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -438,6 +438,7 @@
     private readonly navigation: NavigationService
   ) {
     super(initialState);
+    console.info('CommentsModel constrcutor');
     this.subscriptions.push(
       this.savingInProgress$.subscribe(savingInProgress => {
         if (savingInProgress) {
@@ -477,6 +478,7 @@
     );
     this.subscriptions.push(
       this.changeViewModel.changeNum$.subscribe(changeNum => {
+        console.info(`CommentsModel reload ${changeNum}`);
         this.changeNum = changeNum;
         this.setState({...initialState});
         this.reloadAllComments();
@@ -518,8 +520,18 @@
     this.setState(reducer({...this.getState()}));
   }
 
+  override setState(state: CommentState) {
+    const commentsUndefPrev = this.getState().comments === undefined;
+    const commentsUndefNext = state.comments === undefined;
+    console.info(
+      `CommentsModel setState ${commentsUndefPrev} ${commentsUndefNext} ${this.stateUpdateInProgress}`
+    );
+    super.setState(state);
+  }
+
   async reloadComments(changeNum: NumericChangeId): Promise<void> {
     const comments = await this.restApiService.getDiffComments(changeNum);
+    console.info(`CommentsModel setComments ${comments === undefined}`);
     this.modifyState(s => setComments(s, comments));
   }
 
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index 168e0f4..4c0df60 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -34,6 +34,11 @@
     configState => configState.serverConfig
   );
 
+  public download$ = select(
+    this.serverConfig$,
+    serverConfig => serverConfig?.download
+  );
+
   public mergeabilityComputationBehavior$ = select(
     this.serverConfig$,
     serverConfig => serverConfig?.change?.mergeability_computation_behavior
diff --git a/polygerrit-ui/app/models/model.ts b/polygerrit-ui/app/models/model.ts
index 19b52fc..2d0ff42 100644
--- a/polygerrit-ui/app/models/model.ts
+++ b/polygerrit-ui/app/models/model.ts
@@ -27,7 +27,7 @@
    * another `next()` call. So make sure that state updates complete before
    * starting another one.
    */
-  private stateUpdateInProgress = false;
+  protected stateUpdateInProgress = false;
 
   private subject$: BehaviorSubject<T>;
 
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 713d401..127f77b 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -204,25 +204,33 @@
     childView: ChangeChildView.DIFF,
   });
 
-  const path = `/${encodeURL(state.diffView?.path ?? '')}`;
-
-  let suffix = '';
+  let path = `/${encodeURL(state.diffView?.path ?? '')}`;
   // TODO: Move creating of comment URLs to a separate function. We are
   // "abusing" the `commentId` property, which should only be used for pointing
   // to comment in the COMMENTS tab of the OVERVIEW page.
   if (state.commentId) {
-    suffix += `comment/${state.commentId}/`;
+    path += `comment/${state.commentId}/`;
   }
 
+  let queryParams = '';
+  const params = [];
+  if (state.checksPatchset && state.checksPatchset > 0) {
+    params.push(`checksPatchset=${state.checksPatchset}`);
+  }
+  if (params.length > 0) {
+    queryParams = '?' + params.join('&');
+  }
+
+  let hash = '';
   if (state.diffView?.lineNum) {
-    suffix += '#';
+    hash += '#';
     if (state.diffView?.leftSide) {
-      suffix += 'b';
+      hash += 'b';
     }
-    suffix += state.diffView.lineNum;
+    hash += state.diffView.lineNum;
   }
 
-  return `${createChangeUrlCommon(state)}${path}${suffix}`;
+  return `${createChangeUrlCommon(state)}${path}${queryParams}${hash}`;
 }
 
 export function createEditUrl(
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
index 837e362..8e671d1 100644
--- a/polygerrit-ui/app/models/views/change_test.ts
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -6,6 +6,7 @@
 import {assert} from '@open-wc/testing';
 import {
   BasePatchSetNum,
+  PatchSetNumber,
   RepoName,
   RevisionPatchSetNum,
 } from '../../api/rest-api';
@@ -77,59 +78,97 @@
     assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/42');
   });
 
-  test('createDiffUrl', () => {
-    const params: ChangeViewState = {
-      ...createDiffViewState(),
-      patchNum: 12 as RevisionPatchSetNum,
-      diffView: {path: 'x+y/path.cpp'},
-    };
-    assert.equal(
-      createDiffUrl(params),
-      '/c/test-project/+/42/12/x%252By/path.cpp'
-    );
+  suite('createDiffUrl', () => {
+    let params: ChangeViewState;
+    setup(() => {
+      params = {
+        ...createDiffViewState(),
+        patchNum: 12 as RevisionPatchSetNum,
+        diffView: {path: 'x+y/path.cpp'},
+      };
+    });
 
-    window.CANONICAL_PATH = '/base';
-    assert.equal(createDiffUrl(params).substring(0, 5), '/base');
-    window.CANONICAL_PATH = undefined;
+    test('CANONICAL_PATH', () => {
+      window.CANONICAL_PATH = '/base';
+      assert.equal(createDiffUrl(params).substring(0, 5), '/base');
+      window.CANONICAL_PATH = undefined;
+    });
 
-    params.repo = 'test' as RepoName;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
+    test('basic', () => {
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/12/x%252By/path.cpp'
+      );
+    });
 
-    params.basePatchNum = 6 as BasePatchSetNum;
-    assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
+    test('repo', () => {
+      params.repo = 'test' as RepoName;
+      assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
+    });
 
-    params.diffView = {
-      path: 'foo bar/my+file.txt%',
-    };
-    params.patchNum = 2 as RevisionPatchSetNum;
-    delete params.basePatchNum;
-    assert.equal(
-      createDiffUrl(params),
-      '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
-    );
+    test('checks patchset', () => {
+      params.checksPatchset = 4 as PatchSetNumber;
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/12/x%252By/path.cpp?checksPatchset=4'
+      );
+    });
 
-    params.diffView = {
-      path: 'file.cpp',
-      lineNum: 123,
-    };
-    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#123');
+    test('base patchset', () => {
+      params.basePatchNum = 6 as BasePatchSetNum;
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/6..12/x%252By/path.cpp'
+      );
+    });
 
-    params.diffView = {
-      path: 'file.cpp',
-      lineNum: 123,
-      leftSide: true,
-    };
-    assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#b123');
-  });
+    test('percent', () => {
+      params.diffView = {
+        path: 'foo bar/my+file.txt%',
+      };
+      params.patchNum = 2 as RevisionPatchSetNum;
+      delete params.basePatchNum;
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/2/foo+bar/my%252Bfile.txt%2525'
+      );
+    });
 
-  test('diff with repo name encoding', () => {
-    const params: ChangeViewState = {
-      ...createDiffViewState(),
-      patchNum: 12 as RevisionPatchSetNum,
-      repo: 'x+/y' as RepoName,
-      diffView: {path: 'x+y/path.cpp'},
-    };
-    assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+    test('line right', () => {
+      params.diffView = {
+        path: 'file.cpp',
+        lineNum: 123,
+      };
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/12/file.cpp#123'
+      );
+    });
+
+    test('line left', () => {
+      params.diffView = {
+        path: 'file.cpp',
+        lineNum: 123,
+        leftSide: true,
+      };
+      assert.equal(
+        createDiffUrl(params),
+        '/c/test-project/+/42/12/file.cpp#b123'
+      );
+    });
+
+    test('diff with repo name encoding', () => {
+      const params: ChangeViewState = {
+        ...createDiffViewState(),
+        patchNum: 12 as RevisionPatchSetNum,
+        repo: 'x+/y' as RepoName,
+        diffView: {path: 'x+y/path.cpp'},
+      };
+      assert.equal(
+        createDiffUrl(params),
+        '/c/x%252B/y/+/42/12/x%252By/path.cpp'
+      );
+    });
   });
 
   test('createEditUrl', () => {
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 49259b4..9b08a6e 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -34,6 +34,7 @@
 
   appStarted(): void;
   onVisibilityChange(): void;
+  onFocusChange(): void;
   beforeLocationChanged(): void;
   locationChanged(page: string): void;
   dashboardDisplayed(): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 55bb64c..61534a4 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -88,6 +88,9 @@
   [Timing.WEB_COMPONENTS_READY]: 0,
 };
 
+// List of timers that should NOT be reset before a location change.
+const LOCATION_CHANGE_OK_TIMERS: (string | Timing)[] = [Timing.SEND_REPLY];
+
 const SLOW_RPC_THRESHOLD = 500;
 
 export function initErrorReporter(reportingService: ReportingService) {
@@ -185,6 +188,12 @@
   document.addEventListener('visibilitychange', () => {
     reportingService.onVisibilityChange();
   });
+  window.addEventListener('blur', () => {
+    reportingService.onFocusChange();
+  });
+  window.addEventListener('focus', () => {
+    reportingService.onFocusChange();
+  });
 }
 
 export function initClickReporter(reportingService: ReportingService) {
@@ -203,6 +212,62 @@
   });
 }
 
+/**
+ * Reports generic user interaction every x seconds to detect, if the user is
+ * present and is using the application somehow. If you just look at
+ * `document.visibilityState`, then the user may have left the browser open
+ * without locking the screen. So it helps to know whether some interaction is
+ * actually happening.
+ */
+export class InteractionReporter implements Finalizable {
+  /** Accumulates event names until the next round of interaction reporting. */
+  private interactionEvents = new Set<string>();
+
+  /** Allows clearing the interval timer. Mostly useful for tests. */
+  private intervalId?: number;
+
+  constructor(
+    private readonly reportingService: ReportingService,
+    private readonly reportingIntervalMs = 10 * 1000
+  ) {
+    const events = ['mousemove', 'scroll', 'wheel', 'keydown', 'pointerdown'];
+    for (const eventName of events) {
+      document.addEventListener(eventName, () =>
+        this.interactionEvents.add(eventName)
+      );
+    }
+
+    this.intervalId = window.setInterval(
+      () => this.report(),
+      this.reportingIntervalMs
+    );
+  }
+
+  finalize() {
+    window.clearInterval(this.intervalId);
+  }
+
+  private report() {
+    const active = this.interactionEvents.size > 0;
+    if (active) {
+      this.reportingService.reportInteraction(Interaction.USER_ACTIVE, {
+        events: [...this.interactionEvents],
+      });
+    } else if (document.visibilityState === 'visible') {
+      this.reportingService.reportInteraction(Interaction.USER_PASSIVE, {});
+    }
+    this.interactionEvents.clear();
+  }
+}
+
+let interactionReporter: InteractionReporter;
+
+export function initInteractionReporter(reportingService: ReportingService) {
+  if (!interactionReporter) {
+    interactionReporter = new InteractionReporter(reportingService);
+  }
+}
+
 export function initWebVitals(reportingService: ReportingService) {
   function reportWebVitalMetric(name: Timing, metric: Metric) {
     let score = metric.value;
@@ -470,6 +535,20 @@
     this._reportNavResTimes();
   }
 
+  onFocusChange() {
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.VISIBILITY,
+      LifeCycle.FOCUS,
+      undefined,
+      {
+        isVisible: document.visibilityState === 'visible',
+        hasFocus: document.hasFocus(),
+      },
+      false
+    );
+  }
+
   onVisibilityChange() {
     this.hiddenDurationTimer.onVisibilityChange();
     let eventName;
@@ -486,6 +565,8 @@
         undefined,
         {
           hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
+          isVisible: document.visibilityState === 'visible',
+          hasFocus: document.hasFocus(),
         },
         false
       );
@@ -522,6 +603,7 @@
 
   beforeLocationChanged() {
     for (const prop of Object.keys(this._baselines)) {
+      if (LOCATION_CHANGE_OK_TIMERS.includes(prop)) continue;
       delete this._baselines[prop];
     }
     this.time(Timing.CHANGE_DISPLAYED);
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index d4efbcc..fd1b88c 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -44,6 +44,9 @@
   onVisibilityChange: () => {
     log('onVisibilityChange');
   },
+  onFocusChange: () => {
+    log('onFocusChange');
+  },
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
   pluginsFailed: () => {},
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
index 9c5e20d..2d3dfe2 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
@@ -8,11 +8,14 @@
   GrReporting,
   DEFAULT_STARTUP_TIMERS,
   initErrorReporter,
+  InteractionReporter,
 } from './gr-reporting_impl';
 import {getAppContext} from '../app-context';
 import {Deduping} from '../../api/reporting';
-import {SinonFakeTimers} from 'sinon';
+import {SinonFakeTimers, SinonStub} from 'sinon';
 import {assert} from '@open-wc/testing';
+import {grReportingMock} from './gr-reporting_mock';
+import {Interaction} from '../../constants/reporting';
 
 suite('gr-reporting tests', () => {
   // We have to type as any because we access
@@ -563,3 +566,62 @@
     });
   });
 });
+
+suite('InteractionReporter', () => {
+  let interactionReporter: InteractionReporter;
+  let clock: SinonFakeTimers;
+  let stub: SinonStub;
+  let activeCalls: number[] = [];
+  let passiveCalls: number[] = [];
+
+  setup(() => {
+    clock = sinon.useFakeTimers(0);
+    activeCalls = [];
+    passiveCalls = [];
+    const reporting = grReportingMock;
+    stub = sinon
+      .stub(reporting, 'reportInteraction')
+      .callsFake((interaction: string | Interaction) => {
+        if (interaction === Interaction.USER_ACTIVE) {
+          activeCalls.push(clock.now);
+        }
+        if (interaction === Interaction.USER_PASSIVE) {
+          passiveCalls.push(clock.now);
+        }
+      });
+    interactionReporter = new InteractionReporter(reporting, 1000);
+  });
+
+  teardown(() => {
+    clock.restore();
+    interactionReporter.finalize();
+  });
+
+  test('interaction example', () => {
+    clock.tick(500);
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    document.dispatchEvent(new MouseEvent('scroll'));
+    document.dispatchEvent(new MouseEvent('wheel'));
+    clock.tick(1000);
+    clock.tick(1000);
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    clock.tick(1000);
+    document.dispatchEvent(new MouseEvent('mousemove'));
+    clock.tick(1000);
+
+    assert.sameOrderedMembers(activeCalls, [2000, 3000, 6000, 7000]);
+    assert.sameOrderedMembers(passiveCalls, [1000, 4000, 5000]);
+
+    assert.isUndefined(stub.getCall(0).args[1].events);
+    assert.sameMembers(stub.getCall(1).args[1].events, ['mousemove']);
+    assert.sameMembers(stub.getCall(2).args[1].events, [
+      'mousemove',
+      'scroll',
+      'wheel',
+    ]);
+  });
+});
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 365bb16..ed472cc 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -37,7 +37,7 @@
   Provider,
 } from '../models/dependency';
 import * as sinon from 'sinon';
-import '../styles/themes/app-theme.ts';
+import '../styles/themes/app-theme';
 import {Creator} from '../services/app-context-init';
 import {pluginLoaderToken} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
 
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index dce8e4b..4a0f6b8 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -104,7 +104,7 @@
   SubmitRequirementStatus,
 } from '../api/rest-api';
 import {CheckResult, CheckRun, RunResult} from '../models/checks/checks-model';
-import {Category, RunStatus} from '../api/checks';
+import {Category, Fix, Link, LinkIcon, RunStatus} from '../api/checks';
 import {DiffInfo} from '../api/diff';
 import {SearchViewState} from '../models/views/search';
 import {ChangeChildView, ChangeViewState} from '../models/views/change';
@@ -1147,6 +1147,29 @@
   };
 }
 
+export function createCheckFix(partial: Partial<Fix> = {}): Fix {
+  return {
+    description: 'this is a test fix',
+    replacements: [
+      {
+        path: 'testpath',
+        range: createRange(),
+        replacement: 'testreplacement',
+      },
+    ],
+    ...partial,
+  };
+}
+
+export function createCheckLink(partial: Partial<Link> = {}): Link {
+  return {
+    url: 'http://test/url',
+    primary: true,
+    icon: LinkIcon.EXTERNAL,
+    ...partial,
+  };
+}
+
 export function createDetailedLabelInfo(): DetailedLabelInfo {
   return {
     values: {
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 21150eb..6e20dd4 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -18,8 +18,7 @@
 import {PageContext} from '../elements/core/gr-router/gr-page';
 import {waitUntil} from '../utils/async-util';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
-export {waitUntil} from '../utils/async-util';
-export {mockPromise} from '../utils/async-util';
+export {mockPromise, waitUntil} from '../utils/async-util';
 export type {MockPromise} from '../utils/async-util';
 
 export function isHidden(el: Element | undefined | null) {
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index b148780..7a6610b 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -52,6 +52,8 @@
   ConfigParameterInfo,
   ConfigParameterInfoBase,
   ContributorAgreementInfo,
+  CustomKey,
+  CustomKeyedValues,
   DetailedLabelInfo,
   DownloadInfo,
   DownloadSchemeInfo,
@@ -1112,6 +1114,15 @@
 }
 
 /**
+ * The CustomKeyedValuesInput entity contains information about hashtags to add to, and/or remove from, a change
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#custom-keyed-values-input
+ */
+export interface CustomKeyedValuesInput {
+  add?: CustomKeyedValues;
+  remove?: CustomKey[];
+}
+
+/**
  * The HashtagsInput entity contains information about hashtags to add to, and/or remove from, a change
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#hashtags-input
  */
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 6517836..40474e9 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -12,8 +12,10 @@
   CommitInfo,
   EditPatchSet,
   PatchSetNum,
+  PatchSetNumber,
   ReviewerUpdateInfo,
   RevisionInfo,
+  RevisionPatchSetNum,
   Timestamp,
 } from './common';
 
@@ -89,6 +91,17 @@
   return !!(x as PatchSetFile).path;
 }
 
+export function isPatchSetNumber(
+  x?:
+    | PatchSetNum
+    | PatchSetNumber
+    | RevisionPatchSetNum
+    | BasePatchSetNum
+    | null
+): x is PatchSetNumber {
+  return !!x && Number.isInteger(x) && (x as number) > 0;
+}
+
 export interface FileRange {
   basePath?: string;
   path: string;
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 1af66fa..32a6867 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -339,32 +339,6 @@
   return wrappedPromise;
 }
 
-export async function waitUntil(
-  predicate: (() => boolean) | (() => Promise<boolean>),
-  message = 'The waitUntil() predicate is still false after 1000 ms.',
-  timeout_ms = 1000
-): Promise<void> {
-  if (await predicate()) return Promise.resolve();
-  const start = Date.now();
-  let sleep = 10;
-  const error = new Error(message);
-  return new Promise((resolve, reject) => {
-    const waiter = async () => {
-      if (await predicate()) {
-        resolve();
-        return;
-      }
-      if (Date.now() - start >= timeout_ms) {
-        reject(error);
-        return;
-      }
-      setTimeout(waiter, sleep);
-      sleep *= 2;
-    };
-    waiter();
-  });
-}
-
 export interface MockPromise<T> extends Promise<T> {
   resolve: (value?: T) => void;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -397,3 +371,29 @@
     setTimeout(resolve, timeoutMs);
   });
 }
+
+export async function waitUntil(
+  predicate: (() => boolean) | (() => Promise<boolean>),
+  message = 'The waitUntil() predicate is still false after 1000 ms.',
+  timeout_ms = 1000
+): Promise<void> {
+  if (await predicate()) return Promise.resolve();
+  const start = Date.now();
+  let sleep = 10;
+  const error = new Error(message);
+  return new Promise((resolve, reject) => {
+    const waiter = async () => {
+      if (await predicate()) {
+        resolve();
+        return;
+      }
+      if (Date.now() - start >= timeout_ms) {
+        reject(error);
+        return;
+      }
+      setTimeout(waiter, sleep);
+      sleep *= 2;
+    };
+    waiter();
+  });
+}
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 4490afa..7de5e7e 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -16,10 +16,8 @@
 import {ParsedChangeInfo} from '../types/types';
 import {getUserId, isServiceUser} from './account-util';
 
-// This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
-  mergeable: boolean; // This can be wrong! See WARNING above
-  submitEnabled: boolean; // This can be wrong! See WARNING above
+  mergeable: boolean;
   /** Is there a reverting change and if so, what status has it? */
   revertingChangeStatus?: ChangeStatus;
 }
@@ -190,11 +188,9 @@
     return states;
   }
 
-  // If no missing requirements, either active or ready to submit.
-  if (change.submittable && options.submitEnabled) {
+  if (change.submittable) {
     states.push(ChangeStates.READY_TO_SUBMIT);
   } else {
-    // Otherwise it is active.
     states.push(ChangeStates.ACTIVE);
   }
   return states;
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
index f768145..1782d80 100644
--- a/polygerrit-ui/app/utils/change-util_test.ts
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -66,29 +66,24 @@
     assert.deepEqual(statuses, []);
 
     change.submittable = false;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
 
-    // With no missing labels but no submitEnabled option.
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
-    assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
-
-    // Without missing labels and enabled submit
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
 
     change.mergeable = false;
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: false});
     assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
 
     change.mergeable = true;
-    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    statuses = changeStatuses(change, {mergeable: true});
     assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
 
     change.submittable = true;
-    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    statuses = changeStatuses(change, {mergeable: false});
     assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
   });
 
@@ -141,7 +136,6 @@
       changeStatuses(change, {
         revertingChangeStatus: ChangeStatus.NEW,
         mergeable: true,
-        submitEnabled: true,
       }),
       [ChangeStates.MERGED, ChangeStates.REVERT_CREATED]
     );
@@ -149,7 +143,6 @@
       changeStatuses(change, {
         revertingChangeStatus: ChangeStatus.MERGED,
         mergeable: true,
-        submitEnabled: true,
       }),
       [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED]
     );
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index ee1a44c..f5649b6 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -36,6 +36,7 @@
 import {FormattedReviewerUpdateInfo} from '../types/types';
 import {extractMentionedUsers} from './account-util';
 import {assertIsDefined, uuid} from './common-util';
+import {FILE} from '../api/diff';
 
 export function isFormattedReviewerUpdate(
   message: ChangeMessage
@@ -173,7 +174,7 @@
       rootId: id(comment),
     };
     if (!comment.line && !comment.range) {
-      newThread.line = 'FILE';
+      newThread.line = FILE;
     }
     threads.push(newThread);
     if (id(comment)) idThreadMap[id(comment)] = newThread;
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 7bf0c1e..713e6df 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -35,6 +35,7 @@
   UrlEncodedCommentId,
 } from '../types/common';
 import {assert} from '@open-wc/testing';
+import {FILE} from '../api/diff';
 
 suite('comment-util', () => {
   test('isUnresolved', () => {
@@ -213,7 +214,7 @@
       assert.equal(actualThreads[1].comments.length, 1);
       assert.deepEqual(actualThreads[1].comments[0], comments[2]);
       assert.equal(actualThreads[1].patchNum, 1 as RevisionPatchSetNum);
-      assert.equal(actualThreads[1].line, 'FILE');
+      assert.equal(actualThreads[1].line, FILE);
     });
 
     test('derives patchNum and range', () => {
diff --git a/polygerrit-ui/app/utils/diff-util.ts b/polygerrit-ui/app/utils/diff-util.ts
new file mode 100644
index 0000000..da674df
--- /dev/null
+++ b/polygerrit-ui/app/utils/diff-util.ts
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Side} from '../constants/constants';
+import {DiffInfo} from '../types/diff';
+
+// If any line of the diff is more than the character limit, then disable
+// syntax highlighting for the entire file.
+export const SYNTAX_MAX_LINE_LENGTH = 500;
+
+export function countLines(diff?: DiffInfo, side?: Side) {
+  if (!diff?.content || !side) return 0;
+  return diff.content.reduce((sum, chunk) => {
+    const sideChunk = side === Side.LEFT ? chunk.a : chunk.b;
+    return sum + (sideChunk?.length ?? chunk.ab?.length ?? chunk.skip ?? 0);
+  }, 0);
+}
+
+export function isFileUnchanged(diff: DiffInfo) {
+  return !diff.content.some(
+    content => (content.a && !content.common) || (content.b && !content.common)
+  );
+}
+
+/**
+ * @return whether any of the lines in diff are longer
+ * than SYNTAX_MAX_LINE_LENGTH.
+ */
+export function anyLineTooLong(diff?: DiffInfo) {
+  if (!diff) return false;
+  return diff.content.some(section => {
+    const lines = section.ab
+      ? section.ab
+      : (section.a || []).concat(section.b || []);
+    return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
+  });
+}
+
+/**
+ * Get the approximate length of the diff as the sum of the maximum
+ * length of the chunks.
+ */
+export function getDiffLength(diff?: DiffInfo) {
+  if (!diff) return 0;
+  return diff.content.reduce((sum, sec) => {
+    if (sec.ab) {
+      return sum + sec.ab.length;
+    } else {
+      return sum + Math.max(sec.a?.length ?? 0, sec.b?.length ?? 0);
+    }
+  }, 0);
+}
diff --git a/polygerrit-ui/app/utils/diff-util_test.ts b/polygerrit-ui/app/utils/diff-util_test.ts
new file mode 100644
index 0000000..dbab76d
--- /dev/null
+++ b/polygerrit-ui/app/utils/diff-util_test.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import {DiffInfo} from '../api/diff';
+import '../test/common-test-setup';
+import {createDiff} from '../test/test-data-generators';
+import {isFileUnchanged} from './diff-util';
+
+suite('diff-util tests', () => {
+  test('isFileUnchanged', () => {
+    let diff: DiffInfo = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef']},
+        {b: ['ancd'], a: ['xx']},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), false);
+    diff = {
+      ...createDiff(),
+      content: [{ab: ['abcd']}, {ab: ['ancd']}],
+    };
+    assert.equal(isFileUnchanged(diff), true);
+    diff = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef'], common: true},
+        {b: ['ancd'], ab: ['xx']},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), false);
+    diff = {
+      ...createDiff(),
+      content: [
+        {a: ['abcd'], ab: ['ef'], common: true},
+        {b: ['ancd'], ab: ['xx'], common: true},
+      ],
+    };
+    assert.equal(isFileUnchanged(diff), true);
+  });
+});
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 056238a..67c78cd 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -20,6 +20,10 @@
   return node.nodeType === 1;
 }
 
+export function isHtmlElement(node: Node): node is HTMLElement {
+  return isElement(node) && node instanceof HTMLElement;
+}
+
 export function isElementTarget(
   target: EventTarget | null | undefined
 ): target is Element {
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 4fa0e63..6ceaa4f 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -149,3 +149,9 @@
 export function generateAbsoluteUrl(url: string) {
   return new URL(url, window.location.href).toString();
 }
+
+export function sameOrigin(href: string) {
+  if (!href) return false;
+  const url = new URL(href, window.location.origin);
+  return url.origin === window.location.origin;
+}
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index c82cd70..e2ff837 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -14,6 +14,7 @@
   toSearchParams,
   getPatchRangeExpression,
   PatchRangeParams,
+  sameOrigin,
 } from './url-util';
 import {assert} from '@open-wc/testing';
 
@@ -69,6 +70,12 @@
     });
   });
 
+  test('sameOrigin', () => {
+    assert.isTrue(sameOrigin('/asdf'));
+    assert.isTrue(sameOrigin(window.location.origin + '/asdf'));
+    assert.isFalse(sameOrigin('http://www.goole.com/asdf'));
+  });
+
   test('toPathname', () => {
     assert.equal(toPathname('asdf'), 'asdf');
     assert.equal(toPathname('asdf?qwer=zxcv'), 'asdf');
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 6fa4d0f..e8bd3d8 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -29,7 +29,7 @@
     "test:browsers": "web-test-runner --playwright --browsers webkit firefox chromium",
     "test:coverage": "web-test-runner --coverage",
     "test:watch": "web-test-runner --watch",
-    "test:single": "web-test-runner --watch --files",
+    "test:single": "web-test-runner --watch --group default --files",
     "test:single:coverage": "web-test-runner --watch --coverage --files"
   },
   "license": "Apache-2.0",
diff --git a/polygerrit-ui/web-test-runner.config.mjs b/polygerrit-ui/web-test-runner.config.mjs
index 552e609..bd8d9ac 100644
--- a/polygerrit-ui/web-test-runner.config.mjs
+++ b/polygerrit-ui/web-test-runner.config.mjs
@@ -2,15 +2,58 @@
 import { defaultReporter, summaryReporter } from "@web/test-runner";
 import { visualRegressionPlugin } from "@web/test-runner-visual-regression/plugin";
 
+function testRunnerHtmlFactory(options) {
+  const setNewDiffExp = `<script type="text/javascript">window.ENABLED_EXPERIMENTS = ['UiFeature__new_diff'];</script>`;
+  return (testFramework) => `
+    <!DOCTYPE html>
+    <html>
+      <head>
+        <link rel="stylesheet" href="polygerrit-ui/app/styles/main.css">
+        <link rel="stylesheet" href="polygerrit-ui/app/styles/fonts.css">
+        <link
+          rel="stylesheet"
+          href="polygerrit-ui/app/styles/material-icons.css">
+      </head>
+      <body>
+        ${options.newDiff ? setNewDiffExp : ''}
+        <script type="module" src="${testFramework}"></script>
+      </body>
+    </html>
+  `;
+}
+
 /** @type {import('@web/test-runner').TestRunnerConfig} */
 const config = {
   files: [
     "app/**/*_test.{ts,js}",
+    "!app/embed/diff/gr-context-controls/**/*_test.{ts,js}",
+    "!app/embed/diff/gr-diff/**/*_test.{ts,js}",
+    "!app/embed/diff/gr-diff-builder/**/*_test.{ts,js}",
+    "!app/embed/diff/gr-diff-cursor/**/*_test.{ts,js}",
+    "!app/embed/diff/gr-diff-highlight/**/*_test.{ts,js}",
+    "!app/embed/diff/gr-diff-model/**/*_test.{ts,js}",
+    "!app/embed/diff/gr-diff-processor/**/*_test.{ts,js}",
+    "!app/embed/diff/gr-diff-selection/**/*_test.{ts,js}",
     "!**/node_modules/**/*",
     ...(process.argv.includes("--run-screenshots")
       ? []
       : ["!app/**/*_screenshot_test.{ts,js}"]),
   ],
+  // TODO(newdiff-cleanup): Remove once newdiff migration is completed.
+  groups: [
+    {
+      name: "new-diff",
+      files: [
+        "app/embed/diff/**/*_test.{ts,js}",
+        "app/elements/change/gr-file-list/gr-file-list_test.{ts,js}",
+        "app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.{ts,js}",
+        "app/elements/diff/gr-diff-host/gr-diff-host_test.{ts,js}",
+        "app/elements/diff/gr-diff-view/gr-diff-view_test.{ts,js}",
+        "app/elements/shared/gr-comment-thread/gr-comment-thread_test.{ts,js}",
+      ],
+      testRunnerHtml: testRunnerHtmlFactory({newDiff: true}),
+    },
+  ],
   port: 9876,
   nodeResolve: true,
   testFramework: { config: { ui: "tdd", timeout: 5000 } },
@@ -42,20 +85,6 @@
       await next();
     },
   ],
-  testRunnerHtml: (testFramework) => `
-    <!DOCTYPE html>
-    <html>
-      <head>
-        <link rel="stylesheet" href="polygerrit-ui/app/styles/main.css">
-        <link rel="stylesheet" href="polygerrit-ui/app/styles/fonts.css">
-        <link
-          rel="stylesheet"
-          href="polygerrit-ui/app/styles/material-icons.css">
-      </head>
-      <body>
-        <script type="module" src="${testFramework}"></script>
-      </body>
-    </html>
-  `,
+  testRunnerHtml: testRunnerHtmlFactory({newDiff: false}),
 };
 export default config;
diff --git a/prologtests/BUILD b/prologtests/BUILD
index 279dbb7..3a598be 100644
--- a/prologtests/BUILD
+++ b/prologtests/BUILD
@@ -1,5 +1,5 @@
 filegroup(
     name = "gerrit_common_test",
-    srcs = ["com/google/gerrit/server/rules/gerrit_common_test.pl"],
+    srcs = ["com/google/gerrit/server/rules/prolog/gerrit_common_test.pl"],
     visibility = ["//visibility:public"],
 )
diff --git a/prologtests/com/google/gerrit/server/rules/gerrit_common_test.pl b/prologtests/com/google/gerrit/server/rules/prolog/gerrit_common_test.pl
similarity index 100%
rename from prologtests/com/google/gerrit/server/rules/gerrit_common_test.pl
rename to prologtests/com/google/gerrit/server/rules/prolog/gerrit_common_test.pl
diff --git a/proto/cache.proto b/proto/cache.proto
index 7063ee5..79bfa9e 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -78,7 +78,7 @@
 // Instead, we just take the tedious yet simple approach of having a "has_foo"
 // field for each nullable field "foo", indicating whether or not foo is null.
 //
-// Next ID: 28
+// Next ID: 29
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -142,6 +142,8 @@
 
   repeated string hashtag = 5;
 
+  map<string, string> custom_keyed_values = 28;
+
   repeated devtools.gerritcodereview.PatchSet patch_set = 6;
 
   repeated devtools.gerritcodereview.PatchSetApproval approval = 7;
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index 0cc3da0..1772eb7 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -166,6 +166,30 @@
   fi
 }
 
+function test_preserve_link {
+  cat << EOF > input
+bla bla
+
+Link: https://myhost/id/I1234567890123456789012345678901234567890
+EOF
+
+  git config gerrit.reviewUrl https://myhost/
+  ${hook} input || fail "failed hook execution"
+  git config --unset gerrit.reviewUrl
+  found=$(grep -c '^Change-Id' input) || :
+  if [[ "${found}" != "0" ]]; then
+    fail "got ${found} Change-Ids, want 0"
+  fi
+  found=$(grep -c '^Link: https://myhost/id/I' input) || :
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Link footers, want 1"
+  fi
+  found=$(grep -c '^Link: https://myhost/id/I1234567890123456789012345678901234567890$' input) || :
+  if [[ "${found}" != "1" ]]; then
+    fail "got ${found} Link: https://myhost/id/I123..., want 1"
+  fi
+}
+
 # Change-Id goes after existing trailers.
 function test_at_end {
   cat << EOF > input
diff --git a/resources/com/google/gerrit/server/mail/AddKey.soy b/resources/com/google/gerrit/server/mail/AddKey.soy
index 319db05..8958ea3 100644
--- a/resources/com/google/gerrit/server/mail/AddKey.soy
+++ b/resources/com/google/gerrit/server/mail/AddKey.soy
@@ -48,9 +48,9 @@
   You can also manage your {$email.keyType} keys by visiting
   {\n}
   {if $email.sshKey}
-    {$email.gerritUrl}#/settings/ssh-keys
+    {$email.sshKeysSettingsUrl}
   {elseif $email.gpgKeys}
-    {$email.gerritUrl}#/settings/gpg-keys
+    {$email.gpgKeysSettingsUrl}
   {/if}
   {\n}
   {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
index c356a95..cb5b224 100644
--- a/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -47,9 +47,9 @@
   <p>
     You can also manage your {$email.keyType} keys by following{sp}
     {if $email.sshKey}
-      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+      <a href="{$email.sshKeysSettingsUrl}">this link</a>
     {elseif $email.gpgKeys}
-      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+      <a href="{$email.gpgKeysSettingsUrl}">this link</a>
     {/if}
     {sp}
     {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/DeleteKey.soy b/resources/com/google/gerrit/server/mail/DeleteKey.soy
index 0957dc6..46bfc7e 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKey.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKey.soy
@@ -47,9 +47,9 @@
   You can also manage your {$email.keyType} keys by visiting
   {\n}
   {if $email.sshKey}
-    {$email.gerritUrl}#/settings/ssh-keys
+    {$email.sshKeysSettingsUrl}
   {elseif $email.gpgKey}
-    {$email.gerritUrl}#/settings/gpg-keys
+    {$email.gpgKeysSettingsUrl}
   {/if}
   {\n}
   {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
index fea6785..539688e 100644
--- a/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteKeyHtml.soy
@@ -45,9 +45,9 @@
   <p>
     You can also manage your {$email.keyType} keys by following{sp}
     {if $email.sshKey}
-      <a href="{$email.gerritUrl}#/settings/ssh-keys">this link</a>
+      <a href="{$email.sshKeysSettingsUrl}">this link</a>
     {elseif $email.gpgKeyFingerprints}
-      <a href="{$email.gerritUrl}#/settings/gpg-keys">this link</a>
+      <a href="{$email.gpgKeysSettingsUrl}">this link</a>
     {/if}
     {sp}
     {if $email.userNameEmail}
diff --git a/resources/com/google/gerrit/server/mail/Email.soy b/resources/com/google/gerrit/server/mail/Email.soy
new file mode 100644
index 0000000..9afea72
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/Email.soy
@@ -0,0 +1,27 @@
+/**
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+*/
+
+{namespace com.google.gerrit.server.mail.template.Email}
+
+/**
+ * The .Email template defines the structure of the content in the email.
+ */
+{template Email kind="text"}
+  {@param body: string}
+  {@param footer: string}
+  {$body}
+  {$footer}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/EmailHtml.soy b/resources/com/google/gerrit/server/mail/EmailHtml.soy
new file mode 100644
index 0000000..c2c69f8
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/EmailHtml.soy
@@ -0,0 +1,40 @@
+/**
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+*/
+
+{namespace com.google.gerrit.server.mail.template.EmailHtml}
+
+/**
+ * The .EmailHtml template defines the structure of the content in the email.
+ */
+{template EmailHtml}
+  {@param styles: css}
+  {@param body_sections_html: list<html>}
+  {@param footer_html: html}
+  <!DOCTYPE html>
+  <html>
+    <head>
+      <style>
+        {$styles}
+      </style>
+    </head>
+    <body>
+      {for $section in $body_sections_html}
+        {$section}
+      {/for}
+      {$footer_html}
+    </body>
+  </html>
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
index 49fbccb..3efa8be 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdate.soy
@@ -33,7 +33,7 @@
 
   You can also manage your HTTP password by visiting
   {\n}
-  {$email.gerritUrl}#/settings/http-password
+  {$email.httpPasswordSettingsUrl}
   {\n}
   {if $email.userNameEmail}
     (while signed in as {$email.userNameEmail})
diff --git a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
index 3f88a6f..ee033cd 100644
--- a/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
+++ b/resources/com/google/gerrit/server/mail/HttpPasswordUpdateHtml.soy
@@ -30,7 +30,7 @@
 
   <p>
     You can also manage your HTTP password by following{sp}
-    <a href="{$email.gerritUrl}#/settings/http-password">this link</a>
+    <a href="{$email.httpPasswordSettingsUrl}">this link</a>
     {sp}
     {if $email.userNameEmail}
       (while signed in as {$email.userNameEmail})
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
index 273f52f..cd38742 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmail.soy
@@ -34,7 +34,7 @@
 
   {\n}
 
-  {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}{\n}
+  {$email.emailRegistrationLink}{\n}
 
   {\n}
 
diff --git a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
index 7d6cd23..20f9999 100644
--- a/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
+++ b/resources/com/google/gerrit/server/mail/RegisterNewEmailHtml.soy
@@ -31,7 +31,7 @@
 
   <p>
 
-    {$email.gerritUrl}#/VE/{$email.emailRegistrationToken}
+    {$email.emailRegistrationLink}
   </p>
   <p>
     If you have received this mail in error, you do not need to take any
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index 0154d43..5c7dffa 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -64,7 +64,7 @@
 if test -n "${reviewurl}" ; then
   token="Link"
   value="${reviewurl%/}/id/I$random"
-  pattern=".*/id/I[0-9a-f]\{40\}$"
+  pattern=".*/id/I[0-9a-f]\{40\}"
 else
   token="Change-Id"
   value="I$random"
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index ab61cdc..ebecfbd 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-acceptance-framework</artifactId>
-  <version>3.8.0</version>
+  <version>3.9.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index 0b043bb..f03721a 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-extension-api</artifactId>
-  <version>3.8.0</version>
+  <version>3.9.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 09e40dc..e66fbee 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>3.8.0</version>
+  <version>3.9.0-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index b55027d..b83e4f2 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>3.8.0</version>
+  <version>3.9.0-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 7f26ef3..a03ccf0 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -171,24 +171,24 @@
         sha1 = GUAVA_TESTLIB_BIN_SHA1,
     )
 
-    GUICE_VERS = "5.0.1"
+    GUICE_VERS = "5.1.0"
 
     maven_jar(
         name = "guice-library",
         artifact = "com.google.inject:guice:" + GUICE_VERS,
-        sha1 = "0dae7556b441cada2b4f0a2314eb68e1ff423429",
+        sha1 = "da25056c694c54ba16e78e4fc35f17fc60f0d1b4",
     )
 
     maven_jar(
         name = "guice-assistedinject",
         artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS,
-        sha1 = "62e02f2aceb7d90ba354584dacc018c1e94ff01c",
+        sha1 = "58a8956f00d6939978d7da735f393d7af7db5c02",
     )
 
     maven_jar(
         name = "guice-servlet",
         artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS,
-        sha1 = "f527009d51f172a2e6937bfb55fcb827e2e2386b",
+        sha1 = "cb89ddec4246a469698a3461e69de1f245016c5d",
     )
 
     # Keep this version of Soy synchronized with the version used in Gitiles.
@@ -243,36 +243,36 @@
         sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
     )
 
-    LUCENE_VERS = "7.7.3"
+    LUCENE_VERS = "8.11.2"
 
     maven_jar(
         name = "lucene-core",
         artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-        sha1 = "5faa5ae56f7599019fce6184accc6c968b7519e7",
+        sha1 = "57438c3f31e0e440de149294890eee88e030ea6d",
     )
 
     maven_jar(
         name = "lucene-analyzers-common",
         artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-        sha1 = "0a76cbf5e21bbbb0c2d6288b042450236248214e",
+        sha1 = "07a74c5c2dd082b08c644a9016bc6ff66c8f27cc",
     )
 
     maven_jar(
         name = "backward-codecs",
         artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-        sha1 = "40207d0dd023a0e2868a23dd87d72f1a3cdbb893",
+        sha1 = "a5d0f0db405d607cc13265819b8d2ef0c81c0819",
     )
 
     maven_jar(
         name = "lucene-misc",
         artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-        sha1 = "3aca078edf983059722fe61a81b7b7bd5ecdb222",
+        sha1 = "9c7204f923465a96a20ac9e49cdca0cfcde64851",
     )
 
     maven_jar(
         name = "lucene-queryparser",
         artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-        sha1 = "685fc6166d29eb3e3441ae066873bb442aa02df1",
+        sha1 = "1886e3a27a8d4a73eb8fad54ea93a160b099bc60",
     )
 
     # JGit's transitive dependencies
diff --git a/version.bzl b/version.bzl
index da37913..05dfcd0 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "3.8.0"
+GERRIT_VERSION = "3.9.0-SNAPSHOT"