Merge "Include project name in ProjectConfig parsing log"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 3e82254..8d30db2 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -561,7 +561,8 @@
 +
 To enable the actual usage of contributor agreement the project
 specific config option in the `project.config` must be set:
-link:config-project-config.html[receive.requireContributorAgreement].
+link:config-project-config.html#receive.requireContributorAgreement[
+receive.requireContributorAgreement].
 
 [[auth.trustContainerAuth]]auth.trustContainerAuth::
 +
diff --git a/Documentation/pg-plugin-checks-api.txt b/Documentation/pg-plugin-checks-api.txt
index 8786cc4..e4cb5d0 100644
--- a/Documentation/pg-plugin-checks-api.txt
+++ b/Documentation/pg-plugin-checks-api.txt
@@ -10,6 +10,10 @@
 when a change page is loaded. Such a call would return a list of `Runs` and each
 run can contain a list of `Results`.
 
+`Results` messages will render as markdown. It follows the
+[CommonMark](https://commonmark.org/help/) spec except inline images and direct
+HTML are not rendered and kept as plaintext.
+
 The details of the ChecksApi are documented in the
 link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/checks.ts[source code].
 Note that this link points to the `master` branch and might thus reflect a
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index c4b8aa7..5e5d3f8 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -146,8 +146,14 @@
 
 image::images/browser-notification-preference.png["user preference for browser notifications", align="center"]
 
-Current implementation works only when gerrit is open in one of the tabs. We
-poll every 5 minutes for changes in attention set.
+The notifications work only when Gerrit is open in one of the browser tabs.
+The latency to get the notification is up to 5 minutes.
+
+If you are not getting notifications:
+ - Check your user preferences - Allow browser notification setting
+ - Make sure notifications are turned on for the Gerrit site in the browser
+ - Make sure browser notifications are turned on in your operating system
+ - Your host can have browser notifications disabled for some user groups
 
 === Bold Changes / Mark Reviewed
 
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 396ba74..44e7854 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -1386,22 +1386,21 @@
     throw new BadRequestException("Expected JSON object");
   }
 
-  @SuppressWarnings("unchecked")
   private static Object createInstance(Type type)
       throws NoSuchMethodException, InstantiationException, IllegalAccessException,
           InvocationTargetException {
     if (type instanceof Class) {
-      Class<Object> clazz = (Class<Object>) type;
-      Constructor<Object> c = clazz.getDeclaredConstructor();
+      Class<?> clazz = (Class<?>) type;
+      Constructor<?> c = clazz.getDeclaredConstructor();
       c.setAccessible(true);
       return c.newInstance();
     }
     if (type instanceof ParameterizedType) {
       Type rawType = ((ParameterizedType) type).getRawType();
-      if (rawType instanceof Class && List.class.isAssignableFrom((Class<Object>) rawType)) {
+      if (rawType instanceof Class && List.class.isAssignableFrom((Class<?>) rawType)) {
         return new ArrayList<>();
       }
-      if (rawType instanceof Class && Map.class.isAssignableFrom((Class<Object>) rawType)) {
+      if (rawType instanceof Class && Map.class.isAssignableFrom((Class<?>) rawType)) {
         return new HashMap<>();
       }
     }
diff --git a/java/com/google/gerrit/server/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
index cd2d79e..a1889da 100644
--- a/java/com/google/gerrit/server/approval/ApprovalCopier.java
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.approval.ApprovalContext;
 import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
@@ -353,10 +354,25 @@
         Predicate<ApprovalContext> copyConditionPredicate =
             approvalQueryBuilder.parse(labelType.getCopyCondition().get());
         boolean canCopy = copyConditionPredicate.asMatchable().match(ctx);
-        ImmutableSet.Builder<String> passingAtoms = ImmutableSet.builder();
-        ImmutableSet.Builder<String> failingAtoms = ImmutableSet.builder();
-        evaluateAtoms(copyConditionPredicate, ctx, passingAtoms, failingAtoms);
-        return ApprovalCopyResult.create(canCopy, passingAtoms.build(), failingAtoms.build());
+        ImmutableSet.Builder<String> passingAtomsBuilder = ImmutableSet.builder();
+        ImmutableSet.Builder<String> failingAtomsBuilder = ImmutableSet.builder();
+        evaluateAtoms(copyConditionPredicate, ctx, passingAtomsBuilder, failingAtomsBuilder);
+        ImmutableSet<String> passingAtoms = passingAtomsBuilder.build();
+        ImmutableSet<String> failingAtoms = failingAtomsBuilder.build();
+        logger.atFine().log(
+            "%s copy %s of account %d on change %d from patch set %d to patch set %d"
+                + " (copyCondition = %s, passingAtoms = %s, failingAtoms = %s, changeKind = %s)",
+            canCopy ? "Can" : "Cannot",
+            LabelVote.create(labelType.getName(), approvalValue).format(),
+            approverId.get(),
+            changeNotes.getChangeId().get(),
+            sourcePatchSetId.get(),
+            targetPatchSet.id().get(),
+            labelType.getCopyCondition().get(),
+            passingAtoms,
+            failingAtoms,
+            changeKind.name());
+        return ApprovalCopyResult.create(canCopy, passingAtoms, failingAtoms);
       }
     } catch (QueryParseException e) {
       logger.atWarning().withCause(e).log(
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 5b8d784..eb4f6bf 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -1046,6 +1046,15 @@
   public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE_EXACT =
       COMMIT_MESSAGE_EXACT_FIELD.exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT);
 
+  /** Commit message of the current patch set. */
+  public static final IndexedField<ChangeData, String> SUBJECT_FIELD =
+      IndexedField.<ChangeData>stringBuilder("Subject")
+          .required()
+          .build(changeGetter(Change::getSubject));
+
+  public static final IndexedField<ChangeData, String>.SearchSpec SUBJECT_SPEC =
+      SUBJECT_FIELD.fullText(ChangeQueryBuilder.FIELD_SUBJECT);
+
   /** Summary or inline comment. */
   public static final IndexedField<ChangeData, Iterable<String>> COMMENT_FIELD =
       IndexedField.<ChangeData>iterableStringBuilder("Comment")
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 6c92f111..5677b40 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -207,6 +207,15 @@
           .remove(ChangeField.STAR_SPEC, ChangeField.STARBY_SPEC, ChangeField.DRAFTBY_SPEC)
           .remove(ChangeField.STAR_FIELD, ChangeField.STARBY_FIELD, ChangeField.DRAFTBY_FIELD)
           .build();
+
+  /** Add subject field. */
+  static final Schema<ChangeData> V80 =
+      new Schema.Builder<ChangeData>()
+          .add(V79)
+          .addIndexedFields(ChangeField.SUBJECT_FIELD)
+          .addSearchSpecs(ChangeField.SUBJECT_SPEC)
+          .build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index c2672a9..4637191 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -332,6 +332,10 @@
     return new ChangeIndexPredicate(ChangeField.COMMIT_MESSAGE, message);
   }
 
+  public static Predicate<ChangeData> subject(String subject) {
+    return new ChangeIndexPredicate(ChangeField.SUBJECT_SPEC, subject);
+  }
+
   /**
    * Returns a predicate that matches changes where the provided {@code comment} appears in any
    * comment on any patch set of the change. Uses full-text search semantics.
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index b433b25..8262e58 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -186,6 +186,7 @@
   public static final String FIELD_MERGEABLE = "mergeable2";
   public static final String FIELD_MERGED_ON = "mergedon";
   public static final String FIELD_MESSAGE = "message";
+  public static final String FIELD_SUBJECT = "subject";
   public static final String FIELD_MESSAGE_EXACT = "messageexact";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
@@ -1140,6 +1141,12 @@
     return ChangePredicates.message(text);
   }
 
+  @Operator
+  public Predicate<ChangeData> subject(String value) throws QueryParseException {
+    checkFieldAvailable(ChangeField.SUBJECT_SPEC, ChangeQueryBuilder.FIELD_SUBJECT);
+    return ChangePredicates.subject(value);
+  }
+
   private Predicate<ChangeData> starredBySelf() throws QueryParseException {
     return ChangePredicates.starBy(
         args.starredChangesUtil, self(), StarredChangesUtil.DEFAULT_LABEL);
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 947ca6a..62fdcbb 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -236,6 +236,7 @@
     cherryPickInput = createCherryPickInput(revertInput);
     Instant timestamp = TimeUtil.now();
 
+    String initialMessage = revertInput.message;
     for (BranchNameKey projectAndBranch : changesPerProjectAndBranch.keySet()) {
       cherryPickInput.base = null;
       Project.NameKey project = projectAndBranch.project();
@@ -253,6 +254,7 @@
               .collect(Collectors.toSet());
 
       revertAllChangesInProjectAndBranch(
+          initialMessage,
           revertInput,
           project,
           sortedChangesInProjectAndBranch,
@@ -265,7 +267,9 @@
     return revertSubmissionInfo;
   }
 
+  // Warning: reuses and modifies revertInput.message.
   private void revertAllChangesInProjectAndBranch(
+      String initialMessage,
       RevertInput revertInput,
       Project.NameKey project,
       Iterator<PatchSetData> sortedChangesInProjectAndBranch,
@@ -273,8 +277,6 @@
       Instant timestamp)
       throws IOException, RestApiException, UpdateException, ConfigInvalidException,
           PermissionBackendException {
-
-    String initialMessage = revertInput.message;
     while (sortedChangesInProjectAndBranch.hasNext()) {
       ChangeNotes changeNotes = sortedChangesInProjectAndBranch.next().data().notes();
       if (cherryPickInput.base == null) {
@@ -282,6 +284,7 @@
         cherryPickInput.base = getBase(changeNotes, commitIdsInProjectAndBranch).name();
       }
 
+      // Set revert message for the current revert change.
       revertInput.message = getMessage(initialMessage, changeNotes);
       if (cherryPickInput.base.equals(changeNotes.getCurrentPatchSet().commitId().getName())) {
         // This is the code in case this is the first revert of this project + branch, and the
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 527253c..e0e980e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -63,6 +63,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.regex.Pattern;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Repository;
@@ -1278,10 +1279,38 @@
                 .distinct()
                 .count())
         .isEqualTo(1);
+
+    // Size
     List<ChangeApi> revertChanges = getChangeApis(revertSubmissionInfo);
+    assertThat(revertChanges).hasSize(3);
+    assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(2);
+
+    // Contents
     assertThat(revertChanges.get(0).current().files().get("c.txt").linesDeleted).isEqualTo(1);
     assertThat(revertChanges.get(1).current().files().get("a.txt").linesDeleted).isEqualTo(1);
     assertThat(revertChanges.get(2).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+
+    // Commit message
+    assertThat(revertChanges.get(0).current().commit(false).message)
+        .matches(
+            Pattern.compile(
+                "Revert \"first change\"\n\n"
+                    + "This reverts commit [a-f0-9]+\\.\n\n"
+                    + "Change-Id: I[a-f0-9]+\n"));
+    assertThat(revertChanges.get(1).current().commit(false).message)
+        .matches(
+            Pattern.compile(
+                "Revert \"second change\"\n\n"
+                    + "This reverts commit [a-f0-9]+\\.\n\n"
+                    + "Change-Id: I[a-f0-9]+\n"));
+    assertThat(revertChanges.get(2).current().commit(false).message)
+        .matches(
+            Pattern.compile(
+                "Revert \"third change\"\n\n"
+                    + "This reverts commit [a-f0-9]+\\.\n\n"
+                    + "Change-Id: I[a-f0-9]+\n"));
+
+    // Relationships
     String sha1FirstChange = resultCommits.get(0).getCommit().getName();
     String sha1ThirdChange = resultCommits.get(2).getCommit().getName();
     String sha1SecondRevert = revertChanges.get(2).current().commit(false).commit;
@@ -1291,9 +1320,6 @@
         .isEqualTo(sha1ThirdChange);
     assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
         .isEqualTo(sha1SecondRevert);
-
-    assertThat(revertChanges).hasSize(3);
-    assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(2);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index d7ec030..23f33fc 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -148,6 +148,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -635,7 +636,7 @@
     Change change1 = insert(repo, newChange(repo), userId);
     assertQuery("is:uploader", change1);
     assertQuery("uploader:" + userId.get(), change1);
-    change1 = newPatchSet(repo, change1, user2CurrentUser);
+    change1 = newPatchSet(repo, change1, user2CurrentUser, /* message= */ Optional.empty());
     // Uploader has changed
     assertQuery("uploader:" + userId.get());
     assertQuery("uploader:" + user2.get(), change1);
@@ -770,8 +771,9 @@
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     CurrentUser user2CurrentUser = userFactory.create(user2);
-    newPatchSet(repo, change1, user2CurrentUser);
+    change1 = newPatchSet(repo, change1, user2CurrentUser, /* message= */ Optional.empty());
     assertQuery("uploaderin:Administrators");
+    assertQuery("uploaderin:\"Registered Users\"", change1);
   }
 
   @Test
@@ -1033,6 +1035,55 @@
   }
 
   @Test
+  public void bySubject() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.SUBJECT_SPEC)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "First commit with test subject\n\n"
+                        + "Message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b920")
+                .create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "Second commit with test subject\n\n"
+                        + "Message body for another commit\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit commit3 =
+        repo.parseBody(
+            repo.commit()
+                .message(
+                    "Third commit with test subject\n\n"
+                        + "Last message body\n\n"
+                        + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+                .create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+
+    assertQuery("subject:First", change1);
+    assertQuery("subject:Second", change2);
+    assertQuery("subject:Third", change3);
+    assertQuery("subject:\"commit with test subject\"", change3, change2, change1);
+    assertQuery("subject:\"Message body\"");
+    assertQuery("subject:body");
+    change1 =
+        newPatchSet(
+            repo,
+            change1,
+            user,
+            Optional.of("Rework of commit with test subject\n\n" + "Message body\n\n"));
+    assertQuery("subject:Rework", change1);
+    assertQuery("subject:First");
+    assertQuery("subject:\"commit with test subject\"", change1, change3, change2);
+  }
+
+  @Test
   public void fullTextWithNumbers() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("12345 67890").create());
@@ -2807,7 +2858,7 @@
     gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
 
     PatchSet.Id ps3_1 = change3.currentPatchSetId();
-    change3 = newPatchSet(repo, change3, user);
+    change3 = newPatchSet(repo, change3, user, /* message= */ Optional.empty());
     assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
     // Response to previous patch set still counts as reviewing.
     gApi.changes()
@@ -4040,13 +4091,18 @@
     }
   }
 
-  protected Change newPatchSet(TestRepository<Repo> repo, Change c, CurrentUser user)
+  protected Change newPatchSet(
+      TestRepository<Repo> repo, Change c, CurrentUser user, Optional<String> message)
       throws Exception {
     // Add a new file so the patch set is not a trivial rebase, to avoid default
     // Code-Review label copying.
     int n = c.currentPatchSetId().get() + 1;
     RevCommit commit =
-        repo.parseBody(repo.commit().message("message").add("file" + n, "contents " + n).create());
+        repo.parseBody(
+            repo.commit()
+                .message(message.orElse("message"))
+                .add("file" + n, "contents " + n)
+                .create());
 
     PatchSetInserter inserter =
         patchSetFactory
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 733f112..3ed48e6 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -14,6 +14,7 @@
 import {ChangeActionsPluginApi} from './change-actions';
 import {RestPluginApi} from './rest';
 import {HookApi, RegisterOptions} from './hook';
+import {StylePluginApi} from './styles';
 
 export enum TargetElement {
   CHANGE_ACTIONS = 'changeactions',
@@ -77,10 +78,11 @@
     moduleName?: string,
     options?: RegisterOptions
   ): HookApi<T>;
-  // DEPRECATED: Just add <style> elements to `document.head`.
+  // DEPRECATED: Use styleApi() instead.
   registerStyleModule(endpoint: string, moduleName: string): void;
   reporting(): ReportingPluginApi;
   restApi(): RestPluginApi;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   screen(screenName: string, moduleName?: string): any;
+  styleApi(): StylePluginApi;
 }
diff --git a/polygerrit-ui/app/api/styles.ts b/polygerrit-ui/app/api/styles.ts
index f5b22a1..6ca8496 100644
--- a/polygerrit-ui/app/api/styles.ts
+++ b/polygerrit-ui/app/api/styles.ts
@@ -22,6 +22,7 @@
   toString(): string;
 }
 
+/** Accessible via `window.Gerrit.styles`. */
 export declare interface Styles {
   font: Style;
   form: Style;
@@ -32,3 +33,22 @@
   table: Style;
   modal: Style;
 }
+
+/** Accessible via `window.Gerrit.install(plugin => {plugin.styleApi()})`. */
+export declare interface StylePluginApi {
+  /**
+   * Convenience method for adding a CSS rule to a <style> element in <head>.
+   *
+   * Note that you can only insert one rule per call. See `insertRule()`
+   * documentation of `CSSStyleSheet`:
+   * https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/insertRule
+   *
+   * @param rule the css rule, e.g.:
+   *        ```
+   *          html.darkTheme {
+   *            --header-background-color: blue;
+   *          }
+   *        ```
+   */
+  insertCSSRule(rule: string): void;
+}
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 adc3bd7..a78ba1c 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
@@ -1351,7 +1351,6 @@
   showRevertDialog() {
     const change = this.change;
     if (!change) return;
-    // The search is still broken if there is a " in the topic.
     const query = `submissionid: "${change.submission_id}"`;
     /* A chromium plugin expects that the modifyRevertMsg hook will only
     be called after the revert button is pressed, hence we populate the
@@ -1365,7 +1364,11 @@
         return;
       }
       assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
-      this.confirmRevertDialog.populate(change, this.commitMessage, changes);
+      this.confirmRevertDialog.populate(
+        change,
+        this.commitMessage,
+        changes.length
+      );
       this.showActionDialog(this.confirmRevertDialog);
     });
   }
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 a1021a0..bdcdc31 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
@@ -1582,13 +1582,8 @@
             'Revert submission 199 0' +
             '\n\n' +
             'Reason for revert: <INSERT REASONING HERE>' +
-            '\n' +
-            'Reverted Changes:' +
-            '\n' +
-            '1234567890:random' +
-            '\n' +
-            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-            '\n';
+            '\n\n' +
+            'Reverted changes: /q/submissionid:199+0\n';
           assert.equal(confirmRevertDialog.message, expectedMsg);
           const radioInputs = queryAll<HTMLInputElement>(
             confirmRevertDialog,
@@ -1648,13 +1643,8 @@
             'Revert submission 199 0' +
             '\n\n' +
             'Reason for revert: <INSERT REASONING HERE>' +
-            '\n' +
-            'Reverted Changes:' +
-            '\n' +
-            '1234567890:random' +
-            '\n' +
-            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-            '\n';
+            '\n\n' +
+            'Reverted changes: /q/submissionid:199+0\n';
           const singleChangeMsg =
             'Revert "random commit message"\n\nThis reverts ' +
             'commit 2000.\n\nReason' +
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 fe2bb4d..91b82dc 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
@@ -2112,7 +2112,6 @@
       this.viewState.basePatchNum = PARENT;
 
     const patchChanged = this.hasPatchRangeChanged(this.viewState);
-    let patchNumChanged = this.hasPatchNumChanged(this.viewState);
 
     this.patchRange = {
       patchNum: this.viewState.patchNum,
@@ -2136,14 +2135,13 @@
           ...this.patchRange,
           patchNum: computeLatestPatchNum(this.allPatchSets),
         };
-        patchNumChanged = true;
       }
       if (patchChanged) {
         // We need to collapse all diffs when viewState changes so that a non
         // existing diff is not requested. See Issue 125270 for more details.
         this.fileList?.resetFileState();
         this.fileList?.collapseAllDiffs();
-        this.reloadPatchNumDependentResources(patchNumChanged).then(() => {
+        this.reloadPatchNumDependentResources().then(() => {
           this.sendShowChangeEvent();
         });
       }
@@ -2978,25 +2976,8 @@
    * Kicks off requests for resources that rely on the patch range
    * (`this.patchRange`) being defined.
    */
-  reloadPatchNumDependentResources(patchNumChanged?: boolean) {
-    assertIsDefined(this.changeNum, 'changeNum');
-    if (!this.patchRange?.patchNum) throw new Error('missing patchNum');
-    const promises = [this.loadAndSetCommitInfo()];
-    if (patchNumChanged) {
-      promises.push(
-        this.getCommentsModel().reloadPortedComments(
-          this.changeNum,
-          this.patchRange?.patchNum
-        )
-      );
-      promises.push(
-        this.getCommentsModel().reloadPortedDrafts(
-          this.changeNum,
-          this.patchRange?.patchNum
-        )
-      );
-    }
-    return Promise.all(promises);
+  reloadPatchNumDependentResources() {
+    return this.loadAndSetCommitInfo();
   }
 
   // Private but used in tests
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 4fafcd9..9d89192 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
@@ -103,10 +103,6 @@
 import {rootUrl} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
-import {
-  CommentsModel,
-  commentsModelToken,
-} from '../../../models/comments/comments-model';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 
 suite('gr-change-view tests', () => {
@@ -114,7 +110,6 @@
   let setUrlStub: sinon.SinonStub;
   let userModel: UserModel;
   let changeModel: ChangeModel;
-  let commentsModel: CommentsModel;
 
   const ROBOT_COMMENTS_LIMIT = 10;
 
@@ -382,7 +377,6 @@
       sinon.stub(element.actions, 'reload').returns(Promise.resolve());
     });
     userModel = testResolver(userModelToken);
-    commentsModel = testResolver(commentsModelToken);
     changeModel = testResolver(changeModelToken);
   });
 
@@ -1490,7 +1484,7 @@
       .callsFake(() => Promise.resolve());
     const reloadPatchDependentStub = sinon
       .stub(element, 'reloadPatchNumDependentResources')
-      .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
+      .callsFake(() => Promise.resolve());
     assertIsDefined(element.fileList);
     await element.fileList.updateComplete;
     const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
@@ -1525,46 +1519,6 @@
     assert.isTrue(collapseStub.calledTwice);
   });
 
-  test('reload ported comments when patchNum changes', async () => {
-    assertIsDefined(element.fileList);
-    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
-    sinon.stub(element, 'loadAndSetCommitInfo');
-    await element.updateComplete;
-    const reloadPortedCommentsStub = sinon.stub(
-      commentsModel,
-      'reloadPortedComments'
-    );
-    const reloadPortedDraftsStub = sinon.stub(
-      commentsModel,
-      'reloadPortedDrafts'
-    );
-    sinon.stub(element.fileList, 'collapseAllDiffs');
-
-    const value: ChangeViewState = {
-      ...createChangeViewState(),
-      view: GerritView.CHANGE,
-      patchNum: 1 as RevisionPatchSetNum,
-    };
-    element.viewState = value;
-    await element.updateComplete;
-
-    element.initialLoadComplete = true;
-    element.change = {
-      ...createChangeViewChange(),
-      revisions: {
-        rev1: createRevision(1),
-        rev2: createRevision(2),
-      },
-    };
-
-    value.basePatchNum = 1 as BasePatchSetNum;
-    value.patchNum = 2 as RevisionPatchSetNum;
-    element.viewState = {...value};
-    await element.updateComplete;
-    assert.isTrue(reloadPortedCommentsStub.calledOnce);
-    assert.isTrue(reloadPortedDraftsStub.calledOnce);
-  });
-
   test('do not reload entire page when patchRange doesnt change', async () => {
     assertIsDefined(element.fileList);
     const reloadStub = sinon
@@ -2382,7 +2336,7 @@
       sinon.stub(element, 'getLatestCommitMessage').returns(Promise.resolve());
       sinon
         .stub(element, 'reloadPatchNumDependentResources')
-        .returns(Promise.resolve([undefined, undefined, undefined]));
+        .returns(Promise.resolve());
     });
 
     test("don't report changeDisplayed on reply", async () => {
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 b238d77..6b37284 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
@@ -14,9 +14,9 @@
 import {BindValueChangeEvent} from '../../../types/events';
 import {resolve} from '../../../models/dependency';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {createSearchUrl} from '../../../models/views/search';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
-const CHANGE_SUBJECT_LIMIT = 50;
 const INSERT_REASON_STRING = '<INSERT REASONING HERE>';
 
 // TODO(dhruvsri): clean up repeated definitions after moving to js modules
@@ -58,8 +58,9 @@
   @state()
   private showRevertSubmission = false;
 
+  // Value supplied by populate(). Non-private for access in tests.
   @state()
-  private changesCount?: number;
+  changesCount?: number;
 
   @state()
   showErrorMessage = false;
@@ -189,15 +190,15 @@
     );
   }
 
-  populate(change: ChangeInfo, commitMessage: string, changes: ChangeInfo[]) {
-    this.changesCount = changes.length;
+  populate(change: ChangeInfo, commitMessage: string, changesCount: number) {
+    this.changesCount = changesCount;
     // The option to revert a single change is always available
     this.populateRevertSingleChangeMessage(
       change,
       commitMessage,
       change.current_revision
     );
-    this.populateRevertSubmissionMessage(change, changes, commitMessage);
+    this.populateRevertSubmissionMessage(change, commitMessage);
   }
 
   populateRevertSingleChangeMessage(
@@ -225,12 +226,6 @@
     this.originalRevertMessages[this.revertType] = this.message;
   }
 
-  private getTrimmedChangeSubject(subject: string) {
-    if (!subject) return '';
-    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
-    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
-  }
-
   private modifyRevertSubmissionMsg(
     change: ChangeInfo,
     msg: string,
@@ -243,30 +238,22 @@
     );
   }
 
-  populateRevertSubmissionMessage(
-    change: ChangeInfo,
-    changes: ChangeInfo[],
-    commitMessage: string
-  ) {
+  populateRevertSubmissionMessage(change: ChangeInfo, commitMessage: string) {
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
       fireAlert(this, ERR_COMMIT_NOT_FOUND);
       return;
     }
-    if (!changes || changes.length <= 1) return;
-    const revertTitle = `Revert submission ${change.submission_id}`;
-    let message =
-      revertTitle +
+    if (this.changesCount! <= 1) return;
+    const message =
+      `Revert submission ${change.submission_id}` +
       '\n\n' +
       'Reason for revert: <INSERT ' +
-      'REASONING HERE>\n';
-    message += 'Reverted Changes:\n';
-    changes.forEach(change => {
-      message +=
-        `${change.change_id.substring(0, 10)}:` +
-        `${this.getTrimmedChangeSubject(change.subject)}\n`;
-    });
+      'REASONING HERE>\n\n' +
+      'Reverted changes: ' +
+      createSearchUrl({query: `submissionid:${change.submission_id}`}) +
+      '\n';
     this.message = this.modifyRevertSubmissionMsg(
       change,
       message,
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 59416da..38309f2 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
@@ -6,7 +6,7 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
 import {createChange} from '../../../test/test-data-generators';
-import {CommitId} from '../../../types/common';
+import {ChangeSubmissionId, CommitId} from '../../../types/common';
 import {EventType} from '../../../types/events';
 import './gr-confirm-revert-dialog';
 import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
@@ -111,4 +111,22 @@
       'Reason for revert: <INSERT REASONING HERE>\n';
     assert.equal(element.message, expected);
   });
+
+  test('revert submission', () => {
+    element.changesCount = 3;
+    element.populateRevertSubmissionMessage(
+      {
+        ...createChange(),
+        submission_id: '5545' as ChangeSubmissionId,
+        current_revision: 'abcd123' as CommitId,
+      },
+      'one line commit\n\nChange-Id: abcdefg\n'
+    );
+
+    const expected =
+      'Revert submission 5545\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n\n' +
+      'Reverted changes: /q/submissionid:5545\n';
+    assert.equal(element.message, expected);
+  });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index a2beee2..90b05f6 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -69,8 +69,8 @@
         .notCurrent {
           color: var(--warning-foreground);
         }
-        .indirectAncestor {
-          color: var(--indirect-ancestor-text-color);
+        .indirectRelation {
+          color: var(--indirect-relation-text-color);
         }
         .submittableCheck {
           padding-left: var(--spacing-s);
@@ -147,8 +147,8 @@
       change._revision_number !== change._current_revision_number
     ) {
       classes.push('notCurrent');
-    } else if (!isChangeInfo(change) && this.isIndirectAncestor(change)) {
-      classes.push('indirectAncestor');
+    } else if (!isChangeInfo(change) && this.isIndirectRelation(change)) {
+      classes.push('indirectRelation');
     } else if (change.submittable) {
       classes.push('submittable');
     } else if (change.status === ChangeStatus.NEW) {
@@ -169,15 +169,15 @@
       change._revision_number !== change._current_revision_number
     ) {
       return 'Not current';
-    } else if (!isChangeInfo(change) && this.isIndirectAncestor(change)) {
-      return 'Indirect ancestor';
+    } else if (!isChangeInfo(change) && this.isIndirectRelation(change)) {
+      return 'Indirect relation';
     } else if (change.submittable) {
       return 'Submittable';
     }
     return '';
   }
 
-  private isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
+  private isIndirectRelation(change: RelatedChangeAndCommitInfo) {
     return (
       this.connectedRevisions &&
       !this.connectedRevisions.includes(change.commit.commit)
diff --git a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts
index e01bb34..ab95711 100644
--- a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts
+++ b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts
@@ -51,7 +51,7 @@
         #notificationsPrompt {
           position: absolute;
           right: 30px;
-          top: 100px;
+          top: 50px;
           z-index: 150; /* Less than gr-hovercard's, higher than rest */
           display: flex;
           background-color: var(--background-color-primary);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts
new file mode 100644
index 0000000..13eefc8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {PluginApi} from '../../../api/plugin';
+import {StylePluginApi} from '../../../api/styles';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+
+function getOrCreatePluginStyleEl(): HTMLStyleElement {
+  const el =
+    document.head.querySelector<HTMLStyleElement>('style#plugin-style');
+  if (el) return el;
+
+  const styleEl = document.createElement('style');
+  styleEl.setAttribute('id', 'plugin-style');
+  // Append at the end so that they override the default light and dark theme
+  // styles.
+  document.head.appendChild(styleEl);
+  return styleEl;
+}
+
+export class GrPluginStyleApi implements StylePluginApi {
+  constructor(
+    private readonly reporting: ReportingService,
+    private readonly plugin: PluginApi
+  ) {
+    this.reporting.trackApi(this.plugin, 'style', 'constructor');
+  }
+
+  insertCSSRule(rule: string): void {
+    this.reporting.trackApi(this.plugin, 'style', 'insertCSSRule');
+
+    const styleEl = getOrCreatePluginStyleEl();
+    try {
+      styleEl.sheet?.insertRule(rule);
+    } catch (error) {
+      console.error(
+        `Failed to insert CSS rule for plugin ${this.plugin.getPluginName()}: ${error}`
+      );
+    }
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts
new file mode 100644
index 0000000..469d667
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {query, queryAndAssert} from '../../../utils/common-util';
+import {assert} from '@open-wc/testing';
+import {StylePluginApi} from '../../../api/styles';
+
+suite('gr-plugin-style-api tests', () => {
+  let styleApi: StylePluginApi;
+
+  setup(() => {
+    window.Gerrit.install(
+      p => (styleApi = p.styleApi()),
+      '0.1',
+      'http://test.com/plugins/testplugin/static/test.js'
+    );
+  });
+
+  teardown(() => {
+    const styleEl = query<HTMLStyleElement>(
+      document.head,
+      'style#plugin-style'
+    );
+    styleEl?.remove();
+  });
+
+  test('insertCSSRule adds a rule', async () => {
+    styleApi.insertCSSRule('html{color:green;}');
+    const styleEl = queryAndAssert<HTMLStyleElement>(
+      document.head,
+      'style#plugin-style'
+    );
+    const styleSheet = styleEl.sheet;
+    assert.equal(styleSheet?.cssRules.length, 1);
+  });
+
+  test('insertCSSRule re-uses the <style> element', async () => {
+    styleApi.insertCSSRule('html{color:green;}');
+    styleApi.insertCSSRule('html{margin:0px;}');
+    const styleEl = queryAndAssert<HTMLStyleElement>(
+      document.head,
+      'style#plugin-style'
+    );
+    const styleSheet = styleEl.sheet;
+    assert.equal(styleSheet?.cssRules.length, 2);
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 2ed3794..7170051 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -35,6 +35,8 @@
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {GrPluginStyleApi} from './gr-plugin-style-api';
+import {StylePluginApi} from '../../../api/styles';
 
 /**
  * Plugin-provided custom components can affect content in extension
@@ -244,6 +246,10 @@
     return new GrReportingJsApi(this.report, this);
   }
 
+  styleApi(): StylePluginApi {
+    return new GrPluginStyleApi(this.report, this);
+  }
+
   admin(): AdminPluginApi {
     return new GrAdminApi(this.report, this);
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 2de5b5f..8b3e2a6 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -533,7 +533,7 @@
     } catch (err) {
       fireNetworkError(err as Error);
       if (req.errFn) {
-        req.errFn.call(undefined, null, err as Error);
+        await req.errFn.call(undefined, null, err as Error);
         xhr = undefined;
       } else {
         throw err;
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 dd08544..9f52517 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -357,12 +357,8 @@
   }
 
   private handleTabKey(e: KeyboardEvent) {
-    // Tab should have normal behavior if the picker is closed or if the user
-    // has only typed ':'.
-    if (
-      !this.isDropdownVisible() ||
-      (this.isEmojiDropdownActive() && this.currentSearchString === '')
-    ) {
+    // Tab should have normal behavior if the picker is closed.
+    if (!this.isDropdownVisible()) {
       return;
     }
     e.preventDefault();
@@ -372,12 +368,9 @@
 
   // private but used in test
   handleEnterByKey(e: KeyboardEvent) {
-    // Enter should have newline behavior if the picker is closed or if the user
-    // has only typed ':'. Also make sure that shortcuts aren't clobbered.
-    if (
-      !this.isDropdownVisible() ||
-      (this.isEmojiDropdownActive() && this.currentSearchString === '')
-    ) {
+    // Enter should have newline behavior if the picker is closed. Also make
+    // sure that shortcuts aren't clobbered.
+    if (!this.isDropdownVisible()) {
       this.indent(e);
       return;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 634c387..1460246 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -617,19 +617,6 @@
       await element.updateComplete;
       assert.equal(element.text, '💯');
     });
-
-    test('enter key - ignored on just colon without more information', async () => {
-      const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
-      pressKey(element.textarea! as HTMLElement, Key.ENTER);
-      assert.isFalse(enterSpy.called);
-      element.textarea!.focus();
-      element.textarea!.selectionStart = 1;
-      element.textarea!.selectionEnd = 1;
-      element.text = ':';
-      await element.updateComplete;
-      pressKey(element.textarea! as HTMLElement, Key.ENTER);
-      assert.isFalse(enterSpy.called);
-    });
   });
 
   suite('gr-textarea monospace', () => {
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 e5fce12..14bb17e 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
@@ -30,6 +30,29 @@
   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;
@@ -179,8 +202,7 @@
   moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
     const result = this.cursorManager.next({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
-      getTargetHeight: target =>
-        (target?.parentNode as HTMLElement)?.scrollHeight || 0,
+      getTargetHeight: target => fromRowToChunk(target)?.scrollHeight || 0,
       clipToTop,
     });
     this._fixSide();
@@ -413,13 +435,14 @@
   }
 
   _isFirstRowOfChunk(row: HTMLElement) {
-    const parentClassList = (row.parentNode as HTMLElement).classList;
-    const isInChunk =
-      parentClassList.contains('section') && parentClassList.contains('delta');
-    const previousRow = row.previousSibling as HTMLElement;
-    const firstContentRow =
-      !previousRow || previousRow.classList.contains('moveControls');
-    return isInChunk && firstContentRow;
+    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 {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
index 1e554b7..b9db280 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -7,7 +7,12 @@
 import '../gr-diff/gr-diff';
 import './gr-diff-cursor';
 import {fixture, html, assert} from '@open-wc/testing';
-import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+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';
@@ -41,37 +46,29 @@
 
     diff = createDiff();
     diffElement.prefs = createDefaultDiffPrefs();
+    diffElement.renderPrefs = {use_lit_components: true};
     diffElement.diff = diff;
     await promise;
   });
 
   test('diff cursor functionality (side-by-side)', () => {
-    // The cursor has been initialized to the first delta.
     assert.isOk(cursor.diffRow);
 
-    const firstDeltaRow = queryAndAssert<HTMLElement>(
+    const deltaRows = queryAll<HTMLTableRowElement>(
       diffElement,
-      '.section.delta .diff-row'
+      '.section.delta tr.diff-row'
     );
-    assert.equal(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.diffRow, deltaRows[0]);
 
     cursor.moveDown();
 
-    assert.isOk(firstDeltaRow.nextElementSibling);
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
-    assert.equal(
-      cursor.diffRow,
-      firstDeltaRow.nextElementSibling as HTMLElement
-    );
+    assert.notEqual(cursor.diffRow, deltaRows[0]);
+    assert.equal(cursor.diffRow, deltaRows[1]);
 
     cursor.moveUp();
 
-    assert.isOk(firstDeltaRow.nextElementSibling);
-    assert.notEqual(
-      cursor.diffRow,
-      firstDeltaRow.nextElementSibling as HTMLElement
-    );
-    assert.equal(cursor.diffRow, firstDeltaRow);
+    assert.notEqual(cursor.diffRow, deltaRows[1]);
+    assert.equal(cursor.diffRow, deltaRows[0]);
   });
 
   test('moveToFirstChunk', async () => {
@@ -115,20 +112,26 @@
     ] 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(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    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(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.equal(cursor.diffRow, rows[1]);
     assert.equal(cursor.side, Side.LEFT);
+
     cursor.moveToFirstChunk();
     assert.ok(cursor.diffRow);
-    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.equal(cursor.diffRow, rows[0]);
     assert.equal(cursor.side, Side.RIGHT);
   });
 
@@ -164,20 +167,31 @@
     await waitForEventOnce(diffElement, 'render');
     cursor._updateStops();
 
-    const chunks = [...queryAll(diffElement, '.section.delta')];
+    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.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    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.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[0]);
     assert.equal(cursor.side, Side.LEFT);
+
     cursor.moveToLastChunk();
-    assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+    assert.ok(cursor.diffRow);
+    assert.equal(cursor.diffRow, rows[1]);
     assert.equal(cursor.side, Side.RIGHT);
   });
 
@@ -221,30 +235,22 @@
     });
 
     test('diff cursor functionality (unified)', () => {
-      // The cursor has been initialized to the first delta.
       assert.isOk(cursor.diffRow);
 
-      const firstDeltaRow = queryAndAssert<HTMLElement>(
-        diffElement,
-        '.section.delta .diff-row'
-      );
-      assert.equal(cursor.diffRow, firstDeltaRow);
+      const rows = [
+        ...queryAll(diffElement, '.section.delta tr.diff-row'),
+      ] as HTMLTableRowElement[];
+      assert.equal(cursor.diffRow, rows[0]);
 
       cursor.moveDown();
 
-      assert.notEqual(cursor.diffRow, firstDeltaRow);
-      assert.equal(
-        cursor.diffRow,
-        firstDeltaRow.nextElementSibling as HTMLElement
-      );
+      assert.notEqual(cursor.diffRow, rows[0]);
+      assert.equal(cursor.diffRow, rows[1]);
 
       cursor.moveUp();
 
-      assert.notEqual(
-        cursor.diffRow,
-        firstDeltaRow.nextElementSibling as HTMLElement
-      );
-      assert.equal(cursor.diffRow, firstDeltaRow);
+      assert.notEqual(cursor.diffRow, rows[1]);
+      assert.equal(cursor.diffRow, rows[0]);
     });
   });
 
@@ -253,19 +259,21 @@
     // mode.
     assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
 
-    const firstDeltaSection = queryAndAssert<HTMLElement>(
-      diffElement,
-      '.section.delta'
-    );
-    const firstDeltaRow = queryAndAssert<HTMLElement>(
-      firstDeltaSection,
-      '.diff-row'
-    );
+    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, firstDeltaRow);
+    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
@@ -274,33 +282,26 @@
     cursor.moveLeft();
 
     assert.equal(cursor.side, Side.LEFT);
-    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.notEqual(cursor.diffRow, rows[0]);
+    assert.equal(cursor.diffRow, rowBeforeFirstDelta);
     assert.equal(cursor.cursorManager.index, firstIndex - 1);
-    assert.equal(
-      cursor.diffRow!.parentElement,
-      firstDeltaSection.previousSibling
-    );
 
     // 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, firstDeltaRow);
+    assert.notEqual(cursor.diffRow, rowBeforeFirstDelta);
+    assert.notEqual(cursor.diffRow, rows[0]);
     assert.isTrue(cursor.cursorManager.index > firstIndex);
-    assert.equal(cursor.diffRow!.parentElement, firstDeltaSection.nextSibling);
   });
 
   test('chunk skip functionality', () => {
-    const chunks = [...queryAll(diffElement, '.section.delta')];
-    const indexOfChunk = function (chunk: HTMLElement) {
-      return Array.prototype.indexOf.call(chunks, chunk);
-    };
+    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.
-    let currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
-    assert.equal(currentIndex, 0);
+    assert.equal(cursor.diffRow, deltaChunks[0].querySelector('tr'));
     assert.equal(cursor.side, Side.RIGHT);
 
     // Move to the next chunk.
@@ -308,9 +309,7 @@
 
     // Since this chunk only has content on the left side. we should have been
     // automatically moved over.
-    const previousIndex = currentIndex;
-    currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
-    assert.equal(currentIndex, previousIndex + 1);
+    assert.equal(cursor.diffRow, deltaChunks[1].querySelector('tr'));
     assert.equal(cursor.side, Side.LEFT);
   });
 
@@ -358,10 +357,10 @@
 
     test('renders moveControls with simple descriptions', () => {
       const [movedIn, movedOut] = [
-        ...queryAll(diffElement, '.dueToMove .moveControls'),
+        ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
       ];
-      assert.equal(movedIn.textContent, 'Moved in');
-      assert.equal(movedOut.textContent, 'Moved out');
+      assert.include(movedIn.innerText, 'Moved in');
+      assert.include(movedOut.innerText, 'Moved out');
     });
   });
 
@@ -409,10 +408,10 @@
 
     test('renders moveControls with simple descriptions', () => {
       const [movedIn, movedOut] = [
-        ...queryAll(diffElement, '.dueToMove .moveControls'),
+        ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
       ];
-      assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
-      assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
+      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 () => {
@@ -609,6 +608,7 @@
     const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
     showContext.click();
     await waitForEventOnce(diffElement, 'render');
+    await waitUntil(() => spy.called);
     assert.isTrue(spy.called);
   });
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
index db53eae..a790736 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
@@ -4,10 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../styles/shared-styles';
-import {
-  normalize,
-  NormalizedRange,
-} from '../gr-diff-highlight/gr-range-normalizer';
+import {normalize} from '../gr-diff-highlight/gr-range-normalizer';
 import {
   descendedFromClass,
   parentWithClass,
@@ -21,7 +18,6 @@
   getSideByLineEl,
   isThreadEl,
 } from '../gr-diff/gr-diff-utils';
-import {assertIsDefined} from '../../../utils/common-util';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -121,19 +117,17 @@
   }
 
   handleCopy = (e: ClipboardEvent) => {
-    let commentSelected = false;
     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)) {
-      commentSelected = true;
-    }
+    if (this.diffTable.classList.contains(SelectionClass.COMMENT)) return;
+
     const lineEl = getLineElByChild(target);
     if (!lineEl) return;
     const side = getSideByLineEl(lineEl);
-    const text = this.getSelectedText(side, commentSelected);
+    const text = this.getSelectedText(side);
     if (text && e.clipboardData) {
       e.clipboardData.setData('Text', text);
       e.preventDefault();
@@ -166,14 +160,11 @@
    * @param commentSelected Whether or not a comment is selected.
    * @return The selected text.
    */
-  getSelectedText(side: Side, commentSelected: boolean) {
+  getSelectedText(side: Side) {
     const sel = this.getSelection();
     if (!sel || sel.rangeCount !== 1) {
       return ''; // No multi-select support yet.
     }
-    if (commentSelected) {
-      return this.getCommentLines(sel, side);
-    }
     const range = normalize(sel.getRangeAt(0));
     const startLineEl = getLineElByChild(range.startContainer);
     if (!startLineEl) return;
@@ -253,82 +244,4 @@
     this.linesCache[side] = lines;
     return lines;
   }
-
-  /**
-   * Query the diffElement for comments and check whether they lie inside the
-   * selection range.
-   *
-   * @param sel The selection of the window.
-   * @param side The side that is currently selected.
-   * @return The selected comment text.
-   */
-  getCommentLines(sel: Selection, side: Side) {
-    const range = normalize(sel.getRangeAt(0));
-    const content = [];
-    assertIsDefined(this.diffTable, 'diffTable');
-    const messages = this.diffTable.querySelectorAll(
-      `.side-by-side [data-side="${side}"] .message *, .unified .message *`
-    );
-
-    for (let i = 0; i < messages.length; i++) {
-      const el = messages[i];
-      // Check if the comment element exists inside the selection.
-      if (sel.containsNode(el, true)) {
-        // Padded elements require newlines for accurate spacing.
-        if (
-          el.parentElement!.id === 'container' ||
-          el.parentElement!.nodeName === 'BLOCKQUOTE'
-        ) {
-          if (content.length && content[content.length - 1] !== '') {
-            content.push('');
-          }
-        }
-
-        if (
-          el.id === 'output' &&
-          !descendedFromClass(el, 'collapsed', this.diffTable)
-        ) {
-          content.push(this.getTextContentForRange(el, sel, range));
-        }
-      }
-    }
-
-    return content.join('\n');
-  }
-
-  /**
-   * Given a DOM node, a selection, and a selection range, recursively get all
-   * of the text content within that selection.
-   * Using a domNode that isn't in the selection returns an empty string.
-   *
-   * @param domNode The root DOM node.
-   * @param sel The selection.
-   * @param range The normalized selection range.
-   * @return The text within the selection.
-   */
-  getTextContentForRange(
-    domNode: Node,
-    sel: Selection,
-    range: NormalizedRange
-  ) {
-    if (!sel.containsNode(domNode, true)) {
-      return '';
-    }
-
-    let text = '';
-    if (domNode instanceof Text) {
-      text = domNode.textContent || '';
-      if (domNode === range.endContainer) {
-        text = text.substring(0, range.endOffset);
-      }
-      if (domNode === range.startContainer) {
-        text = text.substring(range.startOffset);
-      }
-    } else {
-      for (const childNode of domNode.childNodes) {
-        text += this.getTextContentForRange(childNode, sel, range);
-      }
-    }
-    return text;
-  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
index 8acaf04..1da6e0c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -8,7 +8,6 @@
 import {GrDiffSelection} from './gr-diff-selection';
 import {createDiff} from '../../../test/test-data-generators';
 import {DiffInfo, Side} from '../../../api/diff';
-import {GrFormattedText} from '../../../elements/shared/gr-formatted-text/gr-formatted-text';
 import {fixture, html, assert} from '@open-wc/testing';
 import {mouseDown} from '../../../test/test-utils';
 
@@ -43,15 +42,7 @@
       <td class="lineNum right" data-value="2">2</td>
       <td class="content">
         <div class="contentText" data-side="right">more more more</div>
-        <div data-side="right">
-          <div class="comment-thread">
-            <div class="gr-formatted-text message">
-              <span id="output" class="gr-formatted-text"
-                >This is a comment on the right</span
-              >
-            </div>
-          </div>
-        </div>
+        <div data-side="right"></div>
       </td>
     </tr>
     <tr class="diff-row">
@@ -59,15 +50,7 @@
       <td class="lineNum left" data-value="3">3</td>
       <td class="content">
         <div class="contentText" data-side="left">ga ga</div>
-        <div data-side="left">
-          <div class="comment-thread">
-            <div class="gr-formatted-text message">
-              <span id="output" class="gr-formatted-text"
-                >This is <a>a</a> different comment 💩 unicode is fun</span
-              >
-            </div>
-          </div>
-        </div>
+        <div data-side="left"></div>
       </td>
       <td class="lineNum right" data-value="3">3</td>
     </tr>
@@ -76,11 +59,7 @@
       <td class="lineNum left" data-value="4">4</td>
       <td class="content">
         <div class="contentText" data-side="left">ga ga</div>
-        <div data-side="left">
-          <div class="comment-thread">
-            <textarea data-side="right">test for textarea copying</textarea>
-          </div>
-        </div>
+        <div data-side="left"></div>
       </td>
       <td class="lineNum right" data-value="4">4</td>
     </tr>
@@ -190,7 +169,7 @@
   test('asks for text for left side Elements', () => {
     const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
     emulateCopyOn(diffTable.querySelector('div.contentText'));
-    assert.deepEqual([Side.LEFT, false], getSelectedTextStub.lastCall.args);
+    assert.deepEqual([Side.LEFT], getSelectedTextStub.lastCall.args);
   });
 
   test('reacts to copy for content Elements', () => {
@@ -257,45 +236,7 @@
       2
     );
     selection.addRange(range);
-    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
-  });
-
-  test('copies comments', () => {
-    element.diffTable!.classList.add('selected-left');
-    element.diffTable!.classList.add('selected-comment');
-    element.diffTable!.classList.remove('selected-right');
-    const selection = document.getSelection();
-    if (selection === null) assert.fail('no selection');
-    selection.removeAllRanges();
-    const range = document.createRange();
-    range.setStart(
-      diffTable.querySelector('.gr-formatted-text *')!.firstChild!,
-      3
-    );
-    range.setEnd(
-      diffTable.querySelectorAll('.gr-formatted-text *')[2].childNodes[2],
-      7
-    );
-    selection.addRange(range);
-    assert.equal(
-      's is a comment\nThis is a differ',
-      element.getSelectedText(Side.LEFT, true)
-    );
-  });
-
-  test('respects astral chars in comments', () => {
-    element.diffTable!.classList.add('selected-left');
-    element.diffTable!.classList.add('selected-comment');
-    element.diffTable!.classList.remove('selected-right');
-    const selection = document.getSelection();
-    if (selection === null) assert.fail('no selection');
-    selection.removeAllRanges();
-    const range = document.createRange();
-    const nodes = diffTable.querySelectorAll('.gr-formatted-text *');
-    range.setStart(nodes[2].childNodes[2], 13);
-    range.setEnd(nodes[2].childNodes[2], 23);
-    selection.addRange(range);
-    assert.equal('mment 💩 u', element.getSelectedText(Side.LEFT, true));
+    assert.equal(element.getSelectedText(Side.LEFT), 'ba\nzin\nga');
   });
 
   test('defers to default behavior for textarea', () => {
@@ -323,7 +264,7 @@
       10
     );
     selection.addRange(range);
-    assert.equal(element.getSelectedText(Side.RIGHT, false), ' other');
+    assert.equal(element.getSelectedText(Side.RIGHT), ' other');
   });
 
   test('copies to end of side (issue 7895)', () => {
@@ -339,54 +280,6 @@
       2
     );
     selection.addRange(range);
-    assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
-  });
-
-  suite('getTextContentForRange', () => {
-    let selection: Selection;
-    let range: Range;
-    let nodes: NodeListOf<GrFormattedText>;
-
-    setup(() => {
-      element.diffTable!.classList.add('selected-left');
-      element.diffTable!.classList.add('selected-comment');
-      element.diffTable!.classList.remove('selected-right');
-      const s = document.getSelection();
-      if (s === null) assert.fail('no selection');
-      selection = s;
-      selection.removeAllRanges();
-      range = document.createRange();
-      nodes = diffTable.querySelectorAll('.gr-formatted-text *');
-    });
-
-    test('multi level element contained in range', () => {
-      range.setStart(nodes[2].childNodes[0], 1);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(
-        element.getTextContentForRange(diffTable, selection, range),
-        'his is a differ'
-      );
-    });
-
-    test('multi level element as startContainer of range', () => {
-      range.setStart(nodes[2].childNodes[1], 0);
-      range.setEnd(nodes[2].childNodes[2], 7);
-      selection.addRange(range);
-      assert.equal(
-        element.getTextContentForRange(diffTable, selection, range),
-        'a differ'
-      );
-    });
-
-    test('startContainer === endContainer', () => {
-      range.setStart(nodes[0].firstChild!, 2);
-      range.setEnd(nodes[0].firstChild!, 12);
-      selection.addRange(range);
-      assert.equal(
-        element.getTextContentForRange(diffTable, selection, range),
-        'is is a co'
-      );
-    });
+    assert.equal(element.getSelectedText(Side.LEFT), 'ba\nzin\nga');
   });
 });
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 7a724b9..5738d65 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
@@ -9,6 +9,7 @@
 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. */
@@ -487,12 +488,13 @@
     // This is a temporary hack while migration to lit based diff rendering:
     // Elements with 'display: contents;' do not have a height, so they
     // won't work as intended with `untilRendered()`.
-    const watchEl =
-      this.element.tagName === 'GR-DIFF-SECTION'
-        ? this.element.firstElementChild
-        : this.element;
-    assertIsDefined(watchEl);
-    await untilRendered(watchEl as HTMLElement);
+    const isLitDiff = this.element.tagName === 'GR-DIFF-SECTION';
+    if (isLitDiff) {
+      await (this.element as LitElement).updateComplete;
+      await untilRendered(this.element.firstElementChild as HTMLElement);
+    } else {
+      await untilRendered(this.element);
+    }
   }
 
   /**
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
index c8f4fa6..b383fd7 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -219,6 +219,7 @@
         );
       }
       this.addEventListener('request-dependency', this.resolveDep);
+      this.addEventListener('reload', this.reload);
     }
 
     private removeTargetEventListeners() {
@@ -231,6 +232,7 @@
       }
       this.targetCleanups = [];
       this.removeEventListener('request-dependency', this.resolveDep);
+      this.removeEventListener('reload', this.reload);
     }
 
     /**
@@ -246,6 +248,10 @@
       }
     }
 
+    readonly reload = () => {
+      this.dispatchEventThroughTarget('reload');
+    };
+
     readonly mouseDebounceHide = (e: MouseEvent) => {
       this.debounceHide({mouseEvent: e});
     };
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 60e1a91..86c6ba2 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -226,7 +226,7 @@
     --tooltip-button-text-color: var(--gerrit-blue-dark);
     --negative-red-text-color: var(--red-600);
     --positive-green-text-color: var(--green-700);
-    --indirect-ancestor-text-color: var(--green-700);
+    --indirect-relation-text-color: var(--green-700);
 
     /* background colors */
     /* primary background colors */
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 2836247..ecae845 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -113,7 +113,7 @@
     --tooltip-button-text-color: var(--gerrit-blue-light);
     --negative-red-text-color: var(--red-200);
     --positive-green-text-color: var(--green-200);
-    --indirect-ancestor-text-color: var(--green-200);
+    --indirect-relation-text-color: var(--green-200);
 
     /* background colors */
     /* primary background colors */
@@ -294,7 +294,13 @@
   const styleEl = document.createElement('style');
   styleEl.setAttribute('id', 'dark-theme');
   safeStyleEl.setTextContent(styleEl, darkThemeCss);
-  document.head.appendChild(styleEl);
+
+  // We would like to insert the dark theme styles after the light theme such
+  // that the dark theme values override the defaults in the light theme. But
+  // OTOH we want to insert before any plugin provided styles, because we do NOT
+  // want to override those.
+  const pluginStyleEl = document.head.querySelector('style#plugin-style');
+  document.head.insertBefore(styleEl, pluginStyleEl);
 }
 
 export function removeTheme() {