Merge "Migrate ChangeModel to DI pattern."
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 5418555..ad110df 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2028,6 +2028,14 @@
 +
 Default is 1 hour.
 
+[[dashboard]]
+=== Section dashboard
+
+[[dashboard.submitRequirementColumns]]dashboard.submitRequirementColumns::
++
+The list of submit requirement names that should be displayed as separate
+columns in the dashboard.
+
 [[download]]
 === Section download
 
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index bd93b8b..86d7f58 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -2008,6 +2008,10 @@
 |`default_theme`           |optional|
 URL to a default Gerrit UI theme plugin, if available.
 Located in `/static/gerrit-theme.js` by default.
+|`submit_requirement_dashboard_columns` ||
+The list of submit requirement names that should be displayed as separate
+columns in the dashboard. If empty, the default is to display all submit
+requirements that are applicable for changes appearing in the dashboard.
 |=======================================
 
 [[sshd-info]]
diff --git a/java/com/google/gerrit/entities/KeyUtil.java b/java/com/google/gerrit/entities/KeyUtil.java
index 40fb757..be28689 100644
--- a/java/com/google/gerrit/entities/KeyUtil.java
+++ b/java/com/google/gerrit/entities/KeyUtil.java
@@ -48,12 +48,12 @@
     for (char i = 'a'; i <= 'f'; i++) hexb[i] = (byte) ((i - 'a') + 10);
   }
 
-  public static String encode(final String e) {
+  public static String encode(final String key) {
     final byte[] b;
     try {
-      b = e.getBytes("UTF-8");
-    } catch (UnsupportedEncodingException e1) {
-      throw new RuntimeException("No UTF-8 support", e1);
+      b = key.getBytes("UTF-8");
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException("No UTF-8 support", e);
     }
 
     final StringBuilder r = new StringBuilder(b.length);
@@ -71,20 +71,20 @@
     return r.toString();
   }
 
-  public static String decode(final String e) {
-    if (e.indexOf('%') < 0) {
-      return e.replace('+', ' ');
+  public static String decode(final String key) {
+    if (key.indexOf('%') < 0) {
+      return key.replace('+', ' ');
     }
 
-    final byte[] b = new byte[e.length()];
+    final byte[] b = new byte[key.length()];
     int bPtr = 0;
     try {
-      for (int i = 0; i < e.length(); ) {
-        final char c = e.charAt(i);
-        if (c == '%' && i + 2 < e.length()) {
-          final int v = (hexb[e.charAt(i + 1)] << 4) | hexb[e.charAt(i + 2)];
+      for (int i = 0; i < key.length(); ) {
+        final char c = key.charAt(i);
+        if (c == '%' && i + 2 < key.length()) {
+          final int v = (hexb[key.charAt(i + 1)] << 4) | hexb[key.charAt(i + 2)];
           if (v < 0) {
-            throw new IllegalArgumentException(e.substring(i, i + 3));
+            throw new IllegalArgumentException(key.substring(i, i + 3));
           }
           b[bPtr++] = (byte) v;
           i += 3;
@@ -97,12 +97,12 @@
         }
       }
     } catch (ArrayIndexOutOfBoundsException err) {
-      throw new IllegalArgumentException("Bad encoding" + e, err);
+      throw new IllegalArgumentException("Bad encoding" + key, err);
     }
     try {
       return new String(b, 0, bPtr, "UTF-8");
-    } catch (UnsupportedEncodingException e1) {
-      throw new RuntimeException("No UTF-8 support", e1);
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException("No UTF-8 support", e);
     }
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/ServerInfo.java b/java/com/google/gerrit/extensions/common/ServerInfo.java
index bc7fcfd..ce65240 100644
--- a/java/com/google/gerrit/extensions/common/ServerInfo.java
+++ b/java/com/google/gerrit/extensions/common/ServerInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.List;
+
 /** API response containing values from {@code gerrit.config} as nested objects. */
 public class ServerInfo {
   public AccountsInfo accounts;
@@ -28,4 +30,5 @@
   public UserConfigInfo user;
   public ReceiveInfo receive;
   public String defaultTheme;
+  public List<String> submitRequirementDashboardColumns;
 }
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index ae11d71..09052a6 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -66,8 +66,10 @@
 import com.google.inject.Inject;
 import java.nio.file.Files;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
@@ -153,6 +155,7 @@
 
     info.user = getUserInfo();
     info.receive = getReceiveInfo();
+    info.submitRequirementDashboardColumns = getSubmitRequirementDashboardColumns();
     return Response.ok(info);
   }
 
@@ -373,6 +376,10 @@
     return info;
   }
 
+  private List<String> getSubmitRequirementDashboardColumns() {
+    return Arrays.asList(config.getStringList("dashboard", null, "submitRequirementColumns"));
+  }
+
   private static Boolean toBoolean(boolean v) {
     return v ? v : null;
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 97288a8..8131352 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -192,6 +192,9 @@
 
     // user
     assertThat(i.user.anonymousCowardName).isEqualTo(AnonymousCowardNameProvider.DEFAULT);
+
+    // submit requirement columns in dashboard
+    assertThat(i.submitRequirementDashboardColumns).isEmpty();
   }
 
   @Test
@@ -202,6 +205,15 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "dashboard.submitRequirementColumns",
+      values = {"Code-Review", "Verified"})
+  public void serverConfigWithMultipleSubmitRequirementColumn() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+    assertThat(i.submitRequirementDashboardColumns).containsExactly("Code-Review", "Verified");
+  }
+
+  @Test
   @GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
   public void mergeabilityComputationBehavior_neverCompute() throws Exception {
     ServerInfo i = gApi.config().server().getInfo();
diff --git a/plugins/download-commands b/plugins/download-commands
index 7f73617..71331e1 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 7f736175df9c18e490d7d21719e35a978706ca6e
+Subproject commit 71331e15af5a62ee7b13dee6ebdadf23d7e75a40
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 1727ed9..6676576 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
@@ -162,12 +162,14 @@
   ParsedChangeInfo,
 } from '../../../types/types';
 import {
+  ChecksTabState,
   CloseFixPreviewEvent,
   EditableContentSaveEvent,
   EventType,
   OpenFixPreviewEvent,
   ShowAlertEventDetail,
   SwitchTabEvent,
+  SwitchTabEventDetail,
   TabState,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -896,7 +898,7 @@
         this._selectedTabPluginHeader = '';
       }
     }
-    this._tabState = e.detail.tabState;
+    if (e.detail.tabState) this._tabState = e.detail.tabState;
   }
 
   /**
@@ -1356,14 +1358,22 @@
     let primaryTab = PrimaryTab.FILES;
     if (params?.tab) {
       primaryTab = params?.tab as PrimaryTab;
-    } else if (params && 'commentId' in params) {
+    } else if (params?.commentId) {
       primaryTab = PrimaryTab.COMMENT_THREADS;
     }
+    const detail: SwitchTabEventDetail = {
+      tab: primaryTab,
+    };
+    if (primaryTab === PrimaryTab.CHECKS) {
+      const state: ChecksTabState = {};
+      detail.tabState = {checksTab: state};
+      if (params?.filter) state.filter = params?.filter;
+      if (params?.select) state.select = params?.select;
+      if (params?.attempt) state.attempt = params?.attempt;
+    }
     this._setActivePrimaryTab(
-      new CustomEvent('initActiveTab', {
-        detail: {
-          tab: primaryTab,
-        },
+      new CustomEvent(EventType.SHOW_PRIMARY_TAB, {
+        detail,
       })
     );
   }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index 13fb5b3..b4591a0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -310,13 +310,9 @@
     }
   </style>
   <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
-  <!-- TODO(taoalpha): remove on-show-checks-table,
-    Gerrit should not have any thing too special for a plugin,
-    replace with a generic event: show-primary-tab. -->
   <div
     id="mainContent"
     class="container"
-    on-show-checks-table="_setActivePrimaryTab"
     hidden$="{{_loading}}"
     aria-hidden="[[_changeViewAriaHidden]]"
   >
@@ -565,7 +561,7 @@
         <h3 class="assistive-tech-only">Comments</h3>
         <gr-thread-list
           threads="[[_commentThreads]]"
-          comment-tab-state="[[_tabState.commentTab]]"
+          comment-tab-state="[[_tabState]]"
           only-show-robot-comments-with-human-reply=""
           unresolved-only="[[unresolvedOnly]]"
           scroll-comment-id="[[scrollCommentId]]"
@@ -577,10 +573,7 @@
         if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
       >
         <h3 class="assistive-tech-only">Checks</h3>
-        <gr-checks-tab
-          id="checksTab"
-          tab-state="[[_tabState.checksTab]]"
-        ></gr-checks-tab>
+        <gr-checks-tab id="checksTab" tab-state="[[_tabState]]"></gr-checks-tab>
       </template>
       <template
         is="dom-if"
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 a7d28ce..b41147b 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
@@ -36,7 +36,7 @@
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {assertIsDefined} from '../../../utils/common-util';
-import {CommentTabState} from '../../../types/events';
+import {CommentTabState, TabState} from '../../../types/events';
 import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {css, html, LitElement, PropertyValues} from 'lit';
@@ -169,7 +169,7 @@
   hideDropdown = false;
 
   @property({type: Object, attribute: 'comment-tab-state'})
-  commentTabState?: CommentTabState;
+  commentTabState?: TabState;
 
   @property({type: String, attribute: 'scroll-comment-id'})
   scrollCommentId?: UrlEncodedCommentId;
@@ -222,7 +222,7 @@
   }
 
   private onCommentTabStateUpdate() {
-    switch (this.commentTabState) {
+    switch (this.commentTabState?.commentTab) {
       case CommentTabState.UNRESOLVED:
         this.handleOnlyUnresolved();
         break;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 5660692..e688c98 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -38,6 +38,7 @@
   ErrorMessages,
 } from '../../models/checks/checks-model';
 import {
+  clearAllFakeRuns,
   fakeActions,
   fakeLinks,
   fakeRun0,
@@ -46,6 +47,7 @@
   fakeRun3,
   fakeRun4Att,
   fakeRun5,
+  setAllFakeRuns,
 } from '../../models/checks/checks-fakes';
 import {assertIsDefined} from '../../utils/common-util';
 import {modifierPressed, whenVisible} from '../../utils/dom-util';
@@ -374,8 +376,12 @@
   @query('#filterInput')
   filterInput?: HTMLInputElement;
 
+  /**
+   * We prefer `undefined` over a RegExp with '', because `.source` yields
+   * a strange '(?:)' for ''.
+   */
   @state()
-  filterRegExp = new RegExp('');
+  filterRegExp?: RegExp;
 
   @property({attribute: false})
   runs: CheckRun[] = [];
@@ -535,7 +541,20 @@
 
   protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
+    // This update is done is response to setting this.filterRegExp below, but
+    // this.filterInput not yet being available at that point.
+    if (this.filterInput && !this.filterInput.value && this.filterRegExp) {
+      this.filterInput.value = this.filterRegExp.source;
+    }
     if (changedProperties.has('tabState') && this.tabState) {
+      // Note that tabState.select and tabState.attempt are processed by
+      // <gr-checks-tab>.
+      if (
+        this.tabState.filter &&
+        this.tabState.filter !== this.filterRegExp?.source
+      ) {
+        this.filterRegExp = new RegExp(this.tabState.filter, 'i');
+      }
       const {statusOrCategory} = this.tabState;
       if (
         statusOrCategory === RunStatus.RUNNING ||
@@ -684,109 +703,11 @@
 
   onInput() {
     assertIsDefined(this.filterInput, 'filter <input> element');
-    this.filterRegExp = new RegExp(this.filterInput.value, 'i');
-  }
-
-  none() {
-    this.getChecksModel().updateStateSetResults(
-      'f0',
-      [],
-      [],
-      [],
-      undefined,
-      ChecksPatchset.LATEST
-    );
-    this.getChecksModel().updateStateSetResults(
-      'f1',
-      [],
-      [],
-      [],
-      undefined,
-      ChecksPatchset.LATEST
-    );
-    this.getChecksModel().updateStateSetResults(
-      'f2',
-      [],
-      [],
-      [],
-      undefined,
-      ChecksPatchset.LATEST
-    );
-    this.getChecksModel().updateStateSetResults(
-      'f3',
-      [],
-      [],
-      [],
-      undefined,
-      ChecksPatchset.LATEST
-    );
-    this.getChecksModel().updateStateSetResults(
-      'f4',
-      [],
-      [],
-      [],
-      undefined,
-      ChecksPatchset.LATEST
-    );
-    this.getChecksModel().updateStateSetResults(
-      'f5',
-      [],
-      [],
-      [],
-      undefined,
-      ChecksPatchset.LATEST
-    );
-  }
-
-  all() {
-    this.getChecksModel().updateStateSetResults(
-      'f0',
-      [fakeRun0],
-      fakeActions,
-      fakeLinks,
-      'ETA: 1 min',
-      ChecksPatchset.LATEST
-    );
-    this.getChecksModel().updateStateSetResults(
-      'f1',
-      [fakeRun1],
-      [],
-      [],
-      undefined,
-      ChecksPatchset.LATEST
-    );
-    this.getChecksModel().updateStateSetResults(
-      'f2',
-      [fakeRun2],
-      [],
-      [],
-      undefined,
-      ChecksPatchset.LATEST
-    );
-    this.getChecksModel().updateStateSetResults(
-      'f3',
-      [fakeRun3],
-      [],
-      [],
-      undefined,
-      ChecksPatchset.LATEST
-    );
-    this.getChecksModel().updateStateSetResults(
-      'f4',
-      fakeRun4Att,
-      [],
-      [],
-      undefined,
-      ChecksPatchset.LATEST
-    );
-    this.getChecksModel().updateStateSetResults(
-      'f5',
-      [fakeRun5],
-      [],
-      [],
-      undefined,
-      ChecksPatchset.LATEST
-    );
+    if (this.filterInput.value) {
+      this.filterRegExp = new RegExp(this.filterInput.value, 'i');
+    } else {
+      this.filterRegExp = undefined;
+    }
   }
 
   toggle(
@@ -815,7 +736,7 @@
           r.status === status ||
           (status === RunStatus.RUNNING && r.status === RunStatus.SCHEDULED)
       )
-      .filter(r => this.filterRegExp.test(r.checkName))
+      .filter(r => !this.filterRegExp || this.filterRegExp.test(r.checkName))
       .sort(compareByWorstCategory);
     if (runs.length === 0) return;
     const expanded = this.isSectionExpanded.get(status) ?? true;
@@ -858,11 +779,7 @@
   }
 
   showFilter(): boolean {
-    const show = this.runs.length > 10;
-    if (!show && this.filterRegExp.source.length > 0) {
-      this.filterRegExp = new RegExp('');
-    }
-    return show;
+    return this.runs.length > 10 || !!this.filterRegExp;
   }
 
   renderFakeControls() {
@@ -870,7 +787,9 @@
     return html`
       <div class="testing">
         <div>Toggle fake runs by clicking buttons:</div>
-        <gr-button link @click="${this.none}">none</gr-button>
+        <gr-button link @click="${() => setAllFakeRuns(this.getChecksModel())}"
+          >none</gr-button
+        >
         <gr-button
           link
           @click="${() =>
@@ -898,7 +817,11 @@
         <gr-button link @click="${() => this.toggle('f5', [fakeRun5])}"
           >5</gr-button
         >
-        <gr-button link @click="${this.all}">all</gr-button>
+        <gr-button
+          link
+          @click="${() => clearAllFakeRuns(this.getChecksModel())}"
+          >all</gr-button
+        >
       </div>
     `;
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index 4d54200..e8f2929 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -14,13 +14,80 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../test/common-test-setup-karma';
+import './gr-checks-runs';
 import {GrChecksRuns} from './gr-checks-runs';
+import {html} from 'lit';
+import {fixture} from '@open-wc/testing-helpers';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {resolve} from '../../models/dependency';
 
 suite('gr-checks-runs test', () => {
-  test('is defined', () => {
-    const el = document.createElement('gr-checks-runs');
-    assert.instanceOf(el, GrChecksRuns);
+  let element: GrChecksRuns;
+
+  setup(async () => {
+    element = await fixture<GrChecksRuns>(
+      html`<gr-checks-runs></gr-checks-runs>`
+    );
+    const getChecksModel = resolve(element, checksModelToken);
+    setAllFakeRuns(getChecksModel());
+  });
+
+  test('tabState filter', async () => {
+    element.tabState = {filter: 'fff'};
+    await element.updateComplete;
+    assert.equal(element.filterRegExp?.source, 'fff');
+  });
+
+  test('renders', async () => {
+    await element.updateComplete;
+    assert.equal(element.runs.length, 44);
+    expect(element).shadowDom.to.equal(
+      `
+      <h2 class="title">
+        <div class="heading-2">Runs</div>
+        <div class="flex-space"></div>
+        <gr-tooltip-content has-tooltip="" title="Collapse runs panel">
+          <gr-button aria-checked="false" aria-label="Collapse runs panel"
+                     class="expandButton" link="" role="switch">
+            <iron-icon class="expandIcon" icon="gr-icons:chevron-left"></iron-icon>
+          </gr-button>
+        </gr-tooltip-content>
+      </h2>
+      <input id="filterInput" placeholder="Filter runs by regular expression" type="text">
+      <div class="expanded running">
+        <div class="sectionHeader">
+          <iron-icon class="expandIcon" icon="gr-icons:expand-less"></iron-icon>
+          <h3 class="heading-3">Running / Scheduled</h3>
+        </div>
+        <div class="sectionRuns">
+          <gr-checks-run></gr-checks-run>
+          <gr-checks-run></gr-checks-run>
+        </div>
+      </div>
+      <div class="completed expanded">
+        <div class="sectionHeader">
+          <iron-icon class="expandIcon" icon="gr-icons:expand-less"></iron-icon>
+          <h3 class="heading-3">Completed</h3>
+        </div>
+        <div class="sectionRuns">
+          <gr-checks-run></gr-checks-run>
+          <gr-checks-run></gr-checks-run>
+          <gr-checks-run></gr-checks-run>
+        </div>
+      </div>
+      <div class="expanded runnable">
+        <div class="sectionHeader">
+          <iron-icon class="expandIcon" icon="gr-icons:expand-less"></iron-icon>
+          <h3 class="heading-3">Not run</h3>
+        </div>
+        <div class="sectionRuns">
+          <gr-checks-run></gr-checks-run>
+        </div>
+      </div>
+    `,
+      {ignoreAttributes: ['tabindex', 'aria-disabled']}
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 49b7a70..551e71d 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -28,7 +28,7 @@
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
 import {ActionTriggeredEvent} from '../../models/checks/checks-util';
 import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
-import {ChecksTabState} from '../../types/events';
+import {TabState} from '../../types/events';
 import {getAppContext} from '../../services/app-context';
 import {subscribe} from '../lit/subscription-controller';
 import {Deduping} from '../../api/reporting';
@@ -48,7 +48,7 @@
   results: CheckResult[] = [];
 
   @property({type: Object})
-  tabState?: ChecksTabState;
+  tabState?: TabState;
 
   @state()
   checksPatchsetNumber: PatchSetNumber | undefined = undefined;
@@ -144,13 +144,13 @@
           .runs="${this.runs}"
           .selectedRuns="${this.selectedRuns}"
           .selectedAttempts="${this.selectedAttempts}"
-          .tabState="${this.tabState}"
+          .tabState="${this.tabState?.checksTab}"
           @run-selected="${this.handleRunSelected}"
           @attempt-selected="${this.handleAttemptSelected}"
         ></gr-checks-runs>
         <gr-checks-results
           class="results"
-          .tabState="${this.tabState}"
+          .tabState="${this.tabState?.checksTab}"
           .runs="${this.runs}"
           .selectedRuns="${this.selectedRuns}"
           .selectedAttempts="${this.selectedAttempts}"
@@ -162,11 +162,52 @@
 
   protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
-    if (changedProperties.has('tabState')) {
-      if (this.tabState) {
-        this.selectedRuns = [];
+    if (changedProperties.has('tabState')) this.applyTabState();
+    if (changedProperties.has('runs')) this.applyTabState();
+  }
+
+  /**
+   * Clearing the tabState means that from now on the user interaction counts,
+   * not the content of the URL (which is where tabState is populated from).
+   */
+  private clearTabState() {
+    this.tabState = {};
+  }
+
+  /**
+   * We want to keep applying the tabState to newly incoming check runs until
+   * the user explicitly interacts with the selection or the attempts, which
+   * will result in clearTabState() being called.
+   */
+  private applyTabState() {
+    if (!this.tabState?.checksTab) return;
+    // Note that .filter is processed by <gr-checks-runs>.
+    const {select, filter, attempt} = this.tabState?.checksTab;
+    if (!select) {
+      this.selectedRuns = [];
+      this.selectedAttempts = new Map<string, number>();
+      return;
+    }
+    const regexpSelect = new RegExp(select, 'i');
+    // We do not allow selection of runs that are invisible because of the
+    // filter.
+    const regexpFilter = new RegExp(filter ?? '', 'i');
+    const selectedRuns = this.runs.filter(
+      run =>
+        regexpSelect.test(run.checkName) && regexpFilter.test(run.checkName)
+    );
+    this.selectedRuns = selectedRuns.map(run => run.checkName);
+    const selectedAttempts = new Map<string, number>();
+    if (attempt) {
+      for (const run of selectedRuns) {
+        if (run.isSingleAttempt) continue;
+        const hasAttempt = run.attemptDetails.some(
+          detail => detail.attempt === attempt
+        );
+        if (hasAttempt) selectedAttempts.set(run.checkName, attempt);
       }
     }
+    this.selectedAttempts = selectedAttempts;
   }
 
   handleActionTriggered(action: Action, run?: CheckRun) {
@@ -174,6 +215,7 @@
   }
 
   handleRunSelected(e: RunSelectedEvent) {
+    this.clearTabState();
     if (e.detail.reset) {
       this.selectedRuns = [];
       this.selectedAttempts = new Map();
@@ -185,6 +227,7 @@
   }
 
   handleAttemptSelected(e: AttemptSelectedEvent) {
+    this.clearTabState();
     const {checkName, attempt} = e.detail;
     this.selectedAttempts.set(checkName, attempt);
     // Force property update.
@@ -192,6 +235,7 @@
   }
 
   toggleSelected(checkName: string) {
+    this.clearTabState();
     if (this.selectedRuns.includes(checkName)) {
       this.selectedRuns = this.selectedRuns.filter(r => r !== checkName);
       this.selectedAttempts.set(checkName, undefined);
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
index 85183ed..1ac553a 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
@@ -14,13 +14,52 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import '../../test/common-test-setup-karma';
+import {html} from 'lit';
+import './gr-checks-tab';
 import {GrChecksTab} from './gr-checks-tab';
+import {fixture} from '@open-wc/testing-helpers';
+import {checksModelToken} from '../../models/checks/checks-model';
+import {fakeRun4_3, setAllFakeRuns} from '../../models/checks/checks-fakes';
+import {resolve} from '../../models/dependency';
+import {Category} from '../../api/checks';
 
 suite('gr-checks-tab test', () => {
-  test('is defined', () => {
-    const el = document.createElement('gr-checks-tab');
-    assert.instanceOf(el, GrChecksTab);
+  let element: GrChecksTab;
+
+  setup(async () => {
+    element = await fixture<GrChecksTab>(html`<gr-checks-tab></gr-checks-tab>`);
+    const getChecksModel = resolve(element, checksModelToken);
+    setAllFakeRuns(getChecksModel());
+  });
+
+  test('renders', async () => {
+    await element.updateComplete;
+    assert.equal(element.runs.length, 44);
+    expect(element).shadowDom.to.equal(`
+      <div class="container">
+        <gr-checks-runs
+          class="runs"
+          collapsed=""
+        >
+        </gr-checks-runs>
+        <gr-checks-results class="results">
+        </gr-checks-results>
+      </div>
+    `);
+  });
+
+  test('select from tab state', async () => {
+    element.tabState = {
+      checksTab: {
+        statusOrCategory: Category.ERROR,
+        filter: 'elim',
+        select: 'fake',
+        attempt: 3,
+      },
+    };
+    await element.updateComplete;
+    assert.equal(element.selectedRuns.length, 39);
+    assert.equal(element.selectedAttempts.get(fakeRun4_3.checkName), 3);
   });
 });
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index cfc424d..f8f4787 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -186,6 +186,12 @@
   commentId?: UrlEncodedCommentId;
   forceReload?: boolean;
   tab?: string;
+  /** regular expression for filtering check runs */
+  filter?: string;
+  /** regular expression for selecting check runs */
+  select?: string;
+  /** selected attempt for selected check runs */
+  attempt?: number;
 }
 
 export interface GenerateUrlRepoViewParameters {
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 2a35494..5c0a2ea 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -1614,6 +1614,17 @@
 
     const tab = ctx.queryMap.get('tab');
     if (tab) params.tab = tab;
+    const filter = ctx.queryMap.get('filter');
+    if (filter) params.filter = filter;
+    const select = ctx.queryMap.get('select');
+    if (select) params.select = select;
+    const attempt = ctx.queryMap.get('attempt');
+    if (attempt) {
+      const attemptInt = parseInt(attempt);
+      if (!isNaN(attemptInt) && attemptInt > 0) {
+        params.attempt = attemptInt;
+      }
+    }
 
     this.reporting.setRepoName(params.project);
     this.reporting.setChangeId(changeNum);
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
index a7de155..148286e 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -1395,6 +1395,27 @@
           assert.isFalse(redirectStub.called);
           assert.isTrue(normalizeRangeStub.called);
         });
+
+        test('params', () => {
+          normalizeRangeStub.returns(false);
+          sinon.stub(element, '_generateUrl').returns('foo');
+          const ctx = makeParams(null, '');
+          ctx.queryMap.set('tab', 'checks');
+          ctx.queryMap.set('filter', 'fff');
+          ctx.queryMap.set('select', 'sss');
+          ctx.queryMap.set('attempt', '1');
+          assertDataToParams(ctx, '_handleChangeRoute', {
+            view: GerritView.CHANGE,
+            project: 'foo/bar',
+            changeNum: 1234,
+            basePatchNum: 4,
+            patchNum: 7,
+            attempt: 1,
+            filter: 'fff',
+            select: 'sss',
+            tab: 'checks',
+          });
+        });
       });
 
       suite('_handleDiffRoute', () => {
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 8ff7734..9c0ce9e 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -132,6 +132,12 @@
   commentId?: UrlEncodedCommentId;
   forceReload?: boolean;
   tab?: string;
+  /** regular expression for filtering check runs */
+  filter?: string;
+  /** regular expression for selecting check runs */
+  select?: string;
+  /** selected attempt for selected check runs */
+  attempt?: number;
 }
 
 export interface AppElementJustRegisteredParams {
diff --git a/polygerrit-ui/app/models/checks/checks-fakes.ts b/polygerrit-ui/app/models/checks/checks-fakes.ts
index 7838b9a..1002b21 100644
--- a/polygerrit-ui/app/models/checks/checks-fakes.ts
+++ b/polygerrit-ui/app/models/checks/checks-fakes.ts
@@ -22,7 +22,7 @@
   RunStatus,
   TagColor,
 } from '../../api/checks';
-import {CheckRun} from './checks-model';
+import {CheckRun, ChecksModel, ChecksPatchset} from './checks-model';
 
 // TODO(brohlfs): Eventually these fakes should be removed. But they have proven
 // to be super convenient for testing, debugging and demoing, so I would like to
@@ -427,3 +427,105 @@
   isLatestAttempt: true,
   attemptDetails: [],
 };
+
+export function clearAllFakeRuns(model: ChecksModel) {
+  model.updateStateSetResults(
+    'f0',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f1',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f2',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f3',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f4',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f5',
+    [],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+}
+
+export function setAllFakeRuns(model: ChecksModel) {
+  model.updateStateSetResults(
+    'f0',
+    [fakeRun0],
+    fakeActions,
+    fakeLinks,
+    'ETA: 1 min',
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f1',
+    [fakeRun1],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f2',
+    [fakeRun2],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f3',
+    [fakeRun3],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f4',
+    fakeRun4Att,
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+  model.updateStateSetResults(
+    'f5',
+    [fakeRun5],
+    [],
+    [],
+    undefined,
+    ChecksPatchset.LATEST
+  );
+}
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 4f24535..3a46e60 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -240,6 +240,12 @@
 export interface ChecksTabState {
   statusOrCategory?: RunStatus | Category;
   checkName?: string;
+  /** regular expression for filtering runs */
+  filter?: string;
+  /** regular expression for selecting runs */
+  select?: string;
+  /** selected attempt for selected runs */
+  attempt?: number;
 }
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 34f0bc1..6f1a27a 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -277,7 +277,10 @@
 ) {
   const observer = new IntersectionObserver(
     (entries: IntersectionObserverEntry[]) => {
-      check(entries.length === 1, 'Expected one intersection observer entry.');
+      check(
+        entries.length === 1,
+        `Expected 1 intersection observer entry, but got ${entries.length}.`
+      );
       const entry = entries[0];
       if (entry.isIntersecting) {
         observer.unobserve(entry.target);