Merge "Make gr-hovercard-run_test independent of `now` time"
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 0ac9903..a58c7bb 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
@@ -31,6 +31,9 @@
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
 import {createChangeUrl} from '../../../models/views/change';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+
+const GET_CHANGES_DEBOUNCE_INTERVAL_MS = 10;
 
 const LOOKUP_QUERY_PATTERNS: RegExp[] = [
   /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
@@ -141,12 +144,17 @@
         this.preferences = x;
         if (this.changesPerPage !== x.changes_per_page) {
           this.changesPerPage = x.changes_per_page;
-          this.viewStateChanged();
+          this.debouncedGetChanges();
         }
       }
     );
   }
 
+  override disconnectedCallback() {
+    this.getChangesTask?.flush();
+    super.disconnectedCallback();
+  }
+
   static override get styles() {
     return [
       sharedStyles,
@@ -275,12 +283,7 @@
   }
 
   reload() {
-    if (this.loading) return;
-    this.loading = true;
-    this.getChanges().then(changes => {
-      this.changes = changes || [];
-      this.loading = false;
-    });
+    if (!this.loading) this.debouncedGetChanges();
   }
 
   // private, but visible for testing
@@ -300,33 +303,44 @@
     // in an async so that attachment to the DOM can take place first.
     setTimeout(() => fireTitleChange(this, this.query));
 
-    return this.getChanges().then(changes => {
-      changes = changes || [];
-      if (this.query && changes.length === 1) {
-        for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
-          if (this.query.match(queryPattern)) {
-            // "Back"/"Forward" buttons work correctly only with replaceUrl()
-            this.getNavigation().replaceUrl(
-              createChangeUrl({change: changes[0]})
-            );
-            return;
-          }
-        }
-      }
-      this.changes = changes;
-      this.loading = false;
-    });
+    this.debouncedGetChanges(true);
   }
 
-  // private but used in test
-  getChanges() {
-    return this.restApiService.getChanges(
-      this.changesPerPage,
-      this.query,
-      this.offset
+  private getChangesTask?: DelayedTask;
+
+  private debouncedGetChanges(shouldSingleMatchRedirect = false) {
+    this.getChangesTask = debounce(
+      this.getChangesTask,
+      () => {
+        this.getChanges(shouldSingleMatchRedirect);
+      },
+      GET_CHANGES_DEBOUNCE_INTERVAL_MS
     );
   }
 
+  async getChanges(shouldSingleMatchRedirect = false) {
+    this.loading = true;
+    const changes =
+      (await this.restApiService.getChanges(
+        this.changesPerPage,
+        this.query,
+        this.offset
+      )) ?? [];
+    if (shouldSingleMatchRedirect && this.query && changes.length === 1) {
+      for (const queryPattern of LOOKUP_QUERY_PATTERNS) {
+        if (this.query.match(queryPattern)) {
+          // "Back"/"Forward" buttons work correctly only with replaceUrl()
+          this.getNavigation().replaceUrl(
+            createChangeUrl({change: changes[0]})
+          );
+          return;
+        }
+      }
+    }
+    this.changes = changes;
+    this.loading = false;
+  }
+
   // private but used in test
   limitFor(query: string, defaultLimit?: number) {
     if (defaultLimit === undefined) return 0;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index 860f8a3..b003b66 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -24,16 +24,22 @@
 import {fixture, html, waitUntil, assert} from '@open-wc/testing';
 import {GerritView} from '../../../services/router/router-model';
 import {testResolver} from '../../../test/common-test-setup';
-import {SinonStub} from 'sinon';
+import {SinonFakeTimers, SinonStub} from 'sinon';
+import {GrChangeList} from '../gr-change-list/gr-change-list';
+import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
+import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
 
 const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
 const COMMIT_HASH = '12345678';
 
 suite('gr-change-list-view tests', () => {
   let element: GrChangeListView;
+  let changes: ChangeInfo[] | undefined = [];
+  let clock: SinonFakeTimers;
 
   setup(async () => {
-    stubRestApi('getChanges').returns(Promise.resolve([]));
+    clock = sinon.useFakeTimers();
+    stubRestApi('getChanges').callsFake(() => Promise.resolve(changes));
     element = await fixture(html`<gr-change-list-view></gr-change-list-view>`);
     element.viewState = {
       view: GerritView.SEARCH,
@@ -68,32 +74,39 @@
   });
 
   suite('bulk actions', () => {
-    let getChangesStub: sinon.SinonStub;
     setup(async () => {
       stubFlags('isEnabled').returns(true);
-      getChangesStub = sinon.stub(element, 'getChanges');
-      getChangesStub.returns(Promise.resolve([createChange()]));
+      changes = [createChange()];
       element.loading = false;
       element.reload();
-      await waitUntil(() => element.loading === false);
-      element.requestUpdate();
+      clock.tick(100);
       await element.updateComplete;
+      await waitUntil(() => element.loading === false);
     });
 
     test('checkboxes remain checked after soft reload', async () => {
+      const changeListEl = queryAndAssert<GrChangeList>(
+        element,
+        'gr-change-list'
+      );
+      await changeListEl.updateComplete;
+      const changeListSectionEl = queryAndAssert<GrChangeListSection>(
+        changeListEl,
+        'gr-change-list-section'
+      );
+      await changeListSectionEl.updateComplete;
+      const changeListItemEl = queryAndAssert<GrChangeListItem>(
+        changeListSectionEl,
+        'gr-change-list-item'
+      );
+      await changeListItemEl.updateComplete;
       let checkbox = queryAndAssert<HTMLInputElement>(
-        query(
-          query(query(element, 'gr-change-list'), 'gr-change-list-section'),
-          'gr-change-list-item'
-        ),
+        changeListItemEl,
         '.selection > label > input'
       );
       checkbox.click();
       await waitUntil(() => checkbox.checked);
 
-      getChangesStub.restore();
-      getChangesStub.returns(Promise.resolve([[createChange()]]));
-
       element.reload();
       await element.updateComplete;
       checkbox = queryAndAssert<HTMLInputElement>(
@@ -288,9 +301,10 @@
 
     test('Searching for a change ID redirects to change', async () => {
       const change = {...createChange(), _number: 1 as NumericChangeId};
-      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
+      changes = [change];
 
       element.viewState = {view: GerritView.SEARCH, query: CHANGE_ID};
+      clock.tick(100);
       await element.updateComplete;
 
       assert.isTrue(replaceUrlStub.called);
@@ -299,9 +313,10 @@
 
     test('Searching for a change num redirects to change', async () => {
       const change = {...createChange(), _number: 1 as NumericChangeId};
-      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
+      changes = [change];
 
       element.viewState = {view: GerritView.SEARCH, query: '1'};
+      clock.tick(100);
       await element.updateComplete;
 
       assert.isTrue(replaceUrlStub.called);
@@ -310,9 +325,10 @@
 
     test('Commit hash redirects to change', async () => {
       const change = {...createChange(), _number: 1 as NumericChangeId};
-      sinon.stub(element, 'getChanges').returns(Promise.resolve([change]));
+      changes = [change];
 
       element.viewState = {view: GerritView.SEARCH, query: COMMIT_HASH};
+      clock.tick(100);
       await element.updateComplete;
 
       assert.isTrue(replaceUrlStub.called);
@@ -320,18 +336,20 @@
     });
 
     test('Searching for an invalid change ID searches', async () => {
-      sinon.stub(element, 'getChanges').returns(Promise.resolve([]));
+      changes = [];
 
       element.viewState = {view: GerritView.SEARCH, query: CHANGE_ID};
+      clock.tick(100);
       await element.updateComplete;
 
       assert.isFalse(replaceUrlStub.called);
     });
 
     test('Change ID with multiple search results searches', async () => {
-      sinon.stub(element, 'getChanges').returns(Promise.resolve(undefined));
+      changes = undefined;
 
       element.viewState = {view: GerritView.SEARCH, query: CHANGE_ID};
+      clock.tick(100);
       await element.updateComplete;
 
       assert.isFalse(replaceUrlStub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown.ts b/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown.ts
index 50f0602..c315603 100644
--- a/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-markdown/gr-markdown.ts
@@ -80,9 +80,9 @@
         /* Pre will preserve whitespace and line breaks but not wrap */
         white-space: pre;
       }
-      /* Code within a sentence needs display:inline to shrink and not take a
-         whole row */
-      p code {
+      /* Non-multiline code elements need display:inline to shrink and not take
+         a whole row */
+      :not(pre) > code {
         display: inline;
       }
       p {