Merge "Add filepath copy-paste button next to filename in Comments section"
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index 7f13731..3cbe546 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_PATTERN;
 import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_REGEX;
 
@@ -39,6 +40,7 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
@@ -169,7 +171,8 @@
       Pattern.compile("Assignee changed from: (.*) to: (.*)");
 
   private static final Pattern REMOVED_REVIEWER_PATTERN =
-      Pattern.compile("Removed (cc|reviewer) (.*)(\\.| with the following votes)");
+      Pattern.compile(
+          "Removed (cc|reviewer) (.*)(\\.| with the following votes:\n.*)", Pattern.DOTALL);
 
   private static final Pattern REMOVED_VOTE_PATTERN = Pattern.compile("Removed (.*) by (.*)");
 
@@ -185,12 +188,18 @@
 
   private static final Pattern ON_CODE_OWNER_ADD_REVIEWER_PATTERN =
       Pattern.compile("(.*) who was added as reviewer owns the following files");
+
+  private static final String CODE_OWNER_ADD_REVIEWER_TAG =
+      ChangeMessagesUtil.AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "code-owners:addReviewer";
+
   private static final String ON_CODE_OWNER_APPROVAL_REGEX = "code-owner approved by (.*):";
   private static final String ON_CODE_OWNER_OVERRIDE_REGEX =
       "code-owners submit requirement .* overridden by (.*)";
 
   private static final Pattern ON_CODE_OWNER_REVIEW_PATTERN =
       Pattern.compile(ON_CODE_OWNER_APPROVAL_REGEX + "|" + ON_CODE_OWNER_OVERRIDE_REGEX);
+  private static final Pattern ON_CODE_OWNER_POST_REVIEW_PATTERN =
+      Pattern.compile("Patch Set [0-9]+:[\\s\\S]*By (voting|removing)[\\s\\S]*");
 
   private static final Pattern REPLY_BY_REASON_PATTERN =
       Pattern.compile("(.*) replied on the change");
@@ -583,7 +592,7 @@
     }
     Matcher matcher = REMOVED_REVIEWER_PATTERN.matcher(originalChangeMessage);
 
-    if (matcher.find() && !ACCOUNT_TEMPLATE_PATTERN.matcher(matcher.group(2)).matches()) {
+    if (matcher.matches() && !ACCOUNT_TEMPLATE_PATTERN.matcher(matcher.group(2)).matches()) {
       // Since we do not use change messages for reviewer updates on UI, it does not matter what we
       // rewrite it to.
       return Optional.of(originalChangeMessage.substring(0, matcher.end(1)));
@@ -731,7 +740,11 @@
     if (Strings.isNullOrEmpty(originalMessage)) {
       return Optional.empty();
     }
-
+    Matcher onCodeOwnerPostReviewMatcher =
+        ON_CODE_OWNER_POST_REVIEW_PATTERN.matcher(originalMessage);
+    if (!onCodeOwnerPostReviewMatcher.matches()) {
+      return Optional.empty();
+    }
     Matcher onCodeOwnerReviewMatcher = ON_CODE_OWNER_REVIEW_PATTERN.matcher(originalMessage);
     while (onCodeOwnerReviewMatcher.find()) {
       String accountName =
@@ -823,7 +836,9 @@
     for (FooterLine fl : footerLines) {
       String footerKey = fl.getKey();
       String footerValue = fl.getValue();
-      if (footerKey.equalsIgnoreCase(FOOTER_ASSIGNEE.getName())) {
+      if (footerKey.equalsIgnoreCase(FOOTER_TAG.getName())) {
+        fixProgress.tag = footerValue;
+      } else if (footerKey.equalsIgnoreCase(FOOTER_ASSIGNEE.getName())) {
         Account.Id oldAssignee = fixProgress.assigneeId;
         FixIdentResult fixedAssignee = null;
         if (footerValue.equals("")) {
@@ -954,7 +969,8 @@
       fixedChangeMessage =
           fixCodeOwnersOnReviewChangeMessage(fixProgress.updateAuthorId, originalChangeMessage);
     }
-    if (!fixedChangeMessage.isPresent()) {
+    if (!fixedChangeMessage.isPresent()
+        && Objects.equals(fixProgress.tag, CODE_OWNER_ADD_REVIEWER_TAG)) {
       fixedChangeMessage =
           fixCodeOwnersOnAddReviewerChangeMessage(fixProgress, originalChangeMessage);
     }
@@ -1194,6 +1210,9 @@
     /** {@link RefNames#changeMetaRef} of the change that is being fixed. */
     final String changeMetaRef;
 
+    /** Tag at current commit update. */
+    String tag = null;
+
     /** Assignee at current commit update. */
     Account.Id assigneeId = null;
 
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index f316660..19c2bcf 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.notedb.ChangeNoteUtil.AttentionStatusInNoteDb;
@@ -1603,19 +1604,20 @@
     accountCache.put(duplicateReviewer);
     Change c = newChange();
     ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
-    ChangeUpdate addReviewerUpdate = newUpdate(c, changeOwner);
+    ChangeUpdate addReviewerUpdate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
     addReviewerUpdate.putReviewer(reviewer.id(), REVIEWER);
     addReviewerUpdate.commit();
-    ChangeUpdate invalidOnAddReviewerUpdate = newUpdate(c, changeOwner);
+    ChangeUpdate invalidOnAddReviewerUpdate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
     invalidOnAddReviewerUpdate.setChangeMessage(
         "Reviewer User who was added as reviewer owns the following files:\n"
             + "   * file1.java\n"
             + "   * file2.ts\n");
     commitsToFix.add(invalidOnAddReviewerUpdate.commit());
-    ChangeUpdate addOtherReviewerUpdate = newUpdate(c, changeOwner);
+    ChangeUpdate addOtherReviewerUpdate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
     addOtherReviewerUpdate.putReviewer(otherUserId, REVIEWER);
     addOtherReviewerUpdate.commit();
-    ChangeUpdate invalidOnAddReviewerMultipleReviewerUpdate = newUpdate(c, changeOwner);
+    ChangeUpdate invalidOnAddReviewerMultipleReviewerUpdate =
+        newCodeOwnerAddReviewerUpdate(c, changeOwner);
     invalidOnAddReviewerMultipleReviewerUpdate.setChangeMessage(
         "Reviewer User who was added as reviewer owns the following files:\n"
             + "   * file1.java\n"
@@ -1624,17 +1626,17 @@
             + "\nMissing Reviewer who was added as reviewer owns the following files:\n"
             + "   * file4.java\n");
     commitsToFix.add(invalidOnAddReviewerMultipleReviewerUpdate.commit());
-    ChangeUpdate addDuplicateReviewerUpdate = newUpdate(c, changeOwner);
+    ChangeUpdate addDuplicateReviewerUpdate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
     addDuplicateReviewerUpdate.putReviewer(duplicateReviewer.id(), REVIEWER);
     addDuplicateReviewerUpdate.commit();
     // Reviewer name resolves to multiple accounts in the same change
-    ChangeUpdate onAddReviewerUpdateWithDuplicate = newUpdate(c, changeOwner);
+    ChangeUpdate onAddReviewerUpdateWithDuplicate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
     onAddReviewerUpdateWithDuplicate.setChangeMessage(
         "Reviewer User who was added as reviewer owns the following files:\n"
             + "   * file6.java\n");
     commitsToFix.add(onAddReviewerUpdateWithDuplicate.commit());
 
-    ChangeUpdate validOnAddReviewerUpdate = newUpdate(c, changeOwner);
+    ChangeUpdate validOnAddReviewerUpdate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
     validOnAddReviewerUpdate.setChangeMessage(
         "Gerrit Account who was added as reviewer owns the following files:\n"
             + "   * file1.java\n"
@@ -2249,6 +2251,13 @@
         .collect(toImmutableList());
   }
 
+  protected ChangeUpdate newCodeOwnerAddReviewerUpdate(Change c, CurrentUser user)
+      throws Exception {
+    ChangeUpdate update = newUpdate(c, user, true);
+    update.setTag("autogenerated:gerrit:code-owners:addReviewer");
+    return update;
+  }
+
   private ImmutableList<String> commitHistoryDiff(BackfillResult result, Change.Id changeId) {
     return result.fixedRefDiff.get(RefNames.changeMetaRef(changeId)).stream()
         .map(CommitDiff::diff)
diff --git a/package.json b/package.json
index a47ba9f..ebfadc8 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
     "eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
     "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --browsers ChromeDev --no-single-run --test-files",
     "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --test-files",
+    "test:single:nocompile": "npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --test-files",
     "polylint": "npm run safe_bazelisk test //polygerrit-ui/app:polylint_test",
     "polylint:dev": "rm -rf ./polygerrit-ui/app/tmpl_out && npm run safe_bazelisk build //polygerrit-ui/app:template_test_tar && mkdir ./polygerrit-ui/app/tmpl_out && tar -xf bazel-bin/polygerrit-ui/app/template_test_tar.tar -C ./polygerrit-ui/app/tmpl_out"
   },
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 7b226e7..4956380 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -19,8 +19,7 @@
 import './gr-change-list.js';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {mockPromise, TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {mockPromise} from '../../../test/test-utils.js';
 import {YOUR_TURN} from '../../core/gr-navigation/gr-navigation.js';
 
 const basicFixture = fixtureFromElement('gr-change-list');
@@ -28,22 +27,6 @@
 suite('gr-change-list basic tests', () => {
   let element;
 
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j');
-    kb.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k');
-    kb.bindShortcut(Shortcut.OPEN_CHANGE, 'o');
-    kb.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
-    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
-    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
-    kb.bindShortcut(Shortcut.NEXT_PAGE, 'n');
-    kb.bindShortcut(Shortcut.NEXT_PAGE, 'p');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
   setup(() => {
     element = basicFixture.instantiate();
   });
@@ -495,11 +478,11 @@
         assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
             'Should navigate to /c/4/');
 
-        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        MockInteractions.keyUpOn(element, 82); // 'r'
         const change = element._changeForIndex(element.selectedIndex);
         assert.equal(change.reviewed, true,
             'Should mark change as reviewed');
-        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
+        MockInteractions.keyUpOn(element, 82); // 'r'
         assert.equal(change.reviewed, false,
             'Should mark change as unreviewed');
         promise.resolve();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 08ad2bd..ba62ec3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
+import '../../../styles/gr-font-styles';
 import '../../../styles/gr-change-metadata-shared-styles';
 import '../../../styles/gr-change-view-integration-shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index a37daaa..26d1277 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -20,6 +20,9 @@
   <style include="gr-change-metadata-shared-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: table;
@@ -94,7 +97,6 @@
       max-width: 285px;
     }
     .metadata-title {
-      font-weight: var(--font-weight-bold);
       color: var(--deemphasized-text-color);
       padding-left: var(--metadata-horizontal-padding);
     }
@@ -114,7 +116,7 @@
   </style>
   <gr-external-style id="externalStyle" name="change-metadata">
     <div class="metadata-header">
-      <h3 class="metadata-title">Change Info</h3>
+      <h3 class="metadata-title heading-3">Change Info</h3>
       <gr-button link="" class="show-all-button" on-click="_onShowAllClick"
         >[[_computeShowAllLabelText(_showAllSections)]]
         <iron-icon
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
index 414784c..725bb24 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
+import '../../../styles/gr-font-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-label-info/gr-label-info';
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
index d824d94..8161592 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: table;
@@ -104,7 +107,7 @@
       padding-left: 0;
     }
   </style>
-  <h3 class="metadata-title">Submit requirements</h3>
+  <h3 class="metadata-title heading-3">Submit requirements</h3>
   <template is="dom-repeat" items="[[_requirements]]">
     <gr-endpoint-decorator
       class="submit-requirement-endpoints"
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 3396e13..0400030 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
@@ -48,6 +48,7 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
@@ -563,6 +564,8 @@
 
   private readonly commentsService = appContext.commentsService;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   private replyDialogResizeObserver?: ResizeObserver;
 
   override keyboardShortcuts() {
@@ -2644,6 +2647,10 @@
       '#relatedChanges'
     );
   }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
+  }
 }
 
 declare global {
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 6668e15..74d3cfc 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
@@ -35,12 +35,7 @@
 import {EventType, PluginApi} from '../../../api/plugin';
 
 import 'lodash/lodash';
-import {
-  mockPromise,
-  stubRestApi,
-  TestKeyboardShortcutBinder,
-} from '../../../test/test-utils';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {mockPromise, stubRestApi} from '../../../test/test-utils';
 import {
   createAppElementChangeViewParams,
   createApproval,
@@ -87,6 +82,7 @@
 } from '../../../types/common';
 import {
   pressAndReleaseKeyOn,
+  keyUpOn,
   tap,
 } from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
@@ -111,25 +107,6 @@
     typeof GerritNav.navigateToChange
   >;
 
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.SEND_REPLY, 'ctrl+enter');
-    kb.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r');
-    kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
-    kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-    kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
-    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
-    kb.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
-    kb.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
-    kb.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-    kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
-    kb.bindShortcut(Shortcut.EDIT_TOPIC, 't');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
   const ROBOT_COMMENTS_LIMIT = 10;
 
   // TODO: should have a mock service to generate VALID fake data
@@ -682,7 +659,7 @@
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      pressAndReleaseKeyOn(element, 65, null, 'a');
+      keyUpOn(element, 65, null, 'a');
       await flush();
       assert.isFalse(element.$.replyOverlay.opened);
       assert.isTrue(loggedInErrorSpy.called);
@@ -715,7 +692,7 @@
 
       const openSpy = sinon.spy(element, '_openReplyDialog');
 
-      pressAndReleaseKeyOn(element, 65, null, 'a');
+      keyUpOn(element, 65, null, 'a');
       await flush();
       assert.isTrue(element.$.replyOverlay.opened);
       element.$.replyOverlay.close();
@@ -828,7 +805,7 @@
       const stub = sinon
         .stub(element.$.downloadOverlay, 'open')
         .returns(Promise.resolve());
-      pressAndReleaseKeyOn(element, 68, null, 'd');
+      keyUpOn(element, 68, null, 'd');
       assert.isTrue(stub.called);
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index bdc6a43..8aef3c0 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -44,7 +44,12 @@
 import {DiffViewMode} from '../../../constants/constants';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fireEvent} from '../../../utils/event-util';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  ShortcutSection,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -144,6 +149,8 @@
   @property({type: Object})
   revisionInfo?: RevisionInfo;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   setDiffViewMode(mode: DiffViewMode) {
     this.$.modeSelect.setMode(mode);
   }
@@ -217,4 +224,8 @@
     }
     return 'patchInfoOldPatchSet';
   }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
+  }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index b8c6cde..85e6f25 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -27,7 +27,6 @@
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {
-  TestKeyboardShortcutBinder,
   stubRestApi,
   spyRestApi,
   listenOnce,
@@ -35,7 +34,6 @@
   query,
 } from '../../../test/test-utils.js';
 import {EditPatchSetNum} from '../../../types/common.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {createCommentThreads} from '../../../utils/comment-util.js';
 import {
   createChange,
@@ -68,30 +66,6 @@
 
   let saveStub;
 
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
-    kb.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
-    kb.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
-    kb.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
-    kb.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-    kb.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-    kb.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
-    kb.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
-    kb.bindShortcut(Shortcut.NEW_COMMENT, 'c');
-    kb.bindShortcut(Shortcut.OPEN_LAST_FILE, '[');
-    kb.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']');
-    kb.bindShortcut(Shortcut.OPEN_FILE, 'o');
-    kb.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
-    kb.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-    kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-    kb.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
   suite('basic tests', () => {
     setup(async () => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
@@ -566,27 +540,27 @@
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
 
-        MockInteractions.keyUpOn(element, 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flush();
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[0]);
         assert.equal(element._expandedFiles.length, 1);
         assert.equal(element._expandedFiles[0].path, paths[0]);
 
-        MockInteractions.keyUpOn(element, 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flush();
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
 
         element.fileCursor.setCursorAtIndex(1);
-        MockInteractions.keyUpOn(element, 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flush();
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[1]);
         assert.equal(element._expandedFiles.length, 1);
         assert.equal(element._expandedFiles[0].path, paths[1]);
 
-        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
         flush();
         assert.equal(element.diffs.length, paths.length);
         assert.equal(element._expandedFiles.length, paths.length);
@@ -595,7 +569,7 @@
         }
         // since _expandedFilesChanged is stubbed
         element.filesExpanded = FilesExpandedState.ALL;
-        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
         flush();
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
@@ -610,12 +584,12 @@
         assert.equal(getNumReviewed(), 0);
 
         // Press the review key to toggle it (set the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        MockInteractions.keyUpOn(element, 82, null, 'r');
         flush();
         assert.equal(getNumReviewed(), 1);
 
         // Press the review key to toggle it (clear the flag).
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        MockInteractions.keyUpOn(element, 82, null, 'r');
         assert.equal(getNumReviewed(), 0);
       });
 
@@ -1574,7 +1548,7 @@
     });
 
     test('cursor with individually opened files', async () => {
-      MockInteractions.keyUpOn(element, 73, null, 'i');
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
       await flush();
       let diffs = await renderAndGetNewDiffs(0);
       const diffStops = diffs[0].getCursorStops();
@@ -1601,7 +1575,7 @@
       // The file cursor is now at 1.
       assert.equal(element.fileCursor.index, 1);
 
-      MockInteractions.keyUpOn(element, 73, null, 'i');
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
       await flush();
       diffs = await renderAndGetNewDiffs(1);
 
@@ -1616,7 +1590,7 @@
     });
 
     test('cursor with toggle all files', async () => {
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+      MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
       await flush();
 
       const diffs = await renderAndGetNewDiffs(0);
@@ -1663,7 +1637,7 @@
       });
 
       test('n key with some files expanded and no shift key', async () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
         await flush();
 
         // Handle N key should return before calling diff cursor functions.
@@ -1677,7 +1651,7 @@
       });
 
       test('n key with some files expanded and shift key', async () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
         await flush();
         assert.equal(nextChunkStub.callCount, 0);
 
@@ -1691,7 +1665,7 @@
       });
 
       test('n key without all files expanded and shift key', async () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
         await flush();
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
@@ -1704,7 +1678,7 @@
       });
 
       test('n key without all files expanded and no shift key', async () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
         await flush();
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
@@ -1759,13 +1733,13 @@
         const saveReviewStub = sinon.stub(element, '_saveReviewedState');
 
         element.editMode = false;
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        MockInteractions.keyUpOn(element, 82, null, 'r');
         assert.isTrue(saveReviewStub.calledOnce);
 
         element.editMode = true;
         await flush();
 
-        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        MockInteractions.keyUpOn(element, 82, null, 'r');
         assert.isTrue(saveReviewStub.calledOnce);
       });
 
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index fdd7c79..cd79bba 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -257,6 +257,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   scrollToMessage(messageID: string) {
     const selector = `[data-message-id="${messageID}"]`;
     const el = this.shadowRoot!.querySelector(selector) as
@@ -384,13 +386,13 @@
 
   _computeExpandAllTitle(_expandAllState?: string) {
     if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
-      return this.createTitle(
+      return this.shortcuts.createTitle(
         Shortcut.COLLAPSE_ALL_MESSAGES,
         ShortcutSection.ACTIONS
       );
     }
     if (_expandAllState === ExpandAllState.EXPAND_ALL) {
-      return this.createTitle(
+      return this.shortcuts.createTitle(
         Shortcut.EXPAND_ALL_MESSAGES,
         ShortcutSection.ACTIONS
       );
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 74f20f2..bce4024 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -40,6 +40,7 @@
   isChangeInfo,
 } from '../../../utils/change-util';
 import {Interaction} from '../../../constants/reporting';
+import {fontStyles} from '../../../styles/gr-font-styles';
 
 /** What is the maximum number of shown changes in collapsed list? */
 const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
@@ -686,9 +687,9 @@
   static override get styles() {
     return [
       sharedStyles,
+      fontStyles,
       css`
         .title {
-          font-weight: var(--font-weight-bold);
           color: var(--deemphasized-text-color);
           padding-left: var(--metadata-horizontal-padding);
         }
@@ -720,7 +721,7 @@
   }
 
   override render() {
-    const title = html`<h4 class="title">${this.title}</h4>`;
+    const title = html`<h3 class="title heading-3">${this.title}</h3>`;
 
     const collapsible = this.length > this.numChangesWhenCollapsed;
     this.collapsed = !this.showAll && collapsible;
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
index 192a812..b7b4d9c 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_html.ts
@@ -51,13 +51,13 @@
     div.section {
       margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
       display: flex;
+      align-items: center;
     }
     div.sectionIcon {
       flex: 0 0 30px;
     }
     div.sectionIcon iron-icon {
       position: relative;
-      top: 2px;
       width: 20px;
       height: 20px;
     }
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index fd85117..f4af06b 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -60,7 +60,6 @@
       fontStyles,
       css`
         .metadata-title {
-          font-weight: var(--font-weight-bold);
           color: var(--deemphasized-text-color);
           padding-left: var(--metadata-horizontal-padding);
           margin: 0 0 var(--spacing-s);
@@ -128,12 +127,12 @@
     const submit_requirements = (this.change?.submit_requirements ?? []).filter(
       req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
     );
-    return html` <h2
+    return html` <h3
         class="metadata-title heading-3"
         id="submit-requirements-caption"
       >
         Submit Requirements
-      </h2>
+      </h3>
       <table class="requirements" aria-labelledby="submit-requirements-caption">
         <thead hidden>
           <tr>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 0b191f1..541d877 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -27,6 +27,7 @@
   SectionView,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {property, customElement} from '@polymer/decorators';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -60,13 +61,14 @@
   @property({type: Array})
   _right?: SectionShortcut[];
 
-  private keyboardShortcutDirectoryListener: ShortcutListener;
+  private readonly shortcutListener: ShortcutListener;
+
+  private readonly shortcuts = appContext.shortcutsService;
 
   constructor() {
     super();
-    this.keyboardShortcutDirectoryListener = (
-      d?: Map<ShortcutSection, SectionView>
-    ) => this._onDirectoryUpdated(d);
+    this.shortcutListener = (d?: Map<ShortcutSection, SectionView>) =>
+      this._onDirectoryUpdated(d);
   }
 
   override ready() {
@@ -76,15 +78,11 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this.addKeyboardShortcutDirectoryListener(
-      this.keyboardShortcutDirectoryListener
-    );
+    this.shortcuts.addListener(this.shortcutListener);
   }
 
   override disconnectedCallback() {
-    this.removeKeyboardShortcutDirectoryListener(
-      this.keyboardShortcutDirectoryListener
-    );
+    this.shortcuts.removeListener(this.shortcutListener);
     super.disconnectedCallback();
   }
 
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index b5b0124..b6d0579 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -19,12 +19,7 @@
 import './gr-search-bar';
 import '../../../scripts/util';
 import {GrSearchBar} from './gr-search-bar';
-import {
-  TestKeyboardShortcutBinder,
-  stubRestApi,
-  mockPromise,
-} from '../../../test/test-utils';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {stubRestApi, mockPromise} from '../../../test/test-utils';
 import {_testOnly_clearDocsBaseUrlCache} from '../../../utils/url-util';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {
@@ -39,15 +34,6 @@
 suite('gr-search-bar tests', () => {
   let element: GrSearchBar;
 
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.SEARCH, '/');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
   setup(async () => {
     element = basicFixture.instantiate();
     await flush();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
index 5e81871..79e7dbc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
@@ -134,7 +134,7 @@
 
       if (node instanceof Text) {
         this._annotateText(node, offset, subLength, cssClass);
-      } else if (node instanceof HTMLElement) {
+      } else if (node instanceof Element) {
         this.annotateElement(node, offset, subLength, cssClass);
       }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index bb5ce94..978d98f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -37,6 +37,7 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   GeneratedWebLink,
@@ -341,6 +342,8 @@
 
   private readonly commentsService = appContext.commentsService;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   _throttledToggleFileReviewed?: EventListener;
 
   _onRenderHandler?: EventListener;
@@ -1898,6 +1901,10 @@
   _computeTruncatedPath(path?: string) {
     return path ? computeTruncatedPath(path) : '';
   }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index a3de30a..9aca719 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -19,8 +19,7 @@
 import './gr-diff-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {ChangeStatus} from '../../../constants/constants.js';
-import {TestKeyboardShortcutBinder, stubRestApi} from '../../../test/test-utils.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {stubRestApi} from '../../../test/test-utils.js';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
 import {GerritView} from '../../../services/router/router-model.js';
 import {
@@ -41,42 +40,6 @@
     let clock;
     let diffCommentsStub;
 
-    suiteSetup(() => {
-      const kb = TestKeyboardShortcutBinder.push();
-      kb.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
-      kb.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
-      kb.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
-      kb.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
-      kb.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-      kb.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-      kb.bindShortcut(Shortcut.NEW_COMMENT, 'c');
-      kb.bindShortcut(Shortcut.SAVE_COMMENT, 'ctrl+s');
-      kb.bindShortcut(Shortcut.NEXT_FILE, ']');
-      kb.bindShortcut(Shortcut.PREV_FILE, '[');
-      kb.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
-      kb.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-      kb.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-      kb.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-      kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
-      kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-      kb.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-      kb.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
-      kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
-      kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
-      kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-      kb.bindShortcut(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, 'shift+x');
-      kb.bindShortcut(Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
-      kb.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
-      kb.bindShortcut(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
-      kb.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-      kb.bindShortcut(Shortcut.TOGGLE_BLAME, 'b');
-      kb.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
-    });
-
-    suiteTeardown(() => {
-      TestKeyboardShortcutBinder.pop();
-    });
-
     const PARENT = 'PARENT';
 
     function getFilesFromFileList(fileList) {
@@ -504,16 +467,16 @@
       sinon.stub(element, '_setReviewed');
       sinon.spy(element, '_handleToggleFileReviewed');
       element.$.reviewed.checked = false;
-      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      MockInteractions.keyUpOn(element, 82, 'shift', 'r');
       assert.isFalse(element._setReviewed.called);
       assert.isTrue(element._handleToggleFileReviewed.calledOnce);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      MockInteractions.keyUpOn(element, 82, null, 'r');
       assert.isTrue(element._handleToggleFileReviewed.calledOnce);
 
       clock.tick(1000);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      MockInteractions.keyUpOn(element, 82, null, 'r');
       assert.isTrue(element._handleToggleFileReviewed.calledTwice);
       assert.isTrue(element._setReviewed.called);
       assert.equal(element._setReviewed.lastCall.args[0], true);
@@ -682,7 +645,7 @@
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      MockInteractions.keyUpOn(element, 65, null, 'a');
       await flush();
       assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
         'should only work when the user is logged in.');
@@ -708,7 +671,7 @@
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+      MockInteractions.keyUpOn(element, 65, null, 'a');
       await flush();
       assert.isTrue(element.changeViewState.showReplyDialog);
       assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
@@ -734,7 +697,7 @@
           sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
           const loggedInErrorSpy = sinon.spy();
           element.addEventListener('show-auth-required', loggedInErrorSpy);
-          MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+          MockInteractions.keyUpOn(element, 65, null, 'a');
           await flush();
           assert.isTrue(element.changeViewState.showReplyDialog);
           assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
@@ -798,7 +761,7 @@
       'Should navigate to /c/42/5..10');
 
       assert.isUndefined(element.changeViewState.showDownloadDialog);
-      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+      MockInteractions.keyUpOn(element, 68, null, 'd');
       assert.isTrue(element.changeViewState.showDownloadDialog);
     });
 
@@ -1732,7 +1695,7 @@
       test('toggle blame with shortcut', () => {
         const toggleBlame = sinon.stub(
             element.$.diffHost, 'loadBlame').callsFake(() => Promise.resolve());
-        MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
+        MockInteractions.keyUpOn(element, 66, null, 'b');
         assert.isTrue(toggleBlame.calledOnce);
       });
     });
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index b6fe60b..3bbcd5b 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -43,7 +43,6 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
-  SPECIAL_SHORTCUT,
 } from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GerritNav} from './core/gr-navigation/gr-navigation';
 import {appContext} from '../services/app-context';
@@ -232,7 +231,6 @@
     // model changes and updates the config model, but at the moment the service
     // is not called from anywhere.
     appContext.configService;
-    this._bindKeyboardShortcuts();
     document.addEventListener(EventType.PAGE_ERROR, e => {
       this._handlePageError(e);
     });
@@ -307,159 +305,6 @@
     };
   }
 
-  _bindKeyboardShortcuts() {
-    this.bindShortcut(
-      Shortcut.SEND_REPLY,
-      SPECIAL_SHORTCUT.DOC_ONLY,
-      'ctrl+enter',
-      'meta+enter'
-    );
-    this.bindShortcut(Shortcut.EMOJI_DROPDOWN, SPECIAL_SHORTCUT.DOC_ONLY, ':');
-
-    this.bindShortcut(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
-    this.bindShortcut(
-      Shortcut.GO_TO_USER_DASHBOARD,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'i'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_OPENED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'o'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_MERGED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'm'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_ABANDONED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'a'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_WATCHED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'w'
-    );
-
-    this.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j');
-    this.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k');
-    this.bindShortcut(Shortcut.OPEN_CHANGE, 'o');
-    this.bindShortcut(Shortcut.NEXT_PAGE, 'n', ']');
-    this.bindShortcut(Shortcut.PREV_PAGE, 'p', '[');
-    this.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
-    this.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's:keydown');
-    this.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
-    this.bindShortcut(Shortcut.EDIT_TOPIC, 't');
-    this.bindShortcut(Shortcut.OPEN_SUBMIT_DIALOG, 'shift+s');
-    this.bindShortcut(Shortcut.TOGGLE_ATTENTION_SET, 'shift+t');
-
-    this.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a:keyup');
-    this.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup');
-    this.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
-    this.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-    this.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
-    this.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
-    this.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
-    this.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
-    this.bindShortcut(
-      Shortcut.DIFF_AGAINST_BASE,
-      SPECIAL_SHORTCUT.V_KEY,
-      'down',
-      's'
-    );
-    // this keyboard shortcut is used in toast _displayDiffAgainstLatestToast
-    // in gr-diff-view. Any updates here should be reflected there
-    this.bindShortcut(
-      Shortcut.DIFF_AGAINST_LATEST,
-      SPECIAL_SHORTCUT.V_KEY,
-      'up',
-      'w'
-    );
-    // this keyboard shortcut is used in toast _displayDiffBaseAgainstLeftToast
-    // in gr-diff-view. Any updates here should be reflected there
-    this.bindShortcut(
-      Shortcut.DIFF_BASE_AGAINST_LEFT,
-      SPECIAL_SHORTCUT.V_KEY,
-      'left',
-      'a'
-    );
-    this.bindShortcut(
-      Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-      SPECIAL_SHORTCUT.V_KEY,
-      'right',
-      'd'
-    );
-    this.bindShortcut(
-      Shortcut.DIFF_BASE_AGAINST_LATEST,
-      SPECIAL_SHORTCUT.V_KEY,
-      'b'
-    );
-
-    this.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
-    this.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
-    if (this._isCursorManagerSupportMoveToVisibleLine()) {
-      this.bindShortcut(Shortcut.VISIBLE_LINE, '.');
-    }
-    this.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
-    this.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-    this.bindShortcut(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, 'shift+x');
-    this.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-    this.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-    this.bindShortcut(
-      Shortcut.EXPAND_ALL_COMMENT_THREADS,
-      SPECIAL_SHORTCUT.DOC_ONLY,
-      'e'
-    );
-    this.bindShortcut(
-      Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-      SPECIAL_SHORTCUT.DOC_ONLY,
-      'shift+e'
-    );
-    this.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
-    this.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
-    this.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-    this.bindShortcut(Shortcut.NEW_COMMENT, 'c');
-    this.bindShortcut(
-      Shortcut.SAVE_COMMENT,
-      'ctrl+enter',
-      'meta+enter',
-      'ctrl+s',
-      'meta+s'
-    );
-    this.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
-    this.bindShortcut(Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
-
-    this.bindShortcut(Shortcut.NEXT_FILE, ']');
-    this.bindShortcut(Shortcut.PREV_FILE, '[');
-    this.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-    this.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-    this.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-    this.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-    this.bindShortcut(Shortcut.OPEN_FILE, 'o', 'enter');
-    this.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
-    this.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-    this.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i');
-    this.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i');
-    this.bindShortcut(Shortcut.TOGGLE_BLAME, 'b:keyup');
-    this.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
-    this.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
-
-    this.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']');
-    this.bindShortcut(Shortcut.OPEN_LAST_FILE, '[');
-
-    this.bindShortcut(Shortcut.SEARCH, '/');
-  }
-
-  _isCursorManagerSupportMoveToVisibleLine() {
-    // This method is a copy-paste from the
-    // method _isIntersectionObserverSupported of gr-cursor-manager.js
-    // It is better share this method with gr-cursor-manager,
-    // but doing it require a lot if changes instead of 1-line copied code
-    return 'IntersectionObserver' in window;
-  }
-
   _accountChanged(account?: AccountDetailInfo) {
     if (!account) return;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 5069ba4..a23621e 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -21,6 +21,11 @@
 import {customElement, property} from '@polymer/decorators';
 import {ChangeInfo} from '../../../types/common';
 import {fireAlert} from '../../../utils/event-util';
+import {
+  Shortcut,
+  ShortcutSection,
+} from '../../../services/shortcuts/shortcuts-config';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -48,6 +53,8 @@
   @property({type: Object, notify: true})
   change?: ChangeInfo;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   _computeStarClass(starred?: boolean) {
     return starred ? 'active' : '';
   }
@@ -83,4 +90,8 @@
       })
     );
   }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 9dce127..9f65dd4 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -179,9 +179,6 @@
   private async getVisibleEntries(
     filter?: (el: Element) => boolean
   ): Promise<IntersectionObserverEntry[]> {
-    if (!this._isIntersectionObserverSupported()) {
-      throw new Error('Intersection observing not supported');
-    }
     if (!this.stops) {
       return [];
     }
@@ -218,14 +215,6 @@
     });
   }
 
-  _isIntersectionObserverSupported() {
-    // The copy of this method exists in gr-app-element.js under the
-    // name _isCursorManagerSupportMoveToVisibleLine
-    // If you update this method, you must update gr-app-element.js
-    // as well.
-    return 'IntersectionObserver' in window;
-  }
-
   /**
    * Set the cursor to an arbitrary stop - if the given element is not one of
    * the stops, unset the cursor.
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index dba36a4..67f84a5 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -24,23 +24,26 @@
 import '../gr-label/gr-label';
 import '../gr-tooltip-content/gr-tooltip-content';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-label-info_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {
-  ChangeInfo,
   AccountInfo,
   LabelInfo,
   ApprovalInfo,
   AccountId,
   isQuickLabelInfo,
   isDetailedLabelInfo,
+  LabelNameToInfoMap,
 } from '../../../types/common';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {GrButton} from '../gr-button/gr-button';
 import {getVotingRangeOrDefault} from '../../../utils/label-util';
 import {appContext} from '../../../services/app-context';
 import {ParsedChangeInfo} from '../../../types/types';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {votingStyles} from '../../../styles/gr-voting-styles';
+import {ifDefined} from 'lit/directives/if-defined';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -57,16 +60,12 @@
 
 interface FormattedLabel {
   className?: LabelClassName;
-  account: ApprovalInfo;
+  account: ApprovalInfo | AccountInfo;
   value: string;
 }
 
 @customElement('gr-label-info')
-export class GrLabelInfo extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrLabelInfo extends LitElement {
   @property({type: Object})
   labelInfo?: LabelInfo;
 
@@ -89,11 +88,148 @@
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      votingStyles,
+      css`
+        .placeholder {
+          color: var(--deemphasized-text-color);
+        }
+        .hidden {
+          display: none;
+        }
+        /* Note that most of the .voteChip styles are coming from the
+         gr-voting-styles include. */
+        .voteChip {
+          display: flex;
+          justify-content: center;
+          margin-right: var(--spacing-s);
+          padding: 1px;
+        }
+        .max {
+          background-color: var(--vote-color-approved);
+        }
+        .min {
+          background-color: var(--vote-color-rejected);
+        }
+        .positive {
+          background-color: var(--vote-color-recommended);
+          border-radius: 12px;
+          border: 1px solid var(--vote-outline-recommended);
+          color: var(--chip-color);
+        }
+        .negative {
+          background-color: var(--vote-color-disliked);
+          border-radius: 12px;
+          border: 1px solid var(--vote-outline-disliked);
+          color: var(--chip-color);
+        }
+        .hidden {
+          display: none;
+        }
+        td {
+          vertical-align: top;
+        }
+        tr {
+          min-height: var(--line-height-normal);
+        }
+        gr-tooltip-content {
+          display: block;
+        }
+        gr-button {
+          vertical-align: top;
+        }
+        gr-button::part(paper-button) {
+          height: var(--line-height-normal);
+          width: var(--line-height-normal);
+          padding: 0;
+        }
+        gr-button[disabled] iron-icon {
+          color: var(--border-color);
+        }
+        gr-account-link {
+          --account-max-length: 100px;
+          margin-right: var(--spacing-xs);
+        }
+        iron-icon {
+          height: calc(var(--line-height-normal) - 2px);
+          width: calc(var(--line-height-normal) - 2px);
+        }
+        .labelValueContainer:not(:first-of-type) td {
+          padding-top: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` <p
+        class="placeholder ${this.computeShowPlaceholder(
+          this.labelInfo,
+          this.change?.labels
+        )}"
+      >
+        No votes
+      </p>
+      <table>
+        ${this.mapLabelInfo(
+          this.labelInfo,
+          this.account,
+          this.change?.labels
+        ).map(mappedLabel => this.renderLabel(mappedLabel))}
+      </table>`;
+  }
+
+  renderLabel(mappedLabel: FormattedLabel) {
+    const {labelInfo, change} = this;
+    return html` <tr class="labelValueContainer">
+      <td>
+        <gr-tooltip-content
+          has-tooltip
+          title="${this._computeValueTooltip(labelInfo, mappedLabel.value)}"
+        >
+          <gr-label class="${mappedLabel.className} voteChip font-small">
+            ${mappedLabel.value}
+          </gr-label>
+        </gr-tooltip-content>
+      </td>
+      <td>
+        <gr-account-link
+          .account="${mappedLabel.account}"
+          .change="${change}"
+        ></gr-account-link>
+      </td>
+      <td>
+        <gr-tooltip-content has-tooltip title="Remove vote">
+          <gr-button
+            link
+            aria-label="Remove vote"
+            @click="${this.onDeleteVote}"
+            data-account-id="${ifDefined(mappedLabel.account._account_id)}"
+            class="deleteBtn ${this.computeDeleteClass(
+              mappedLabel.account,
+              this.mutable,
+              change
+            )}"
+          >
+            <iron-icon icon="gr-icons:delete"></iron-icon>
+          </gr-button>
+        </gr-tooltip-content>
+      </td>
+    </tr>`;
+  }
+
   /**
    * This method also listens on change.labels.*,
    * to trigger computation when a label is removed from the change.
    */
-  _mapLabelInfo(labelInfo?: LabelInfo, account?: AccountInfo) {
+  private mapLabelInfo(
+    labelInfo?: LabelInfo,
+    account?: AccountInfo,
+    _?: LabelNameToInfoMap
+  ): FormattedLabel[] {
     const result: FormattedLabel[] = [];
     if (!labelInfo) {
       return result;
@@ -108,7 +244,8 @@
           {
             value: ok ? '👍️' : '👎️',
             className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
-            account: ok ? labelInfo.approved : labelInfo.rejected,
+            // executed only if approved or rejected is not undefined
+            account: ok ? labelInfo.approved! : labelInfo.rejected!,
           },
         ];
       }
@@ -143,7 +280,7 @@
             labelClassName = LabelClassName.NEGATIVE;
           }
         }
-        const formattedLabel = {
+        const formattedLabel: FormattedLabel = {
           value: `${labelValPrefix}${label.value}`,
           className: labelClassName,
           account: label,
@@ -167,10 +304,10 @@
    * @param reviewer An object describing the reviewer that left the
    *     vote.
    */
-  _computeDeleteClass(
+  private computeDeleteClass(
     reviewer: ApprovalInfo,
     mutable: boolean,
-    change: ChangeInfo
+    change?: ParsedChangeInfo
   ) {
     if (!mutable || !change || !change.removable_reviewers) {
       return 'hidden';
@@ -186,7 +323,7 @@
    * Closure annotation for Polymer.prototype.splice is off.
    * For now, suppressing annotations.
    */
-  _onDeleteVote(e: MouseEvent) {
+  private onDeleteVote(e: MouseEvent) {
     if (!this.change) return;
 
     e.preventDefault();
@@ -220,7 +357,7 @@
       });
   }
 
-  _computeValueTooltip(labelInfo: LabelInfo, score: string) {
+  _computeValueTooltip(labelInfo: LabelInfo | undefined, score: string) {
     if (
       !labelInfo ||
       !isDetailedLabelInfo(labelInfo) ||
@@ -235,7 +372,10 @@
    * This method also listens change.labels.* in
    * order to trigger computation when a label is removed from the change.
    */
-  _computeShowPlaceholder(labelInfo?: LabelInfo) {
+  private computeShowPlaceholder(
+    labelInfo?: LabelInfo,
+    _?: LabelNameToInfoMap
+  ) {
     if (!labelInfo) {
       return '';
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
deleted file mode 100644
index 1186ee1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-font-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-voting-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    .placeholder {
-      color: var(--deemphasized-text-color);
-    }
-    .hidden {
-      display: none;
-    }
-    /* Note that most of the .voteChip styles are coming from the
-       gr-voting-styles include. */
-    .voteChip {
-      display: flex;
-      justify-content: center;
-      margin-right: var(--spacing-s);
-      padding: 1px;
-    }
-    .max {
-      background-color: var(--vote-color-approved);
-    }
-    .min {
-      background-color: var(--vote-color-rejected);
-    }
-    .positive {
-      background-color: var(--vote-color-recommended);
-      border-radius: 12px;
-      border: 1px solid var(--vote-outline-recommended);
-      color: var(--chip-color);
-    }
-    .negative {
-      background-color: var(--vote-color-disliked);
-      border-radius: 12px;
-      border: 1px solid var(--vote-outline-disliked);
-      color: var(--chip-color);
-    }
-    .hidden {
-      display: none;
-    }
-    td {
-      vertical-align: top;
-    }
-    tr {
-      min-height: var(--line-height-normal);
-    }
-    gr-tooltip-content {
-      display: block;
-    }
-    gr-button {
-      display: block;
-      vertical-align: top;
-      --gr-button-padding: 1px;
-    }
-    gr-button[disabled] iron-icon {
-      color: var(--border-color);
-    }
-    gr-account-link {
-      --account-max-length: 100px;
-      margin-right: var(--spacing-xs);
-    }
-    iron-icon {
-      height: calc(var(--line-height-normal) - 2px);
-      width: calc(var(--line-height-normal) - 2px);
-    }
-    .labelValueContainer:not(:first-of-type) td {
-      padding-top: var(--spacing-s);
-    }
-  </style>
-  <p
-    class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]"
-  >
-    No votes
-  </p>
-  <table>
-    <template
-      is="dom-repeat"
-      items="[[_mapLabelInfo(labelInfo, account, change.labels.*)]]"
-      as="mappedLabel"
-    >
-      <tr class="labelValueContainer">
-        <td>
-          <gr-tooltip-content
-            has-tooltip
-            title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
-          >
-            <gr-label class$="[[mappedLabel.className]] voteChip font-small">
-              [[mappedLabel.value]]
-            </gr-label>
-          </gr-tooltip-content>
-        </td>
-        <td>
-          <gr-account-link
-            account="[[mappedLabel.account]]"
-            change="[[change]]"
-          ></gr-account-link>
-        </td>
-        <td>
-          <gr-tooltip-content has-tooltip title="Remove vote">
-            <gr-button
-              link=""
-              aria-label="Remove vote"
-              on-click="_onDeleteVote"
-              data-account-id$="[[mappedLabel.account._account_id]]"
-              class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]"
-            >
-              <iron-icon icon="gr-icons:delete"></iron-icon>
-            </gr-button>
-          </gr-tooltip-content>
-        </td>
-      </tr>
-    </template>
-  </table>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
index b1bd6fa..cad1f69 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
@@ -66,15 +66,17 @@
       element.labelInfo = label;
       element.label = 'Code-Review';
 
-      await flush();
+      await element.updateComplete;
     });
 
-    test('_computeCanDeleteVote', () => {
+    test('_computeCanDeleteVote', async () => {
       element.mutable = false;
+      await element.updateComplete;
       const removeButton = queryAndAssert<GrButton>(element, 'gr-button');
       assert.isTrue(isHidden(removeButton));
       element.change!.removable_reviewers = [account];
       element.mutable = true;
+      await element.updateComplete;
       assert.isFalse(isHidden(removeButton));
     });
 
@@ -109,14 +111,14 @@
   suite('label color and order', () => {
     test('valueless label rejected', async () => {
       element.labelInfo = {rejected: {name: 'someone'}};
-      await flush();
+      await element.updateComplete;
       const labels = queryAll<GrLabel>(element, 'gr-label');
       assert.isTrue(labels[0].classList.contains('negative'));
     });
 
     test('valueless label approved', async () => {
       element.labelInfo = {approved: {name: 'someone'}};
-      await flush();
+      await element.updateComplete;
       const labels = queryAll<GrLabel>(element, 'gr-label');
       assert.isTrue(labels[0].classList.contains('positive'));
     });
@@ -137,7 +139,7 @@
           '+2': 'Ready to submit',
         },
       };
-      await flush();
+      await element.updateComplete;
       const labels = queryAll<GrLabel>(element, 'gr-label');
       assert.isTrue(labels[0].classList.contains('max'));
       assert.isTrue(labels[1].classList.contains('positive'));
@@ -157,7 +159,7 @@
           '+1': 'Looks good to me',
         },
       };
-      await flush();
+      await element.updateComplete;
       const labels = queryAll<GrLabel>(element, 'gr-label');
       assert.isTrue(labels[0].classList.contains('max'));
       assert.isTrue(labels[1].classList.contains('min'));
@@ -175,7 +177,7 @@
           '+2': 'Looks good to me',
         },
       };
-      await flush();
+      await element.updateComplete;
       const labels = queryAll<GrLabel>(element, 'gr-label');
       assert.isTrue(labels[0].classList.contains('max'));
       assert.isTrue(labels[1].classList.contains('positive'));
@@ -195,7 +197,7 @@
           '+1': 'Looks good to me',
         },
       };
-      await flush();
+      await element.updateComplete;
       const chips = queryAll<GrAccountLink>(element, 'gr-account-link');
       assert.equal(chips[0].account!._account_id, element.account._account_id);
     });
@@ -217,7 +219,7 @@
     assert.equal(element._computeValueTooltip(labelInfo, score), '');
   });
 
-  test('placeholder', () => {
+  test('placeholder', async () => {
     const values = {
       '0': 'No score',
       '+1': 'good',
@@ -226,30 +228,37 @@
       '-2': 'terrible',
     };
     element.labelInfo = {};
+    await element.updateComplete;
     assert.isFalse(
       isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
     );
     element.labelInfo = {all: [], values};
+    await element.updateComplete;
     assert.isFalse(
       isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
     );
     element.labelInfo = {all: [{value: 1}], values};
+    await element.updateComplete;
     assert.isTrue(
       isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
     );
     element.labelInfo = {rejected: account};
+    await element.updateComplete;
     assert.isTrue(
       isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
     );
     element.labelInfo = {rejected: account, all: [{value: 1}], values};
+    await element.updateComplete;
     assert.isTrue(
       isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
     );
     element.labelInfo = {approved: account};
+    await element.updateComplete;
     assert.isTrue(
       isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
     );
     element.labelInfo = {approved: account, all: [{value: 1}], values};
+    await element.updateComplete;
     assert.isTrue(
       isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
     );
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 40434a9..2f249e7 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -14,88 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-/*
-
-How to Add a Keyboard Shortcut
-==============================
-
-A keyboard shortcut is composed of the following parts:
-
-  1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
-  2. Documentation for the keyboard shortcut help dialog
-  3. A binding between key combos and the semantic identifier
-  4. A binding between the semantic identifier and a listener
-
-Parts (1) and (2) for all shortcuts are defined in this file. The semantic
-identifier is declared in the Shortcut enum near the head of this script:
-
-  const Shortcut = {
-    // ...
-    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
-    // ...
-  };
-
-Immediately following the Shortcut enum definition, there is a _describe
-function defined which is then invoked many times to populate the help dialog.
-Add a new invocation here to document the shortcut:
-
-  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
-      'Hide/show left diff');
-
-When an attached view binds one or more key combos to this shortcut, the help
-dialog will display this text in the given section (in this case, "Diffs"). See
-the ShortcutSection enum immediately below for the list of supported sections.
-
-Part (3), the actual key bindings, are declared by gr-app. In the future, this
-system may be expanded to allow key binding customizations by plugins or user
-preferences. Key bindings are defined in the following forms:
-
-  // Ordinary shortcut with a single binding.
-  this.bindShortcut(
-      Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-
-  // Ordinary shortcut with multiple bindings.
-  this.bindShortcut(
-      Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-
-  // A "go-key" keyboard shortcut, which is combined with a previously and
-  // continuously pressed "go" key (the go-key is hard-coded as 'g').
-  this.bindShortcut(
-      Shortcut.GO_TO_OPENED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'o');
-
-  // A "doc-only" keyboard shortcut. This declares the key-binding for help
-  // dialog purposes, but doesn't actually implement the binding. It is up
-  // to some element to implement this binding using iron-a11y-keys-behavior's
-  // keyBindings property.
-  this.bindShortcut(
-      Shortcut.EXPAND_ALL_COMMENT_THREADS, SPECIAL_SHORTCUT.DOC_ONLY, 'e');
-
-Part (4), the listener definitions, are declared by the view or element that
-implements the shortcut behavior. This is done by implementing a method named
-keyboardShortcuts() in an element that mixes in this behavior, returning an
-object that maps semantic identifiers (as property names) to listener method
-names, like this:
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-    };
-  },
-
-You can implement key bindings in an element that is hosted by a view IF that
-element is always attached exactly once under that view (e.g. the search bar in
-gr-app). When that is not the case, you will have to define a doc-only binding
-in gr-app, declare the shortcut in the view that hosts the element, and use
-iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
-element. An example of this is in comment threads. A diff view supports actions
-on comment threads, but there may be zero or many comment threads attached at
-any given point. So the shortcut is declared as doc-only by the diff view and
-by gr-app, and actually implemented by gr-comment-thread.
-
-NOTE: doc-only shortcuts will not be customizable in the same way that other
-shortcuts are.
-*/
-
 import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
@@ -105,13 +23,23 @@
 import {getKeyboardEvent, isModifierPressed} from '../../utils/dom-util';
 import {CustomKeyboardEvent} from '../../types/events';
 import {appContext} from '../../services/app-context';
+import {
+  Shortcut,
+  ShortcutSection,
+  SPECIAL_SHORTCUT,
+} from '../../services/shortcuts/shortcuts-config';
+import {
+  ShortcutListener,
+  SectionView,
+} from '../../services/shortcuts/shortcuts-service';
 
-/** Enum for all special shortcuts */
-export enum SPECIAL_SHORTCUT {
-  DOC_ONLY = 'DOC_ONLY',
-  GO_KEY = 'GO_KEY',
-  V_KEY = 'V_KEY',
-}
+export {
+  Shortcut,
+  ShortcutSection,
+  SPECIAL_SHORTCUT,
+  ShortcutListener,
+  SectionView,
+};
 
 // The maximum age of a keydown event to be used in a jump navigation. This
 // is only for cases when the keyup event is lost.
@@ -119,631 +47,6 @@
 
 const V_KEY_TIMEOUT_MS = 1000;
 
-/**
- * Enum for all shortcut sections, where that shortcut should be applied to.
- */
-export enum ShortcutSection {
-  ACTIONS = 'Actions',
-  DIFFS = 'Diffs',
-  EVERYWHERE = 'Global Shortcuts',
-  FILE_LIST = 'File list',
-  NAVIGATION = 'Navigation',
-  REPLY_DIALOG = 'Reply dialog',
-}
-
-/**
- * Enum for all possible shortcut names.
- */
-export enum Shortcut {
-  OPEN_SHORTCUT_HELP_DIALOG = 'OPEN_SHORTCUT_HELP_DIALOG',
-  GO_TO_USER_DASHBOARD = 'GO_TO_USER_DASHBOARD',
-  GO_TO_OPENED_CHANGES = 'GO_TO_OPENED_CHANGES',
-  GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
-  GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
-  GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
-
-  CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
-  CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
-  OPEN_CHANGE = 'OPEN_CHANGE',
-  NEXT_PAGE = 'NEXT_PAGE',
-  PREV_PAGE = 'PREV_PAGE',
-  TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
-  TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
-  REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
-  OPEN_SUBMIT_DIALOG = 'OPEN_SUBMIT_DIALOG',
-  TOGGLE_ATTENTION_SET = 'TOGGLE_ATTENTION_SET',
-
-  OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
-  OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
-  EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
-  COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
-  UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
-  UP_TO_CHANGE = 'UP_TO_CHANGE',
-  TOGGLE_DIFF_MODE = 'TOGGLE_DIFF_MODE',
-  REFRESH_CHANGE = 'REFRESH_CHANGE',
-  EDIT_TOPIC = 'EDIT_TOPIC',
-  DIFF_AGAINST_BASE = 'DIFF_AGAINST_BASE',
-  DIFF_AGAINST_LATEST = 'DIFF_AGAINST_LATEST',
-  DIFF_BASE_AGAINST_LEFT = 'DIFF_BASE_AGAINST_LEFT',
-  DIFF_RIGHT_AGAINST_LATEST = 'DIFF_RIGHT_AGAINST_LATEST',
-  DIFF_BASE_AGAINST_LATEST = 'DIFF_BASE_AGAINST_LATEST',
-
-  NEXT_LINE = 'NEXT_LINE',
-  PREV_LINE = 'PREV_LINE',
-  VISIBLE_LINE = 'VISIBLE_LINE',
-  NEXT_CHUNK = 'NEXT_CHUNK',
-  PREV_CHUNK = 'PREV_CHUNK',
-  TOGGLE_ALL_DIFF_CONTEXT = 'TOGGLE_ALL_DIFF_CONTEXT',
-  NEXT_COMMENT_THREAD = 'NEXT_COMMENT_THREAD',
-  PREV_COMMENT_THREAD = 'PREV_COMMENT_THREAD',
-  EXPAND_ALL_COMMENT_THREADS = 'EXPAND_ALL_COMMENT_THREADS',
-  COLLAPSE_ALL_COMMENT_THREADS = 'COLLAPSE_ALL_COMMENT_THREADS',
-  LEFT_PANE = 'LEFT_PANE',
-  RIGHT_PANE = 'RIGHT_PANE',
-  TOGGLE_LEFT_PANE = 'TOGGLE_LEFT_PANE',
-  NEW_COMMENT = 'NEW_COMMENT',
-  SAVE_COMMENT = 'SAVE_COMMENT',
-  OPEN_DIFF_PREFS = 'OPEN_DIFF_PREFS',
-  TOGGLE_DIFF_REVIEWED = 'TOGGLE_DIFF_REVIEWED',
-
-  NEXT_FILE = 'NEXT_FILE',
-  PREV_FILE = 'PREV_FILE',
-  NEXT_FILE_WITH_COMMENTS = 'NEXT_FILE_WITH_COMMENTS',
-  PREV_FILE_WITH_COMMENTS = 'PREV_FILE_WITH_COMMENTS',
-  NEXT_UNREVIEWED_FILE = 'NEXT_UNREVIEWED_FILE',
-  CURSOR_NEXT_FILE = 'CURSOR_NEXT_FILE',
-  CURSOR_PREV_FILE = 'CURSOR_PREV_FILE',
-  OPEN_FILE = 'OPEN_FILE',
-  TOGGLE_FILE_REVIEWED = 'TOGGLE_FILE_REVIEWED',
-  TOGGLE_ALL_INLINE_DIFFS = 'TOGGLE_ALL_INLINE_DIFFS',
-  TOGGLE_INLINE_DIFF = 'TOGGLE_INLINE_DIFF',
-  TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
-  OPEN_FILE_LIST = 'OPEN_FILE_LIST',
-
-  OPEN_FIRST_FILE = 'OPEN_FIRST_FILE',
-  OPEN_LAST_FILE = 'OPEN_LAST_FILE',
-
-  SEARCH = 'SEARCH',
-  SEND_REPLY = 'SEND_REPLY',
-  EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
-  TOGGLE_BLAME = 'TOGGLE_BLAME',
-}
-
-export type SectionView = Array<{binding: string[][]; text: string}>;
-
-/**
- * The interface for listener for shortcut events.
- */
-export type ShortcutListener = (
-  viewMap?: Map<ShortcutSection, SectionView>
-) => void;
-
-interface ShortcutHelpItem {
-  shortcut: Shortcut;
-  text: string;
-}
-
-// TODO(TS): rename to something more meaningful
-const _help = new Map<ShortcutSection, ShortcutHelpItem[]>();
-
-function _describe(shortcut: Shortcut, section: ShortcutSection, text: string) {
-  if (!_help.has(section)) {
-    _help.set(section, []);
-  }
-  const shortcuts = _help.get(section);
-  if (shortcuts) {
-    shortcuts.push({shortcut, text});
-  }
-}
-
-_describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
-_describe(
-  Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
-  ShortcutSection.EVERYWHERE,
-  'Show this dialog'
-);
-_describe(
-  Shortcut.GO_TO_USER_DASHBOARD,
-  ShortcutSection.EVERYWHERE,
-  'Go to User Dashboard'
-);
-_describe(
-  Shortcut.GO_TO_OPENED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Opened Changes'
-);
-_describe(
-  Shortcut.GO_TO_MERGED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Merged Changes'
-);
-_describe(
-  Shortcut.GO_TO_ABANDONED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Abandoned Changes'
-);
-_describe(
-  Shortcut.GO_TO_WATCHED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Watched Changes'
-);
-
-_describe(
-  Shortcut.CURSOR_NEXT_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Select next change'
-);
-_describe(
-  Shortcut.CURSOR_PREV_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Select previous change'
-);
-_describe(
-  Shortcut.OPEN_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Show selected change'
-);
-_describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
-_describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
-_describe(
-  Shortcut.OPEN_REPLY_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open reply dialog to publish comments and add reviewers'
-);
-_describe(
-  Shortcut.OPEN_DOWNLOAD_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open download overlay'
-);
-_describe(
-  Shortcut.EXPAND_ALL_MESSAGES,
-  ShortcutSection.ACTIONS,
-  'Expand all messages'
-);
-_describe(
-  Shortcut.COLLAPSE_ALL_MESSAGES,
-  ShortcutSection.ACTIONS,
-  'Collapse all messages'
-);
-_describe(
-  Shortcut.REFRESH_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Reload the change at the latest patch'
-);
-_describe(
-  Shortcut.TOGGLE_CHANGE_REVIEWED,
-  ShortcutSection.ACTIONS,
-  'Mark/unmark change as reviewed'
-);
-_describe(
-  Shortcut.TOGGLE_FILE_REVIEWED,
-  ShortcutSection.ACTIONS,
-  'Toggle review flag on selected file'
-);
-_describe(
-  Shortcut.REFRESH_CHANGE_LIST,
-  ShortcutSection.ACTIONS,
-  'Refresh list of changes'
-);
-_describe(
-  Shortcut.TOGGLE_CHANGE_STAR,
-  ShortcutSection.ACTIONS,
-  'Star/unstar change'
-);
-_describe(
-  Shortcut.OPEN_SUBMIT_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open submit dialog'
-);
-_describe(
-  Shortcut.TOGGLE_ATTENTION_SET,
-  ShortcutSection.ACTIONS,
-  'Toggle attention set status'
-);
-_describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic');
-_describe(
-  Shortcut.DIFF_AGAINST_BASE,
-  ShortcutSection.ACTIONS,
-  'Diff against base'
-);
-_describe(
-  Shortcut.DIFF_AGAINST_LATEST,
-  ShortcutSection.ACTIONS,
-  'Diff against latest patchset'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LEFT,
-  ShortcutSection.ACTIONS,
-  'Diff base against left'
-);
-_describe(
-  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-  ShortcutSection.ACTIONS,
-  'Diff right against latest'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LATEST,
-  ShortcutSection.ACTIONS,
-  'Diff base against latest'
-);
-
-_describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
-_describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
-_describe(
-  Shortcut.DIFF_AGAINST_BASE,
-  ShortcutSection.DIFFS,
-  'Diff against base'
-);
-_describe(
-  Shortcut.DIFF_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff against latest patchset'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LEFT,
-  ShortcutSection.DIFFS,
-  'Diff base against left'
-);
-_describe(
-  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff right against latest'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff base against latest'
-);
-_describe(
-  Shortcut.VISIBLE_LINE,
-  ShortcutSection.DIFFS,
-  'Move cursor to currently visible code'
-);
-_describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk');
-_describe(
-  Shortcut.PREV_CHUNK,
-  ShortcutSection.DIFFS,
-  'Go to previous diff chunk'
-);
-_describe(
-  Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
-  ShortcutSection.DIFFS,
-  'Toggle all diff context'
-);
-_describe(
-  Shortcut.NEXT_COMMENT_THREAD,
-  ShortcutSection.DIFFS,
-  'Go to next comment thread'
-);
-_describe(
-  Shortcut.PREV_COMMENT_THREAD,
-  ShortcutSection.DIFFS,
-  'Go to previous comment thread'
-);
-_describe(
-  Shortcut.EXPAND_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Expand all comment threads'
-);
-_describe(
-  Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Collapse all comment threads'
-);
-_describe(
-  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Hide/Display all comment threads'
-);
-_describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
-_describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
-_describe(
-  Shortcut.TOGGLE_LEFT_PANE,
-  ShortcutSection.DIFFS,
-  'Hide/show left diff'
-);
-_describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
-_describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
-_describe(
-  Shortcut.OPEN_DIFF_PREFS,
-  ShortcutSection.DIFFS,
-  'Show diff preferences'
-);
-_describe(
-  Shortcut.TOGGLE_DIFF_REVIEWED,
-  ShortcutSection.DIFFS,
-  'Mark/unmark file as reviewed'
-);
-_describe(
-  Shortcut.TOGGLE_DIFF_MODE,
-  ShortcutSection.DIFFS,
-  'Toggle unified/side-by-side diff'
-);
-_describe(
-  Shortcut.NEXT_UNREVIEWED_FILE,
-  ShortcutSection.DIFFS,
-  'Mark file as reviewed and go to next unreviewed file'
-);
-_describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame');
-
-_describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file');
-_describe(
-  Shortcut.PREV_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to previous file'
-);
-_describe(
-  Shortcut.NEXT_FILE_WITH_COMMENTS,
-  ShortcutSection.NAVIGATION,
-  'Go to next file that has comments'
-);
-_describe(
-  Shortcut.PREV_FILE_WITH_COMMENTS,
-  ShortcutSection.NAVIGATION,
-  'Go to previous file that has comments'
-);
-_describe(
-  Shortcut.OPEN_FIRST_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to first file'
-);
-_describe(
-  Shortcut.OPEN_LAST_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to last file'
-);
-_describe(
-  Shortcut.UP_TO_DASHBOARD,
-  ShortcutSection.NAVIGATION,
-  'Up to dashboard'
-);
-_describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
-
-_describe(
-  Shortcut.CURSOR_NEXT_FILE,
-  ShortcutSection.FILE_LIST,
-  'Select next file'
-);
-_describe(
-  Shortcut.CURSOR_PREV_FILE,
-  ShortcutSection.FILE_LIST,
-  'Select previous file'
-);
-_describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST, 'Go to selected file');
-_describe(
-  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-  ShortcutSection.FILE_LIST,
-  'Show/hide all inline diffs'
-);
-_describe(
-  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
-  ShortcutSection.FILE_LIST,
-  'Hide/Display all comment threads'
-);
-_describe(
-  Shortcut.TOGGLE_INLINE_DIFF,
-  ShortcutSection.FILE_LIST,
-  'Show/hide selected inline diff'
-);
-
-_describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
-_describe(
-  Shortcut.EMOJI_DROPDOWN,
-  ShortcutSection.REPLY_DIALOG,
-  'Emoji dropdown'
-);
-
-/**
- * Shortcut manager, holds all hosts, bindings and listeners.
- */
-export class ShortcutManager {
-  private readonly activeHosts = new Map<PolymerElement, Map<string, string>>();
-
-  private readonly bindings = new Map<Shortcut, string[]>();
-
-  public _testOnly_getBindings() {
-    return this.bindings;
-  }
-
-  public _testOnly_isEmpty() {
-    return this.activeHosts.size === 0 && this.listeners.size === 0;
-  }
-
-  private readonly listeners = new Set<ShortcutListener>();
-
-  bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
-    this.bindings.set(shortcut, bindings);
-  }
-
-  getBindingsForShortcut(shortcut: Shortcut) {
-    return this.bindings.get(shortcut);
-  }
-
-  attachHost(host: PolymerElement, shortcuts: Map<string, string>) {
-    this.activeHosts.set(host, shortcuts);
-    this.notifyListeners();
-  }
-
-  detachHost(host: PolymerElement) {
-    if (this.activeHosts.delete(host)) {
-      this.notifyListeners();
-      return true;
-    }
-    return false;
-  }
-
-  addListener(listener: ShortcutListener) {
-    this.listeners.add(listener);
-    listener(this.directoryView());
-  }
-
-  removeListener(listener: ShortcutListener) {
-    return this.listeners.delete(listener);
-  }
-
-  getDescription(section: ShortcutSection, shortcutName: Shortcut) {
-    const bindings = _help.get(section);
-    let desc = '';
-    if (bindings) {
-      const binding = bindings.find(
-        binding => binding.shortcut === shortcutName
-      );
-      desc = binding ? binding.text : '';
-    }
-    return desc;
-  }
-
-  getShortcut(shortcutName: Shortcut) {
-    const bindings = this.bindings.get(shortcutName);
-    return bindings
-      ? bindings
-          .map(binding => this.describeBinding(binding).join('+'))
-          .join(',')
-      : '';
-  }
-
-  activeShortcutsBySection() {
-    const activeShortcuts = new Set<string>();
-    this.activeHosts.forEach(shortcuts => {
-      shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
-    });
-
-    const activeShortcutsBySection = new Map<
-      ShortcutSection,
-      ShortcutHelpItem[]
-    >();
-    _help.forEach((shortcutList, section) => {
-      shortcutList.forEach(shortcutHelp => {
-        if (activeShortcuts.has(shortcutHelp.shortcut)) {
-          if (!activeShortcutsBySection.has(section)) {
-            activeShortcutsBySection.set(section, []);
-          }
-          // From previous condition, the `get(section)`
-          // should always return a valid result
-          activeShortcutsBySection.get(section)!.push(shortcutHelp);
-        }
-      });
-    });
-    return activeShortcutsBySection;
-  }
-
-  directoryView() {
-    const view = new Map<ShortcutSection, SectionView>();
-    this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
-      const sectionView: Array<{binding: string[][]; text: string}> = [];
-      shortcutHelps.forEach(shortcutHelp => {
-        const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
-        if (!bindingDesc) {
-          return;
-        }
-        this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
-          sectionView.push({
-            binding: bindingDesc,
-            text: shortcutHelp.text,
-          });
-        });
-      });
-      view.set(section, sectionView);
-    });
-    return view;
-  }
-
-  distributeBindingDesc(bindingDesc: string[][]): string[][][] {
-    if (
-      bindingDesc.length === 1 ||
-      this.comboSetDisplayWidth(bindingDesc) < 21
-    ) {
-      return [bindingDesc];
-    }
-    // Find the largest prefix of bindings that is under the
-    // size threshold.
-    const head = [bindingDesc[0]];
-    for (let i = 1; i < bindingDesc.length; i++) {
-      head.push(bindingDesc[i]);
-      if (this.comboSetDisplayWidth(head) >= 21) {
-        head.pop();
-        return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i)));
-      }
-    }
-    return [];
-  }
-
-  comboSetDisplayWidth(bindingDesc: string[][]) {
-    const bindingSizer = (binding: string[]) =>
-      binding.reduce((acc, key) => acc + key.length, 0);
-    // Width is the sum of strings + (n-1) * 2 to account for the word
-    // "or" joining them.
-    return (
-      bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) +
-      2 * (bindingDesc.length - 1)
-    );
-  }
-
-  describeBindings(shortcut: Shortcut): string[][] | null {
-    const bindings = this.bindings.get(shortcut);
-    if (!bindings) {
-      return null;
-    }
-    if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
-      return bindings
-        .slice(1)
-        .map(binding => this._describeKey(binding))
-        .map(binding => ['g'].concat(binding));
-    }
-    if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
-      return bindings
-        .slice(1)
-        .map(binding => this._describeKey(binding))
-        .map(binding => ['v'].concat(binding));
-    }
-
-    return bindings
-      .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
-      .map(binding => this.describeBinding(binding));
-  }
-
-  _describeKey(key: string) {
-    switch (key) {
-      case 'shift':
-        return 'Shift';
-      case 'meta':
-        return 'Meta';
-      case 'ctrl':
-        return 'Ctrl';
-      case 'enter':
-        return 'Enter';
-      case 'up':
-        return '\u2191'; // ↑
-      case 'down':
-        return '\u2193'; // ↓
-      case 'left':
-        return '\u2190'; // ←
-      case 'right':
-        return '\u2192'; // →
-      default:
-        return key;
-    }
-  }
-
-  describeBinding(binding: string) {
-    // single key bindings
-    if (binding.length === 1) {
-      return [binding];
-    }
-    return binding
-      .split(':')[0]
-      .split('+')
-      .map(part => this._describeKey(part));
-  }
-
-  notifyListeners() {
-    const view = this.directoryView();
-    this.listeners.forEach(listener => listener(view));
-  }
-}
-
-const shortcutManager = new ShortcutManager();
-
 interface IronA11yKeysMixinConstructor {
   // Note: this is needed to have same interface as other mixins
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -783,7 +86,9 @@
 
     private readonly restApiService = appContext.restApiService;
 
-    private reporting = appContext.reportingService;
+    private readonly reporting = appContext.reportingService;
+
+    private readonly shortcuts = appContext.shortcutsService;
 
     /** Used to disable shortcuts when the element is not visible. */
     private observer?: IntersectionObserver;
@@ -860,18 +165,8 @@
       return getKeyboardEvent(e);
     }
 
-    bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
-      shortcutManager.bindShortcut(shortcut, ...bindings);
-    }
-
-    createTitle(shortcutName: Shortcut, section: ShortcutSection) {
-      const desc = shortcutManager.getDescription(section, shortcutName);
-      const shortcut = shortcutManager.getShortcut(shortcutName);
-      return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
-    }
-
     _addOwnKeyBindings(shortcut: Shortcut, handler: string) {
-      const bindings = shortcutManager.getBindingsForShortcut(shortcut);
+      const bindings = this.shortcuts.getBindingsForShortcut(shortcut);
       if (!bindings) {
         return;
       }
@@ -947,7 +242,7 @@
       const shortcuts = new Map<string, string>(
         Object.entries(this.keyboardShortcuts())
       );
-      shortcutManager.attachHost(this, shortcuts);
+      this.shortcuts.attachHost(this, shortcuts);
 
       for (const [key, value] of shortcuts.entries()) {
         this._addOwnKeyBindings(key as Shortcut, value);
@@ -983,7 +278,7 @@
     private disableBindings() {
       if (!this.bindingsEnabled) return;
       this.bindingsEnabled = false;
-      if (shortcutManager.detachHost(this)) {
+      if (this.shortcuts.detachHost(this)) {
         this.removeOwnKeyBindings();
       }
     }
@@ -996,14 +291,6 @@
       return {};
     }
 
-    addKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
-      shortcutManager.addListener(listener);
-    }
-
-    removeKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
-      shortcutManager.removeListener(listener);
-    }
-
     _handleVKeyDown(e: CustomKeyboardEvent) {
       if (this.shouldSuppressKeyboardShortcut(e)) return;
       this._shortcut_v_key_last_pressed = Date.now();
@@ -1077,7 +364,10 @@
     }
   }
 
-  return Mixin as T & Constructor<KeyboardShortcutMixinInterface>;
+  return Mixin as T &
+    Constructor<
+      KeyboardShortcutMixinInterface & KeyboardShortcutMixinInterfaceTesting
+    >;
 };
 
 // The following doesn't work (IronA11yKeysBehavior crashes):
@@ -1090,7 +380,10 @@
 // This is a workaround
 export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
   superClass: T
-): T & Constructor<KeyboardShortcutMixinInterface> =>
+): T &
+  Constructor<
+    KeyboardShortcutMixinInterface & KeyboardShortcutMixinInterfaceTesting
+  > =>
   InternalKeyboardShortcutMixin(
     // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
     // which will fail the type check due to missing IronA11yKeysBehavior interface
@@ -1101,14 +394,14 @@
 /** The interface corresponding to KeyboardShortcutMixin */
 export interface KeyboardShortcutMixinInterface {
   keyboardShortcuts(): {[key: string]: string | null};
-  createTitle(name: Shortcut, section: ShortcutSection): string;
-  bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
   shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
   modifierPressed(event: CustomKeyboardEvent): boolean;
-  addKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
-  removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
 }
 
-export function _testOnly_getShortcutManagerInstance() {
-  return shortcutManager;
+export interface KeyboardShortcutMixinInterfaceTesting {
+  _shortcut_go_key_last_pressed: number | null;
+  _shortcut_v_key_last_pressed: number | null;
+  _shortcut_go_table: Map<string, string>;
+  _shortcut_v_table: Map<string, string>;
+  _handleGoAction: (e: CustomKeyboardEvent) => void;
 }
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
deleted file mode 100644
index 4536ecd..0000000
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
+++ /dev/null
@@ -1,424 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {
-  KeyboardShortcutMixin, Shortcut,
-  ShortcutManager, ShortcutSection, SPECIAL_SHORTCUT,
-} from './keyboard-shortcut-mixin.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {mockPromise} from '../../test/test-utils.js';
-
-const basicFixture =
-    fixtureFromElement('keyboard-shortcut-mixin-test-element');
-
-const withinOverlayFixture = fixtureFromTemplate(html`
-<gr-overlay>
-  <keyboard-shortcut-mixin-test-element>
-  </keyboard-shortcut-mixin-test-element>
-</gr-overlay>
-`);
-
-class GrKeyboardShortcutMixinTestElement extends
-  KeyboardShortcutMixin(PolymerElement) {
-  static get is() {
-    return 'keyboard-shortcut-mixin-test-element';
-  }
-
-  get keyBindings() {
-    return {
-      k: '_handleKey',
-      enter: '_handleKey',
-    };
-  }
-
-  _handleKey() {}
-}
-
-customElements.define(GrKeyboardShortcutMixinTestElement.is,
-    GrKeyboardShortcutMixinTestElement);
-
-suite('keyboard-shortcut-mixin tests', () => {
-  let element;
-  let overlay;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    overlay = withinOverlayFixture.instantiate();
-  });
-
-  suite('ShortcutManager', () => {
-    test('bindings management', () => {
-      const mgr = new ShortcutManager();
-      const NEXT_FILE = Shortcut.NEXT_FILE;
-
-      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
-      assert.deepEqual(
-          mgr.getBindingsForShortcut(NEXT_FILE),
-          [']', '}', 'right']);
-    });
-
-    test('getShortcut', () => {
-      const mgr = new ShortcutManager();
-      const NEXT_FILE = Shortcut.NEXT_FILE;
-
-      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
-      assert.equal(mgr.getShortcut(NEXT_FILE), '],},→');
-    });
-
-    test('getShortcut with modifiers', () => {
-      const mgr = new ShortcutManager();
-      const NEXT_FILE = Shortcut.NEXT_FILE;
-
-      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-      mgr.bindShortcut(NEXT_FILE, 'Shift+a:key');
-      assert.equal(mgr.getShortcut(NEXT_FILE), 'Shift+a');
-    });
-
-    suite('binding descriptions', () => {
-      function mapToObject(m) {
-        const o = {};
-        m.forEach((v, k) => o[k] = v);
-        return o;
-      }
-
-      test('single combo description', () => {
-        const mgr = new ShortcutManager();
-        assert.deepEqual(mgr.describeBinding('a'), ['a']);
-        assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
-        assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
-        assert.deepEqual(
-            mgr.describeBinding('ctrl+shift+up:keyup'),
-            ['Ctrl', 'Shift', '↑']);
-      });
-
-      test('combo set description', () => {
-        const mgr = new ShortcutManager();
-        assert.isNull(mgr.describeBindings(Shortcut.NEXT_FILE));
-
-        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
-            SPECIAL_SHORTCUT.GO_KEY, 'o');
-        assert.deepEqual(
-            mgr.describeBindings(Shortcut.GO_TO_OPENED_CHANGES),
-            [['g', 'o']]);
-
-        mgr.bindShortcut(Shortcut.NEXT_FILE, SPECIAL_SHORTCUT.DOC_ONLY,
-            ']', 'ctrl+shift+right:keyup');
-        assert.deepEqual(
-            mgr.describeBindings(Shortcut.NEXT_FILE),
-            [[']'], ['Ctrl', 'Shift', '→']]);
-
-        mgr.bindShortcut(Shortcut.PREV_FILE, '[');
-        assert.deepEqual(mgr.describeBindings(Shortcut.PREV_FILE), [['[']]);
-      });
-
-      test('combo set description width', () => {
-        const mgr = new ShortcutManager();
-        assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
-        assert.strictEqual(
-            mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
-            12);
-      });
-
-      test('distribute shortcut help', () => {
-        const mgr = new ShortcutManager();
-        assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([['g', 'o']]),
-            [[['g', 'o']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
-            [[['ctrl', 'shift', 'meta', 'enter']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([
-              ['ctrl', 'shift', 'meta', 'enter'],
-              ['o'],
-            ]),
-            [
-              [['ctrl', 'shift', 'meta', 'enter']],
-              [['o']],
-            ]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([
-              ['ctrl', 'enter'],
-              ['meta', 'enter'],
-              ['ctrl', 's'],
-              ['meta', 's'],
-            ]),
-            [
-              [['ctrl', 'enter'], ['meta', 'enter']],
-              [['ctrl', 's'], ['meta', 's']],
-            ]);
-      });
-
-      test('active shortcuts by section', () => {
-        const mgr = new ShortcutManager();
-        mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
-        mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
-        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES, 'g+o');
-        mgr.bindShortcut(Shortcut.SEARCH, '/');
-
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {});
-
-        mgr.attachHost({}, new Map([[Shortcut.NEXT_FILE, null]]));
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [ShortcutSection.NAVIGATION]: [
-                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-
-        mgr.attachHost({}, new Map([[Shortcut.NEXT_LINE, null]]));
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [ShortcutSection.DIFFS]: [
-                {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
-              ],
-              [ShortcutSection.NAVIGATION]: [
-                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-
-        mgr.attachHost({}, new Map([
-          [Shortcut.SEARCH, null],
-          [Shortcut.GO_TO_OPENED_CHANGES, null],
-        ]));
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [ShortcutSection.DIFFS]: [
-                {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
-              ],
-              [ShortcutSection.EVERYWHERE]: [
-                {shortcut: Shortcut.SEARCH, text: 'Search'},
-                {
-                  shortcut: Shortcut.GO_TO_OPENED_CHANGES,
-                  text: 'Go to Opened Changes',
-                },
-              ],
-              [ShortcutSection.NAVIGATION]: [
-                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-      });
-
-      test('directory view', () => {
-        const mgr = new ShortcutManager();
-        mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
-        mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
-        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
-            SPECIAL_SHORTCUT.GO_KEY, 'o');
-        mgr.bindShortcut(Shortcut.SEARCH, '/');
-        mgr.bindShortcut(
-            Shortcut.SAVE_COMMENT, 'ctrl+enter', 'meta+enter',
-            'ctrl+s', 'meta+s');
-
-        assert.deepEqual(mapToObject(mgr.directoryView()), {});
-
-        mgr.attachHost({}, new Map([
-          [Shortcut.GO_TO_OPENED_CHANGES, null],
-          [Shortcut.NEXT_FILE, null],
-          [Shortcut.NEXT_LINE, null],
-          [Shortcut.SAVE_COMMENT, null],
-          [Shortcut.SEARCH, null],
-        ]));
-        assert.deepEqual(
-            mapToObject(mgr.directoryView()),
-            {
-              [ShortcutSection.DIFFS]: [
-                {binding: [['j']], text: 'Go to next line'},
-                {
-                  binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
-                  text: 'Save comment',
-                },
-                {
-                  binding: [['Ctrl', 's'], ['Meta', 's']],
-                  text: 'Save comment',
-                },
-              ],
-              [ShortcutSection.EVERYWHERE]: [
-                {binding: [['/']], text: 'Search'},
-                {binding: [['g', 'o']], text: 'Go to Opened Changes'},
-              ],
-              [ShortcutSection.NAVIGATION]: [
-                {binding: [[']']], text: 'Go to next file'},
-              ],
-            });
-      });
-    });
-  });
-
-  test('doesn’t block kb shortcuts for non-allowed els', async () => {
-    const divEl = document.createElement('div');
-    element.appendChild(divEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(divEl, 75, null, 'k');
-    await promise;
-  });
-
-  test('blocks kb shortcuts for input els', async () => {
-    const inputEl = document.createElement('input');
-    element.appendChild(inputEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
-    await promise;
-  });
-
-  test('doesn’t block kb shortcuts for checkboxes', async () => {
-    const inputEl = document.createElement('input');
-    inputEl.setAttribute('type', 'checkbox');
-    element.appendChild(inputEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
-    await promise;
-  });
-
-  test('blocks kb shortcuts for textarea els', async () => {
-    const textareaEl = document.createElement('textarea');
-    element.appendChild(textareaEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
-    await promise;
-  });
-
-  test('blocks kb shortcuts for anything in a gr-overlay', async () => {
-    const divEl = document.createElement('div');
-    const element =
-        overlay.querySelector('keyboard-shortcut-mixin-test-element');
-    element.appendChild(divEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(divEl, 75, null, 'k');
-    await promise;
-  });
-
-  test('blocks enter shortcut on an anchor', async () => {
-    const anchorEl = document.createElement('a');
-    const element =
-        overlay.querySelector('keyboard-shortcut-mixin-test-element');
-    element.appendChild(anchorEl);
-    const promise = mockPromise();
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      promise.resolve();
-    };
-    MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
-    await promise;
-  });
-
-  test('modifierPressed returns accurate values', () => {
-    const spy = sinon.spy(element, 'modifierPressed');
-    element._handleKey = e => {
-      element.modifierPressed(e);
-    };
-    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-  });
-
-  suite('GO_KEY timing', () => {
-    let handlerStub;
-
-    setup(() => {
-      element._shortcut_go_table.set('a', '_handleA');
-      handlerStub = element._handleA = sinon.stub();
-      sinon.stub(Date, 'now').returns(10000);
-    });
-
-    test('success', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isTrue(handlerStub.calledOnce);
-      assert.strictEqual(handlerStub.lastCall.args[0], e);
-    });
-
-    test('go key not pressed', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = null;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('go key pressed too long ago', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = 3000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('should suppress', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('unrecognized key', () => {
-      const e = {detail: {key: 'f'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.ts
new file mode 100644
index 0000000..6350bf9
--- /dev/null
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.ts
@@ -0,0 +1,243 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../test/common-test-setup-karma';
+import {KeyboardShortcutMixin} from './keyboard-shortcut-mixin';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {mockPromise, queryAndAssert} from '../../test/test-utils';
+import '../../elements/shared/gr-overlay/gr-overlay';
+import {GrOverlay} from '../../elements/shared/gr-overlay/gr-overlay';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {CustomKeyboardEvent} from '../../types/events';
+
+class GrKeyboardShortcutMixinTestElement extends KeyboardShortcutMixin(
+  PolymerElement
+) {
+  static get is() {
+    return 'keyboard-shortcut-mixin-test-element';
+  }
+
+  get keyBindings() {
+    return {
+      k: '_handleKey',
+      enter: '_handleKey',
+    };
+  }
+
+  _handleKey(_: any) {}
+
+  _handleA(_: any) {}
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'keyboard-shortcut-mixin-test-element': GrKeyboardShortcutMixinTestElement;
+  }
+}
+
+customElements.define(
+  GrKeyboardShortcutMixinTestElement.is,
+  GrKeyboardShortcutMixinTestElement
+);
+
+const basicFixture = fixtureFromElement('keyboard-shortcut-mixin-test-element');
+
+const withinOverlayFixture = fixtureFromTemplate(html`
+  <gr-overlay>
+    <keyboard-shortcut-mixin-test-element>
+    </keyboard-shortcut-mixin-test-element>
+  </gr-overlay>
+`);
+
+suite('keyboard-shortcut-mixin tests', () => {
+  let element: GrKeyboardShortcutMixinTestElement;
+  let overlay: GrOverlay;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    overlay = withinOverlayFixture.instantiate() as GrOverlay;
+    await flush();
+  });
+
+  test('doesn’t block kb shortcuts for non-allowed els', async () => {
+    const divEl = document.createElement('div');
+    element.appendChild(divEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(divEl, 75, null, 'k');
+    await promise;
+  });
+
+  test('blocks kb shortcuts for input els', async () => {
+    const inputEl = document.createElement('input');
+    element.appendChild(inputEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+    await promise;
+  });
+
+  test('doesn’t block kb shortcuts for checkboxes', async () => {
+    const inputEl = document.createElement('input');
+    inputEl.setAttribute('type', 'checkbox');
+    element.appendChild(inputEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+    await promise;
+  });
+
+  test('blocks kb shortcuts for textarea els', async () => {
+    const textareaEl = document.createElement('textarea');
+    element.appendChild(textareaEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
+    await promise;
+  });
+
+  test('blocks kb shortcuts for anything in a gr-overlay', async () => {
+    const divEl = document.createElement('div');
+    const element = queryAndAssert<GrKeyboardShortcutMixinTestElement>(
+      overlay,
+      'keyboard-shortcut-mixin-test-element'
+    );
+    element.appendChild(divEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(divEl, 75, null, 'k');
+    await promise;
+  });
+
+  test('blocks enter shortcut on an anchor', async () => {
+    const anchorEl = document.createElement('a');
+    const element = queryAndAssert<GrKeyboardShortcutMixinTestElement>(
+      overlay,
+      'keyboard-shortcut-mixin-test-element'
+    );
+    element.appendChild(anchorEl);
+    const promise = mockPromise();
+    element._handleKey = e => {
+      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
+      promise.resolve();
+    };
+    MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
+    await promise;
+  });
+
+  test('modifierPressed returns accurate values', () => {
+    const spy = sinon.spy(element, 'modifierPressed');
+    element._handleKey = e => {
+      element.modifierPressed(e);
+    };
+    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, null, 'k');
+    assert.isFalse(spy.lastCall.returnValue);
+    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+    assert.isTrue(spy.lastCall.returnValue);
+  });
+
+  suite('GO_KEY timing', () => {
+    let handlerStub: sinon.SinonStub;
+
+    setup(() => {
+      element._shortcut_go_table.set('a', '_handleA');
+      handlerStub = element._handleA = sinon.stub();
+      sinon.stub(Date, 'now').returns(10000);
+    });
+
+    test('success', () => {
+      const e = {
+        detail: {key: 'a'},
+        preventDefault: () => {},
+      } as CustomKeyboardEvent;
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isTrue(handlerStub.calledOnce);
+      assert.strictEqual(handlerStub.lastCall.args[0], e);
+    });
+
+    test('go key not pressed', () => {
+      const e = {
+        detail: {key: 'a'},
+        preventDefault: () => {},
+      } as CustomKeyboardEvent;
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = null;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('go key pressed too long ago', () => {
+      const e = {
+        detail: {key: 'a'},
+        preventDefault: () => {},
+      } as CustomKeyboardEvent;
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 3000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('should suppress', () => {
+      const e = {
+        detail: {key: 'a'},
+        preventDefault: () => {},
+      } as CustomKeyboardEvent;
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+
+    test('unrecognized key', () => {
+      const e = {
+        detail: {key: 'f'},
+        preventDefault: () => {},
+      } as CustomKeyboardEvent;
+      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+      element._shortcut_go_key_last_pressed = 9000;
+      element._handleGoAction(e);
+      assert.isFalse(handlerStub.called);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index ade9529..597776d 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -27,6 +27,7 @@
 import {ConfigService} from './config/config-service';
 import {UserService} from './user/user-service';
 import {CommentsService} from './comments/comments-service';
+import {ShortcutsService} from './shortcuts/shortcuts-service';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
@@ -82,5 +83,6 @@
     storageService: () => new GrStorageService(),
     configService: () => new ConfigService(),
     userService: () => new UserService(appContext.restApiService),
+    shortcutsService: () => new ShortcutsService(),
   });
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 161378d..e5828d6 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -26,6 +26,7 @@
 import {ConfigService} from './config/config-service';
 import {UserService} from './user/user-service';
 import {CommentsService} from './comments/comments-service';
+import {ShortcutsService} from './shortcuts/shortcuts-service';
 
 export interface AppContext {
   flagsService: FlagsService;
@@ -40,6 +41,7 @@
   storageService: StorageService;
   configService: ConfigService;
   userService: UserService;
+  shortcutsService: ShortcutsService;
 }
 
 /**
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
new file mode 100644
index 0000000..bd004d7
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -0,0 +1,552 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/** Enum for all special shortcuts */
+export enum SPECIAL_SHORTCUT {
+  DOC_ONLY = 'DOC_ONLY',
+  GO_KEY = 'GO_KEY',
+  V_KEY = 'V_KEY',
+}
+
+/**
+ * Enum for all shortcut sections, where that shortcut should be applied to.
+ */
+export enum ShortcutSection {
+  ACTIONS = 'Actions',
+  DIFFS = 'Diffs',
+  EVERYWHERE = 'Global Shortcuts',
+  FILE_LIST = 'File list',
+  NAVIGATION = 'Navigation',
+  REPLY_DIALOG = 'Reply dialog',
+}
+
+/**
+ * Enum for all possible shortcut names.
+ */
+export enum Shortcut {
+  OPEN_SHORTCUT_HELP_DIALOG = 'OPEN_SHORTCUT_HELP_DIALOG',
+  GO_TO_USER_DASHBOARD = 'GO_TO_USER_DASHBOARD',
+  GO_TO_OPENED_CHANGES = 'GO_TO_OPENED_CHANGES',
+  GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
+  GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
+  GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
+
+  CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
+  CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
+  OPEN_CHANGE = 'OPEN_CHANGE',
+  NEXT_PAGE = 'NEXT_PAGE',
+  PREV_PAGE = 'PREV_PAGE',
+  TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
+  TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
+  REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
+  OPEN_SUBMIT_DIALOG = 'OPEN_SUBMIT_DIALOG',
+  TOGGLE_ATTENTION_SET = 'TOGGLE_ATTENTION_SET',
+
+  OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
+  OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
+  EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
+  COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
+  UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
+  UP_TO_CHANGE = 'UP_TO_CHANGE',
+  TOGGLE_DIFF_MODE = 'TOGGLE_DIFF_MODE',
+  REFRESH_CHANGE = 'REFRESH_CHANGE',
+  EDIT_TOPIC = 'EDIT_TOPIC',
+  DIFF_AGAINST_BASE = 'DIFF_AGAINST_BASE',
+  DIFF_AGAINST_LATEST = 'DIFF_AGAINST_LATEST',
+  DIFF_BASE_AGAINST_LEFT = 'DIFF_BASE_AGAINST_LEFT',
+  DIFF_RIGHT_AGAINST_LATEST = 'DIFF_RIGHT_AGAINST_LATEST',
+  DIFF_BASE_AGAINST_LATEST = 'DIFF_BASE_AGAINST_LATEST',
+
+  NEXT_LINE = 'NEXT_LINE',
+  PREV_LINE = 'PREV_LINE',
+  VISIBLE_LINE = 'VISIBLE_LINE',
+  NEXT_CHUNK = 'NEXT_CHUNK',
+  PREV_CHUNK = 'PREV_CHUNK',
+  TOGGLE_ALL_DIFF_CONTEXT = 'TOGGLE_ALL_DIFF_CONTEXT',
+  NEXT_COMMENT_THREAD = 'NEXT_COMMENT_THREAD',
+  PREV_COMMENT_THREAD = 'PREV_COMMENT_THREAD',
+  EXPAND_ALL_COMMENT_THREADS = 'EXPAND_ALL_COMMENT_THREADS',
+  COLLAPSE_ALL_COMMENT_THREADS = 'COLLAPSE_ALL_COMMENT_THREADS',
+  LEFT_PANE = 'LEFT_PANE',
+  RIGHT_PANE = 'RIGHT_PANE',
+  TOGGLE_LEFT_PANE = 'TOGGLE_LEFT_PANE',
+  NEW_COMMENT = 'NEW_COMMENT',
+  SAVE_COMMENT = 'SAVE_COMMENT',
+  OPEN_DIFF_PREFS = 'OPEN_DIFF_PREFS',
+  TOGGLE_DIFF_REVIEWED = 'TOGGLE_DIFF_REVIEWED',
+
+  NEXT_FILE = 'NEXT_FILE',
+  PREV_FILE = 'PREV_FILE',
+  NEXT_FILE_WITH_COMMENTS = 'NEXT_FILE_WITH_COMMENTS',
+  PREV_FILE_WITH_COMMENTS = 'PREV_FILE_WITH_COMMENTS',
+  NEXT_UNREVIEWED_FILE = 'NEXT_UNREVIEWED_FILE',
+  CURSOR_NEXT_FILE = 'CURSOR_NEXT_FILE',
+  CURSOR_PREV_FILE = 'CURSOR_PREV_FILE',
+  OPEN_FILE = 'OPEN_FILE',
+  TOGGLE_FILE_REVIEWED = 'TOGGLE_FILE_REVIEWED',
+  TOGGLE_ALL_INLINE_DIFFS = 'TOGGLE_ALL_INLINE_DIFFS',
+  TOGGLE_INLINE_DIFF = 'TOGGLE_INLINE_DIFF',
+  TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
+  OPEN_FILE_LIST = 'OPEN_FILE_LIST',
+
+  OPEN_FIRST_FILE = 'OPEN_FIRST_FILE',
+  OPEN_LAST_FILE = 'OPEN_LAST_FILE',
+
+  SEARCH = 'SEARCH',
+  SEND_REPLY = 'SEND_REPLY',
+  EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
+  TOGGLE_BLAME = 'TOGGLE_BLAME',
+}
+
+export interface ShortcutHelpItem {
+  shortcut: Shortcut;
+  text: string;
+  bindings: string[];
+}
+
+export const config = new Map<ShortcutSection, ShortcutHelpItem[]>();
+
+function describe(
+  shortcut: Shortcut,
+  section: ShortcutSection,
+  text: string,
+  binding: string,
+  ...moreBindings: string[]
+) {
+  if (!config.has(section)) {
+    config.set(section, []);
+  }
+  const shortcuts = config.get(section);
+  if (shortcuts) {
+    shortcuts.push({shortcut, text, bindings: [binding, ...moreBindings]});
+  }
+}
+
+describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search', '/');
+describe(
+  Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
+  ShortcutSection.EVERYWHERE,
+  'Show this dialog',
+  '?'
+);
+describe(
+  Shortcut.GO_TO_USER_DASHBOARD,
+  ShortcutSection.EVERYWHERE,
+  'Go to User Dashboard',
+  SPECIAL_SHORTCUT.GO_KEY,
+  'i'
+);
+describe(
+  Shortcut.GO_TO_OPENED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Opened Changes',
+  SPECIAL_SHORTCUT.GO_KEY,
+  'o'
+);
+describe(
+  Shortcut.GO_TO_MERGED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Merged Changes',
+  SPECIAL_SHORTCUT.GO_KEY,
+  'm'
+);
+describe(
+  Shortcut.GO_TO_ABANDONED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Abandoned Changes',
+  SPECIAL_SHORTCUT.GO_KEY,
+  'a'
+);
+describe(
+  Shortcut.GO_TO_WATCHED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Watched Changes',
+  SPECIAL_SHORTCUT.GO_KEY,
+  'w'
+);
+
+describe(
+  Shortcut.CURSOR_NEXT_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Select next change',
+  'j'
+);
+describe(
+  Shortcut.CURSOR_PREV_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Select previous change',
+  'k'
+);
+describe(
+  Shortcut.OPEN_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Show selected change',
+  'o'
+);
+describe(
+  Shortcut.NEXT_PAGE,
+  ShortcutSection.ACTIONS,
+  'Go to next page',
+  'n',
+  ']'
+);
+describe(
+  Shortcut.PREV_PAGE,
+  ShortcutSection.ACTIONS,
+  'Go to previous page',
+  'p',
+  '['
+);
+describe(
+  Shortcut.OPEN_REPLY_DIALOG,
+  ShortcutSection.ACTIONS,
+  'Open reply dialog to publish comments and add reviewers',
+  'a:keyup'
+);
+describe(
+  Shortcut.OPEN_DOWNLOAD_DIALOG,
+  ShortcutSection.ACTIONS,
+  'Open download overlay',
+  'd:keyup'
+);
+describe(
+  Shortcut.EXPAND_ALL_MESSAGES,
+  ShortcutSection.ACTIONS,
+  'Expand all messages',
+  'x'
+);
+describe(
+  Shortcut.COLLAPSE_ALL_MESSAGES,
+  ShortcutSection.ACTIONS,
+  'Collapse all messages',
+  'z'
+);
+describe(
+  Shortcut.REFRESH_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Reload the change at the latest patch',
+  'shift+r:keyup'
+);
+describe(
+  Shortcut.TOGGLE_CHANGE_REVIEWED,
+  ShortcutSection.ACTIONS,
+  'Mark/unmark change as reviewed',
+  'r:keyup'
+);
+describe(
+  Shortcut.TOGGLE_FILE_REVIEWED,
+  ShortcutSection.ACTIONS,
+  'Toggle review flag on selected file',
+  'r:keyup'
+);
+describe(
+  Shortcut.REFRESH_CHANGE_LIST,
+  ShortcutSection.ACTIONS,
+  'Refresh list of changes',
+  'shift+r:keyup'
+);
+describe(
+  Shortcut.TOGGLE_CHANGE_STAR,
+  ShortcutSection.ACTIONS,
+  'Star/unstar change',
+  's:keydown'
+);
+describe(
+  Shortcut.OPEN_SUBMIT_DIALOG,
+  ShortcutSection.ACTIONS,
+  'Open submit dialog',
+  'shift+s'
+);
+describe(
+  Shortcut.TOGGLE_ATTENTION_SET,
+  ShortcutSection.ACTIONS,
+  'Toggle attention set status',
+  'shift+t'
+);
+describe(
+  Shortcut.EDIT_TOPIC,
+  ShortcutSection.ACTIONS,
+  'Add a change topic',
+  't'
+);
+describe(
+  Shortcut.DIFF_AGAINST_BASE,
+  ShortcutSection.DIFFS,
+  'Diff against base',
+  SPECIAL_SHORTCUT.V_KEY,
+  'down',
+  's'
+);
+describe(
+  Shortcut.DIFF_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff against latest patchset',
+  SPECIAL_SHORTCUT.V_KEY,
+  'up',
+  'w'
+);
+describe(
+  Shortcut.DIFF_BASE_AGAINST_LEFT,
+  ShortcutSection.DIFFS,
+  'Diff base against left',
+  SPECIAL_SHORTCUT.V_KEY,
+  'left',
+  'a'
+);
+describe(
+  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff right against latest',
+  SPECIAL_SHORTCUT.V_KEY,
+  'right',
+  'd'
+);
+describe(
+  Shortcut.DIFF_BASE_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff base against latest',
+  SPECIAL_SHORTCUT.V_KEY,
+  'b'
+);
+
+describe(
+  Shortcut.NEXT_LINE,
+  ShortcutSection.DIFFS,
+  'Go to next line',
+  'j',
+  'down'
+);
+describe(
+  Shortcut.PREV_LINE,
+  ShortcutSection.DIFFS,
+  'Go to previous line',
+  'k',
+  'up'
+);
+describe(
+  Shortcut.VISIBLE_LINE,
+  ShortcutSection.DIFFS,
+  'Move cursor to currently visible code',
+  '.'
+);
+describe(
+  Shortcut.NEXT_CHUNK,
+  ShortcutSection.DIFFS,
+  'Go to next diff chunk',
+  'n'
+);
+describe(
+  Shortcut.PREV_CHUNK,
+  ShortcutSection.DIFFS,
+  'Go to previous diff chunk',
+  'p'
+);
+describe(
+  Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
+  ShortcutSection.DIFFS,
+  'Toggle all diff context',
+  'shift+x'
+);
+describe(
+  Shortcut.NEXT_COMMENT_THREAD,
+  ShortcutSection.DIFFS,
+  'Go to next comment thread',
+  'shift+n'
+);
+describe(
+  Shortcut.PREV_COMMENT_THREAD,
+  ShortcutSection.DIFFS,
+  'Go to previous comment thread',
+  'shift+p'
+);
+describe(
+  Shortcut.EXPAND_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Expand all comment threads',
+  SPECIAL_SHORTCUT.DOC_ONLY,
+  'e'
+);
+describe(
+  Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Collapse all comment threads',
+  SPECIAL_SHORTCUT.DOC_ONLY,
+  'shift+e'
+);
+describe(
+  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Hide/Display all comment threads',
+  'h'
+);
+describe(
+  Shortcut.LEFT_PANE,
+  ShortcutSection.DIFFS,
+  'Select left pane',
+  'shift+left'
+);
+describe(
+  Shortcut.RIGHT_PANE,
+  ShortcutSection.DIFFS,
+  'Select right pane',
+  'shift+right'
+);
+describe(
+  Shortcut.TOGGLE_LEFT_PANE,
+  ShortcutSection.DIFFS,
+  'Hide/show left diff',
+  'shift+a'
+);
+describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', 'c');
+describe(
+  Shortcut.SAVE_COMMENT,
+  ShortcutSection.DIFFS,
+  'Save comment',
+  'ctrl+enter',
+  'meta+enter',
+  'ctrl+s',
+  'meta+s'
+);
+describe(
+  Shortcut.OPEN_DIFF_PREFS,
+  ShortcutSection.DIFFS,
+  'Show diff preferences',
+  ','
+);
+describe(
+  Shortcut.TOGGLE_DIFF_REVIEWED,
+  ShortcutSection.DIFFS,
+  'Mark/unmark file as reviewed',
+  'r:keyup'
+);
+describe(
+  Shortcut.TOGGLE_DIFF_MODE,
+  ShortcutSection.DIFFS,
+  'Toggle unified/side-by-side diff',
+  'm:keyup'
+);
+describe(
+  Shortcut.NEXT_UNREVIEWED_FILE,
+  ShortcutSection.DIFFS,
+  'Mark file as reviewed and go to next unreviewed file',
+  'shift+m'
+);
+describe(
+  Shortcut.TOGGLE_BLAME,
+  ShortcutSection.DIFFS,
+  'Toggle blame',
+  'b:keyup'
+);
+describe(Shortcut.OPEN_FILE_LIST, ShortcutSection.DIFFS, 'Open file list', 'f');
+describe(
+  Shortcut.NEXT_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to next file',
+  ']'
+);
+describe(
+  Shortcut.PREV_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to previous file',
+  '['
+);
+describe(
+  Shortcut.NEXT_FILE_WITH_COMMENTS,
+  ShortcutSection.NAVIGATION,
+  'Go to next file that has comments',
+  'shift+j'
+);
+describe(
+  Shortcut.PREV_FILE_WITH_COMMENTS,
+  ShortcutSection.NAVIGATION,
+  'Go to previous file that has comments',
+  'shift+k'
+);
+describe(
+  Shortcut.OPEN_FIRST_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to first file',
+  ']'
+);
+describe(
+  Shortcut.OPEN_LAST_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to last file',
+  '['
+);
+describe(
+  Shortcut.UP_TO_DASHBOARD,
+  ShortcutSection.NAVIGATION,
+  'Up to dashboard',
+  'u'
+);
+describe(
+  Shortcut.UP_TO_CHANGE,
+  ShortcutSection.NAVIGATION,
+  'Up to change',
+  'u'
+);
+
+describe(
+  Shortcut.CURSOR_NEXT_FILE,
+  ShortcutSection.FILE_LIST,
+  'Select next file',
+  'j',
+  'down'
+);
+describe(
+  Shortcut.CURSOR_PREV_FILE,
+  ShortcutSection.FILE_LIST,
+  'Select previous file',
+  'k',
+  'up'
+);
+describe(
+  Shortcut.OPEN_FILE,
+  ShortcutSection.FILE_LIST,
+  'Go to selected file',
+  'o',
+  'enter'
+);
+describe(
+  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+  ShortcutSection.FILE_LIST,
+  'Show/hide all inline diffs',
+  'shift+i'
+);
+describe(
+  Shortcut.TOGGLE_INLINE_DIFF,
+  ShortcutSection.FILE_LIST,
+  'Show/hide selected inline diff',
+  'i'
+);
+
+describe(
+  Shortcut.SEND_REPLY,
+  ShortcutSection.REPLY_DIALOG,
+  'Send reply',
+  SPECIAL_SHORTCUT.DOC_ONLY,
+  'ctrl+enter',
+  'meta+enter'
+);
+describe(
+  Shortcut.EMOJI_DROPDOWN,
+  ShortcutSection.REPLY_DIALOG,
+  'Emoji dropdown',
+  SPECIAL_SHORTCUT.DOC_ONLY,
+  ':'
+);
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
new file mode 100644
index 0000000..3cf46bd
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -0,0 +1,240 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+  config,
+  Shortcut,
+  ShortcutHelpItem,
+  ShortcutSection,
+  SPECIAL_SHORTCUT,
+} from './shortcuts-config';
+
+export type SectionView = Array<{binding: string[][]; text: string}>;
+
+/**
+ * The interface for listener for shortcut events.
+ */
+export type ShortcutListener = (
+  viewMap?: Map<ShortcutSection, SectionView>
+) => void;
+
+/**
+ * Shortcuts service, holds all hosts, bindings and listeners.
+ */
+export class ShortcutsService {
+  private readonly activeHosts = new Map<unknown, Map<string, string>>();
+
+  private readonly bindings = new Map<Shortcut, string[]>();
+
+  private readonly listeners = new Set<ShortcutListener>();
+
+  constructor() {
+    for (const section of config.keys()) {
+      const items = config.get(section) ?? [];
+      for (const item of items) {
+        this.bindings.set(item.shortcut, item.bindings);
+      }
+    }
+  }
+
+  public _testOnly_isEmpty() {
+    return this.activeHosts.size === 0 && this.listeners.size === 0;
+  }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    const desc = this.getDescription(section, shortcutName);
+    const shortcut = this.getShortcut(shortcutName);
+    return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
+  }
+
+  getBindingsForShortcut(shortcut: Shortcut) {
+    return this.bindings.get(shortcut);
+  }
+
+  attachHost(host: unknown, shortcuts: Map<string, string>) {
+    this.activeHosts.set(host, shortcuts);
+    this.notifyListeners();
+  }
+
+  detachHost(host: unknown) {
+    if (!this.activeHosts.delete(host)) return false;
+    this.notifyListeners();
+    return true;
+  }
+
+  addListener(listener: ShortcutListener) {
+    this.listeners.add(listener);
+    listener(this.directoryView());
+  }
+
+  removeListener(listener: ShortcutListener) {
+    return this.listeners.delete(listener);
+  }
+
+  getDescription(section: ShortcutSection, shortcutName: Shortcut) {
+    const bindings = config.get(section);
+    if (!bindings) return '';
+    const binding = bindings.find(binding => binding.shortcut === shortcutName);
+    return binding?.text ?? '';
+  }
+
+  getShortcut(shortcutName: Shortcut) {
+    const bindings = this.bindings.get(shortcutName);
+    if (!bindings) return '';
+    return bindings
+      .map(binding => this.describeBinding(binding).join('+'))
+      .join(',');
+  }
+
+  activeShortcutsBySection() {
+    const activeShortcuts = new Set<string>();
+    this.activeHosts.forEach(shortcuts => {
+      shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
+    });
+
+    const activeShortcutsBySection = new Map<
+      ShortcutSection,
+      ShortcutHelpItem[]
+    >();
+    config.forEach((shortcutList, section) => {
+      shortcutList.forEach(shortcutHelp => {
+        if (activeShortcuts.has(shortcutHelp.shortcut)) {
+          if (!activeShortcutsBySection.has(section)) {
+            activeShortcutsBySection.set(section, []);
+          }
+          // From previous condition, the `get(section)`
+          // should always return a valid result
+          activeShortcutsBySection.get(section)!.push(shortcutHelp);
+        }
+      });
+    });
+    return activeShortcutsBySection;
+  }
+
+  directoryView() {
+    const view = new Map<ShortcutSection, SectionView>();
+    this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
+      const sectionView: SectionView = [];
+      shortcutHelps.forEach(shortcutHelp => {
+        const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
+        if (!bindingDesc) {
+          return;
+        }
+        this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
+          sectionView.push({
+            binding: bindingDesc,
+            text: shortcutHelp.text,
+          });
+        });
+      });
+      view.set(section, sectionView);
+    });
+    return view;
+  }
+
+  distributeBindingDesc(bindingDesc: string[][]): string[][][] {
+    if (
+      bindingDesc.length === 1 ||
+      this.comboSetDisplayWidth(bindingDesc) < 21
+    ) {
+      return [bindingDesc];
+    }
+    // Find the largest prefix of bindings that is under the
+    // size threshold.
+    const head = [bindingDesc[0]];
+    for (let i = 1; i < bindingDesc.length; i++) {
+      head.push(bindingDesc[i]);
+      if (this.comboSetDisplayWidth(head) >= 21) {
+        head.pop();
+        return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i)));
+      }
+    }
+    return [];
+  }
+
+  comboSetDisplayWidth(bindingDesc: string[][]) {
+    const bindingSizer = (binding: string[]) =>
+      binding.reduce((acc, key) => acc + key.length, 0);
+    // Width is the sum of strings + (n-1) * 2 to account for the word
+    // "or" joining them.
+    return (
+      bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) +
+      2 * (bindingDesc.length - 1)
+    );
+  }
+
+  describeBindings(shortcut: Shortcut): string[][] | null {
+    const bindings = this.bindings.get(shortcut);
+    if (!bindings) {
+      return null;
+    }
+    if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
+      return bindings
+        .slice(1)
+        .map(binding => this._describeKey(binding))
+        .map(binding => ['g'].concat(binding));
+    }
+    if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
+      return bindings
+        .slice(1)
+        .map(binding => this._describeKey(binding))
+        .map(binding => ['v'].concat(binding));
+    }
+
+    return bindings
+      .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
+      .map(binding => this.describeBinding(binding));
+  }
+
+  _describeKey(key: string) {
+    switch (key) {
+      case 'shift':
+        return 'Shift';
+      case 'meta':
+        return 'Meta';
+      case 'ctrl':
+        return 'Ctrl';
+      case 'enter':
+        return 'Enter';
+      case 'up':
+        return '\u2191'; // ↑
+      case 'down':
+        return '\u2193'; // ↓
+      case 'left':
+        return '\u2190'; // ←
+      case 'right':
+        return '\u2192'; // →
+      default:
+        return key;
+    }
+  }
+
+  describeBinding(binding: string) {
+    // single key bindings
+    if (binding.length === 1) {
+      return [binding];
+    }
+    return binding
+      .split(':')[0]
+      .split('+')
+      .map(part => this._describeKey(part));
+  }
+
+  notifyListeners() {
+    const view = this.directoryView();
+    this.listeners.forEach(listener => listener(view));
+  }
+}
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
new file mode 100644
index 0000000..a48fa92
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -0,0 +1,224 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../test/common-test-setup-karma';
+import {ShortcutsService} from '../../services/shortcuts/shortcuts-service';
+import {Shortcut, ShortcutSection} from './shortcuts-config';
+
+suite('shortcuts-service tests', () => {
+  test('getShortcut', () => {
+    const mgr = new ShortcutsService();
+    const NEXT_FILE = Shortcut.NEXT_FILE;
+    assert.equal(mgr.getShortcut(NEXT_FILE), ']');
+  });
+
+  test('getShortcut with modifiers', () => {
+    const mgr = new ShortcutsService();
+    const NEXT_FILE = Shortcut.TOGGLE_LEFT_PANE;
+    assert.equal(mgr.getShortcut(NEXT_FILE), 'Shift+a');
+  });
+
+  suite('binding descriptions', () => {
+    function mapToObject<K, V>(m: Map<K, V>) {
+      const o: any = {};
+      m.forEach((v: V, k: K) => (o[k] = v));
+      return o;
+    }
+
+    test('single combo description', () => {
+      const mgr = new ShortcutsService();
+      assert.deepEqual(mgr.describeBinding('a'), ['a']);
+      assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
+      assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
+      assert.deepEqual(mgr.describeBinding('ctrl+shift+up:keyup'), [
+        'Ctrl',
+        'Shift',
+        '↑',
+      ]);
+    });
+
+    test('combo set description', () => {
+      const mgr = new ShortcutsService();
+      assert.deepEqual(mgr.describeBindings(Shortcut.GO_TO_OPENED_CHANGES), [
+        ['g', 'o'],
+      ]);
+      assert.deepEqual(mgr.describeBindings(Shortcut.SAVE_COMMENT), [
+        ['Ctrl', 'Enter'],
+        ['Meta', 'Enter'],
+        ['Ctrl', 's'],
+        ['Meta', 's'],
+      ]);
+      assert.deepEqual(mgr.describeBindings(Shortcut.PREV_FILE), [['[']]);
+    });
+
+    test('combo set description width', () => {
+      const mgr = new ShortcutsService();
+      assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
+      assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
+      assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
+      assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
+      assert.strictEqual(
+        mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
+        12
+      );
+    });
+
+    test('distribute shortcut help', () => {
+      const mgr = new ShortcutsService();
+      assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
+      assert.deepEqual(mgr.distributeBindingDesc([['g', 'o']]), [[['g', 'o']]]);
+      assert.deepEqual(
+        mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
+        [[['ctrl', 'shift', 'meta', 'enter']]]
+      );
+      assert.deepEqual(
+        mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter'], ['o']]),
+        [[['ctrl', 'shift', 'meta', 'enter']], [['o']]]
+      );
+      assert.deepEqual(
+        mgr.distributeBindingDesc([
+          ['ctrl', 'enter'],
+          ['meta', 'enter'],
+          ['ctrl', 's'],
+          ['meta', 's'],
+        ]),
+        [
+          [
+            ['ctrl', 'enter'],
+            ['meta', 'enter'],
+          ],
+          [
+            ['ctrl', 's'],
+            ['meta', 's'],
+          ],
+        ]
+      );
+    });
+
+    test('active shortcuts by section', () => {
+      const mgr = new ShortcutsService();
+      assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {});
+
+      mgr.attachHost({}, new Map([[Shortcut.NEXT_FILE, 'null']]));
+      assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {
+        [ShortcutSection.NAVIGATION]: [
+          {
+            shortcut: Shortcut.NEXT_FILE,
+            text: 'Go to next file',
+            bindings: [']'],
+          },
+        ],
+      });
+
+      mgr.attachHost({}, new Map([[Shortcut.NEXT_LINE, 'null']]));
+      assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {
+        [ShortcutSection.DIFFS]: [
+          {
+            shortcut: Shortcut.NEXT_LINE,
+            text: 'Go to next line',
+            bindings: ['j', 'down'],
+          },
+        ],
+        [ShortcutSection.NAVIGATION]: [
+          {
+            shortcut: Shortcut.NEXT_FILE,
+            text: 'Go to next file',
+            bindings: [']'],
+          },
+        ],
+      });
+
+      mgr.attachHost(
+        {},
+        new Map([
+          [Shortcut.SEARCH, 'null'],
+          [Shortcut.GO_TO_OPENED_CHANGES, 'null'],
+        ])
+      );
+      assert.deepEqual(mapToObject(mgr.activeShortcutsBySection()), {
+        [ShortcutSection.DIFFS]: [
+          {
+            shortcut: Shortcut.NEXT_LINE,
+            text: 'Go to next line',
+            bindings: ['j', 'down'],
+          },
+        ],
+        [ShortcutSection.EVERYWHERE]: [
+          {
+            shortcut: Shortcut.SEARCH,
+            text: 'Search',
+            bindings: ['/'],
+          },
+          {
+            shortcut: Shortcut.GO_TO_OPENED_CHANGES,
+            text: 'Go to Opened Changes',
+            bindings: ['GO_KEY', 'o'],
+          },
+        ],
+        [ShortcutSection.NAVIGATION]: [
+          {
+            shortcut: Shortcut.NEXT_FILE,
+            text: 'Go to next file',
+            bindings: [']'],
+          },
+        ],
+      });
+    });
+
+    test('directory view', () => {
+      const mgr = new ShortcutsService();
+
+      assert.deepEqual(mapToObject(mgr.directoryView()), {});
+
+      mgr.attachHost(
+        {},
+        new Map([
+          [Shortcut.GO_TO_OPENED_CHANGES, 'null'],
+          [Shortcut.NEXT_FILE, 'null'],
+          [Shortcut.NEXT_LINE, 'null'],
+          [Shortcut.SAVE_COMMENT, 'null'],
+          [Shortcut.SEARCH, 'null'],
+        ])
+      );
+      assert.deepEqual(mapToObject(mgr.directoryView()), {
+        [ShortcutSection.DIFFS]: [
+          {binding: [['j'], ['↓']], text: 'Go to next line'},
+          {
+            binding: [
+              ['Ctrl', 'Enter'],
+              ['Meta', 'Enter'],
+            ],
+            text: 'Save comment',
+          },
+          {
+            binding: [
+              ['Ctrl', 's'],
+              ['Meta', 's'],
+            ],
+            text: 'Save comment',
+          },
+        ],
+        [ShortcutSection.EVERYWHERE]: [
+          {binding: [['/']], text: 'Search'},
+          {binding: [['g', 'o']], text: 'Go to Opened Changes'},
+        ],
+        [ShortcutSection.NAVIGATION]: [
+          {binding: [[']']], text: 'Go to next file'},
+        ],
+      });
+    });
+  });
+});
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index f1711ac..a0e85b1 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -284,7 +284,7 @@
     --font-weight-bold: 500;
     --font-weight-h1: 400;
     --font-weight-h2: 400;
-    --font-weight-h3: 400;
+    --font-weight-h3: var(--font-weight-bold, 500);
     --context-control-button-font: var(--font-weight-normal) var(--font-size-normal) var(--font-family);
     --code-hint-font-weight: 500;
     --image-diff-button-font: var(--font-weight-normal) var(--font-size-normal) var(--font-family);
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 550d3df..949c268 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -30,9 +30,7 @@
   registerTestCleanup,
   addIronOverlayBackdropStyleEl,
   removeIronOverlayBackdropStyleEl,
-  TestKeyboardShortcutBinder,
 } from './test-utils';
-import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {safeTypesBridge} from '../utils/safe-types-util';
 import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
 import {initGlobalVariables} from '../elements/gr-app-global-var-init';
@@ -45,6 +43,7 @@
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
 import {updatePreferences} from '../services/user/user-model';
 import {createDefaultPreferences} from '../constants/constants';
+import {appContext} from '../services/app-context';
 
 declare global {
   interface Window {
@@ -101,14 +100,13 @@
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(getCleanupsCount(), 0);
+  _testOnlyInitAppContext();
   // The following calls is nessecary to avoid influence of previously executed
   // tests.
-  TestKeyboardShortcutBinder.push();
-  _testOnlyInitAppContext();
   initGlobalVariables();
   _testOnly_initGerritPluginApi();
-  const mgr = _testOnly_getShortcutManagerInstance();
-  assert.isTrue(mgr._testOnly_isEmpty());
+  const shortcuts = appContext.shortcutsService;
+  assert.isTrue(shortcuts._testOnly_isEmpty());
   const selection = document.getSelection();
   if (selection) {
     selection.removeAllRanges();
@@ -197,7 +195,6 @@
 teardown(() => {
   sinon.restore();
   cleanupTestUtils();
-  TestKeyboardShortcutBinder.pop();
   checkGlobalSpace();
   removeIronOverlayBackdropStyleEl();
   cancelAllTasks();
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index a60c1d1..1cde372 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -17,10 +17,6 @@
 import '../types/globals';
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
 import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
-import {
-  _testOnly_getShortcutManagerInstance,
-  Shortcut,
-} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {appContext} from '../services/app-context';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {SinonSpy} from 'sinon';
@@ -53,44 +49,6 @@
   return getComputedStyle(el).getPropertyValue('display') !== 'none';
 }
 
-// Some tests/elements can define its own binding. We want to restore bindings
-// at the end of the test. The TestKeyboardShortcutBinder store bindings in
-// stack, so it is possible to override bindings in nested suites.
-export class TestKeyboardShortcutBinder {
-  private static stack: TestKeyboardShortcutBinder[] = [];
-
-  static push() {
-    const testBinder = new TestKeyboardShortcutBinder();
-    this.stack.push(testBinder);
-    return _testOnly_getShortcutManagerInstance();
-  }
-
-  static pop() {
-    const item = this.stack.pop();
-    if (!item) {
-      throw new Error('stack is empty');
-    }
-    item._restoreShortcuts();
-  }
-
-  private readonly originalBinding: Map<Shortcut, string[]>;
-
-  constructor() {
-    this.originalBinding = new Map(
-      _testOnly_getShortcutManagerInstance()._testOnly_getBindings()
-    );
-  }
-
-  _restoreShortcuts() {
-    const bindings =
-      _testOnly_getShortcutManagerInstance()._testOnly_getBindings();
-    bindings.clear();
-    this.originalBinding.forEach((value, key) => {
-      bindings.set(key, value);
-    });
-  }
-}
-
 // Provide reset plugins function to clear installed plugins between tests.
 // No gr-app found (running tests)
 export const resetPlugins = () => {
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 0002254..9e3bc74 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -104,8 +104,11 @@
   selector: string
 ): E | undefined {
   if (!el) return undefined;
-  const root = el.shadowRoot ?? el;
-  return root.querySelector<E>(selector) ?? undefined;
+  if (el.shadowRoot) {
+    const r = el.shadowRoot.querySelector<E>(selector);
+    if (r) return r;
+  }
+  return el.querySelector<E>(selector) ?? undefined;
 }
 
 export function queryAndAssert<E extends Element = Element>(