Merge "Replace "Since" by "Waiting""
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index c7c6c63..21bcc77 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -625,7 +625,7 @@
   private void processCommandsUnsafe(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
     logger.atFine().log("Calling user: %s", user.getLoggableName());
-    logger.atFine().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+    logger.atFine().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
 
     if (!projectState.getProject().getState().permitsWrite()) {
       for (ReceiveCommand cmd : commands) {
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 37de0d1..defec4b 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.flogger.LazyArgs.lazy;
 import static com.google.gerrit.entities.RefNames.REFS_CACHE_AUTOMERGE;
 import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
 import static com.google.gerrit.entities.RefNames.REFS_USERS_SELF;
@@ -134,7 +135,7 @@
         "Filter refs for repository %s by visibility (options = %s, refs = %s)",
         projectState.getNameKey(), opts, refs);
     logger.atFinest().log("Calling user: %s", user.getLoggableName());
-    logger.atFinest().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+    logger.atFinest().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
     logger.atFinest().log(
         "auth.skipFullRefEvaluationIfAllRefsAreVisible = %s",
         skipFullRefEvaluationIfAllRefsAreVisible);
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 226cc4c..399ae6e 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -277,6 +277,12 @@
         "@typescript-eslint/restrict-plus-operands": "error",
         // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
         "node/no-unsupported-features/node-builtins": "off",
+        // Disable no-invalid-this for ts files, because it incorrectly reports
+        // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
+        // At the same time, we are using typescript in a strict mode and
+        // it catches almost all errors related to invalid usage of this.
+        "no-invalid-this": "off",
+
         "jsdoc/no-types": 2,
       },
       "parserOptions": {
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 5a925ff..8e8eaf3 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -341,3 +341,20 @@
   OWNER_REVIEWERS = 'OWNER_REVIEWERS',
   ALL = 'ALL',
 }
+
+/**
+ * The authentication type that is configured on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum AuthType {
+  OPENID = 'OPENID',
+  OPENID_SSO = 'OPENID_SSO',
+  OAUTH = 'OAUTH',
+  HTTP = 'HTTP',
+  HTTP_LDAP = 'HTTP_LDAP',
+  CLIENT_SSL_CERT_LDAP = 'CLIENT_SSL_CERT_LDAP',
+  LDAP = 'LDAP',
+  LDAP_BIND = 'LDAP_BIND',
+  CUSTOM_EXTENSION = 'CUSTOM_EXTENSION',
+  DEVELOPMENT_BECOME_ANY_ACCOUNT = 'DEVELOPMENT_BECOME_ANY_ACCOUNT',
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index e5b3daf..20fe1a6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -37,7 +37,6 @@
 import '../gr-commit-info/gr-commit-info.js';
 import '../gr-download-dialog/gr-download-dialog.js';
 import '../gr-file-list-header/gr-file-list-header.js';
-import '../gr-file-list/gr-file-list.js';
 import '../gr-included-in-dialog/gr-included-in-dialog.js';
 import '../gr-messages-list/gr-messages-list.js';
 import '../gr-related-changes-list/gr-related-changes-list.js';
@@ -73,6 +72,7 @@
 } from '../../../utils/patch-set-util.js';
 import {changeStatuses, changeStatusString} from '../../../utils/change-util.js';
 import {EventType} from '../../plugins/gr-plugin-types.js';
+import {DEFAULT_NUM_FILES_SHOWN} from '../gr-file-list/gr-file-list.js';
 
 const CHANGE_ID_ERROR = {
   MISMATCH: 'mismatch',
@@ -82,7 +82,6 @@
   /^(Change-Id\:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
-const DEFAULT_NUM_FILES_SHOWN = 200;
 
 const REVIEWERS_REGEX = /^(R|CC)=/gm;
 const MIN_CHECK_INTERVAL_SECS = 0;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
deleted file mode 100644
index bf45eb6..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ /dev/null
@@ -1,1614 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../../styles/shared-styles.js';
-import '../../diff/gr-diff-cursor/gr-diff-cursor.js';
-import '../../diff/gr-diff-host/gr-diff-host.js';
-import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
-import '../../edit/gr-edit-file-controls/gr-edit-file-controls.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-linked-text/gr-linked-text.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-file-list_html.js';
-import {asyncForeach} from '../../../utils/async-util.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {FilesExpandedState} from '../gr-file-list-constants.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {descendedFromClass} from '../../../utils/dom-util.js';
-import {
-  addUnmodifiedFiles,
-  computeDisplayPath,
-  computeTruncatedPath,
-  isMagicPath,
-  specialFilePathCompare,
-} from '../../../utils/path-list-util.js';
-
-const WARN_SHOW_ALL_THRESHOLD = 1000;
-const LOADING_DEBOUNCE_INTERVAL = 100;
-
-const SIZE_BAR_MAX_WIDTH = 61;
-const SIZE_BAR_GAP_WIDTH = 1;
-const SIZE_BAR_MIN_WIDTH = 1.5;
-
-const RENDER_TIMING_LABEL = 'FileListRenderTime';
-const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
-const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
-const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
-
-const FileStatus = {
-  A: 'Added',
-  C: 'Copied',
-  D: 'Deleted',
-  M: 'Modified',
-  R: 'Renamed',
-  W: 'Rewritten',
-  U: 'Unchanged',
-};
-
-const FILE_ROW_CLASS = 'file-row';
-
-/**
- * Type for FileInfo
- *
- * This should match with the type returned from `files` API plus
- * additional info like `__path`.
- *
- * @typedef {Object} FileInfo
- * @property {string} __path
- * @property {?string} old_path
- * @property {number} size
- * @property {number} size_delta - fallback to 0 if not present in api
- * @property {number} lines_deleted - fallback to 0 if not present in api
- * @property {number} lines_inserted - fallback to 0 if not present in api
- */
-
-/**
- * @extends PolymerElement
- */
-class GrFileList extends KeyboardShortcutMixin(
-    GestureEventListeners(
-        LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-file-list'; }
-  /**
-   * Fired when a draft refresh should get triggered
-   *
-   * @event reload-drafts
-   */
-
-  static get properties() {
-    return {
-    /** @type {?} */
-      patchRange: Object,
-      patchNum: String,
-      changeNum: String,
-      /** @type {?} */
-      changeComments: Object,
-      drafts: Object,
-      revisions: Array,
-      projectConfig: Object,
-      selectedIndex: {
-        type: Number,
-        notify: true,
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      /** @type {?} */
-      change: Object,
-      diffViewMode: {
-        type: String,
-        notify: true,
-        observer: '_updateDiffPreferences',
-      },
-      editMode: {
-        type: Boolean,
-        observer: '_editModeChanged',
-      },
-      filesExpanded: {
-        type: String,
-        value: FilesExpandedState.NONE,
-        notify: true,
-      },
-      _filesByPath: Object,
-
-      /** @type {!Array<FileInfo>} */
-      _files: {
-        type: Array,
-        observer: '_filesChanged',
-        value() { return []; },
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _reviewed: {
-        type: Array,
-        value() { return []; },
-      },
-      diffPrefs: {
-        type: Object,
-        notify: true,
-        observer: '_updateDiffPreferences',
-      },
-      /** @type {?} */
-      _userPrefs: Object,
-      _showInlineDiffs: Boolean,
-      numFilesShown: {
-        type: Number,
-        notify: true,
-      },
-      /** @type {?} */
-      _patchChange: {
-        type: Object,
-        computed: '_calculatePatchChange(_files)',
-      },
-      fileListIncrement: Number,
-      _hideChangeTotals: {
-        type: Boolean,
-        computed: '_shouldHideChangeTotals(_patchChange)',
-      },
-      _hideBinaryChangeTotals: {
-        type: Boolean,
-        computed: '_shouldHideBinaryChangeTotals(_patchChange)',
-      },
-
-      _shownFiles: {
-        type: Array,
-        computed: '_computeFilesShown(numFilesShown, _files)',
-      },
-
-      /**
-       * The amount of files added to the shown files list the last time it was
-       * updated. This is used for reporting the average render time.
-       */
-      _reportinShownFilesIncrement: Number,
-
-      /** @type {!Array<Gerrit.FileRange>} */
-      _expandedFiles: {
-        type: Array,
-        value() { return []; },
-      },
-      _displayLine: Boolean,
-      _loading: {
-        type: Boolean,
-        observer: '_loadingChanged',
-      },
-      /** @type {Gerrit.LayoutStats|undefined} */
-      _sizeBarLayout: {
-        type: Object,
-        computed: '_computeSizeBarLayout(_shownFiles.*)',
-      },
-
-      _showSizeBars: {
-        type: Boolean,
-        value: true,
-        computed: '_computeShowSizeBars(_userPrefs)',
-      },
-
-      /** @type {Function} */
-      _cancelForEachDiff: Function,
-
-      _showDynamicColumns: {
-        type: Boolean,
-        computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
-                '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
-      },
-      _showPrependedDynamicColumns: {
-        type: Boolean,
-        computed: '_computeShowPrependedDynamicColumns(' +
-        '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
-      },
-      /** @type {Array<string>} */
-      _dynamicHeaderEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicContentEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicSummaryEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicPrependedHeaderEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicPrependedContentEndpoints: {
-        type: Array,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_expandedFilesChanged(_expandedFiles.splices)',
-      '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
-        '_loading)',
-    ];
-  }
-
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-    };
-  }
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.LEFT_PANE]: '_handleLeftPane',
-      [Shortcut.RIGHT_PANE]: '_handleRightPane',
-      [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
-      [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
-      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
-        '_handleToggleHideAllCommentThreads',
-      [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
-      [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
-      [Shortcut.NEXT_LINE]: '_handleCursorNext',
-      [Shortcut.PREV_LINE]: '_handleCursorPrev',
-      [Shortcut.NEW_COMMENT]: '_handleNewComment',
-      [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
-      [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
-      [Shortcut.OPEN_FILE]: '_handleOpenFile',
-      [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
-      [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
-      [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
-      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-
-      // Final two are actually handled by gr-comment-thread.
-      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('keydown',
-        e => this._scopedKeydownHandler(e));
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          this._dynamicHeaderEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-header');
-          this._dynamicContentEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-content');
-          this._dynamicPrependedHeaderEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-header-prepend');
-          this._dynamicPrependedContentEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-content-prepend');
-          this._dynamicSummaryEndpoints = getPluginEndpoints()
-              .getDynamicEndpoints('change-view-file-list-summary');
-
-          if (this._dynamicHeaderEndpoints.length !==
-          this._dynamicContentEndpoints.length) {
-            console.warn(
-                'Different number of dynamic file-list header and content.');
-          }
-          if (this._dynamicPrependedHeaderEndpoints.length !==
-        this._dynamicPrependedContentEndpoints.length) {
-            console.warn(
-                'Different number of dynamic file-list header and content.');
-          }
-          if (this._dynamicHeaderEndpoints.length !==
-          this._dynamicSummaryEndpoints.length) {
-            console.warn(
-                'Different number of dynamic file-list headers and summary.');
-          }
-        });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this._cancelDiffs();
-  }
-
-  /**
-   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
-   * events must be scoped to a component level (e.g. `enter`) in order to not
-   * override native browser functionality.
-   *
-   * Context: Issue 7277
-   */
-  _scopedKeydownHandler(e) {
-    if (e.keyCode === 13) {
-      // Enter.
-      this._handleOpenFile(e);
-    }
-  }
-
-  reload() {
-    if (!this.changeNum || !this.patchRange.patchNum) {
-      return Promise.resolve();
-    }
-
-    this._loading = true;
-
-    this.collapseAllDiffs();
-    const promises = [];
-
-    promises.push(this._getFiles().then(filesByPath => {
-      this._filesByPath = filesByPath;
-    }));
-    promises.push(this._getLoggedIn()
-        .then(loggedIn => this._loggedIn = loggedIn)
-        .then(loggedIn => {
-          if (!loggedIn) { return; }
-
-          return this._getReviewedFiles().then(reviewed => {
-            this._reviewed = reviewed;
-          });
-        }));
-
-    promises.push(this._getDiffPreferences().then(prefs => {
-      this.diffPrefs = prefs;
-    }));
-
-    promises.push(this._getPreferences().then(prefs => {
-      this._userPrefs = prefs;
-    }));
-
-    return Promise.all(promises).then(() => {
-      this._loading = false;
-      this._detectChromiteButler();
-      this.reporting.fileListDisplayed();
-    });
-  }
-
-  _detectChromiteButler() {
-    const hasButler = !!document.getElementById('butler-suggested-owners');
-    if (hasButler) {
-      this.reporting.reportExtension('butler');
-    }
-  }
-
-  get diffs() {
-    const diffs = this.root.querySelectorAll('gr-diff-host');
-    // It is possible that a bogus diff element is hanging around invisibly
-    // from earlier with a different patch set choice and associated with a
-    // different entry in the files array. So filter on visible items only.
-    return Array.from(diffs).filter(
-        el => !!el && !!el.style && el.style.display !== 'none');
-  }
-
-  openDiffPrefs() {
-    this.$.diffPreferencesDialog.open();
-  }
-
-  _calculatePatchChange(files) {
-    const magicFilesExcluded = files.filter(files =>
-      !isMagicPath(files.__path)
-    );
-
-    return magicFilesExcluded.reduce((acc, obj) => {
-      const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
-      const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
-      const total_size = (obj.size && obj.binary) ? obj.size : 0;
-      const size_delta_inserted =
-          obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
-      const size_delta_deleted =
-          obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
-
-      return {
-        inserted: acc.inserted + inserted,
-        deleted: acc.deleted + deleted,
-        size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
-        size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
-        total_size: acc.total_size + total_size,
-      };
-    }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
-      size_delta_deleted: 0, total_size: 0});
-  }
-
-  _getDiffPreferences() {
-    return this.$.restAPI.getDiffPreferences();
-  }
-
-  _getPreferences() {
-    return this.$.restAPI.getPreferences();
-  }
-
-  _toggleFileExpanded(file) {
-    // Is the path in the list of expanded diffs? IF so remove it, otherwise
-    // add it to the list.
-    const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
-    if (pathIndex === -1) {
-      this.push('_expandedFiles', file);
-    } else {
-      this.splice('_expandedFiles', pathIndex, 1);
-    }
-  }
-
-  _toggleFileExpandedByIndex(index) {
-    this._toggleFileExpanded(this._computeFileRange(this._files[index]));
-  }
-
-  _updateDiffPreferences() {
-    if (!this.diffs.length) { return; }
-    // Re-render all expanded diffs sequentially.
-    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
-    this._renderInOrder(this._expandedFiles, this.diffs,
-        this._expandedFiles.length);
-  }
-
-  _forEachDiff(fn) {
-    const diffs = this.diffs;
-    for (let i = 0; i < diffs.length; i++) {
-      fn(diffs[i]);
-    }
-  }
-
-  expandAllDiffs() {
-    this._showInlineDiffs = true;
-
-    // Find the list of paths that are in the file list, but not in the
-    // expanded list.
-    const newFiles = [];
-    let path;
-    for (let i = 0; i < this._shownFiles.length; i++) {
-      path = this._shownFiles[i].__path;
-      if (!this._expandedFiles.some(f => f.path === path)) {
-        newFiles.push(this._computeFileRange(this._shownFiles[i]));
-      }
-    }
-
-    this.splice(...['_expandedFiles', 0, 0].concat(newFiles));
-  }
-
-  collapseAllDiffs() {
-    this._showInlineDiffs = false;
-    this._expandedFiles = [];
-    this.filesExpanded = this._computeExpandedFiles(
-        this._expandedFiles.length, this._files.length);
-    this.$.diffCursor.handleDiffUpdate();
-  }
-
-  /**
-   * Computes a string with the number of comments and unresolved comments.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeCommentsString(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const unresolvedCount =
-        changeComments.computeUnresolvedNum({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeUnresolvedNum({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    const commentCount =
-        changeComments.computeCommentCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeCommentCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    const commentString = GrCountStringFormatter.computePluralString(
-        commentCount, 'comment');
-    const unresolvedString = GrCountStringFormatter.computeString(
-        unresolvedCount, 'unresolved');
-
-    return commentString +
-        // Add a space if both comments and unresolved
-        (commentString && unresolvedString ? ' ' : '') +
-        // Add parentheses around unresolved if it exists.
-        (unresolvedString ? `(${unresolvedString})` : '');
-  }
-
-  /**
-   * Computes a string with the number of drafts.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeDraftsString(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const draftCount =
-        changeComments.computeDraftCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeDraftCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
-  }
-
-  /**
-   * Computes a shortened string with the number of drafts.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeDraftsStringMobile(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const draftCount =
-        changeComments.computeDraftCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeDraftCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    return GrCountStringFormatter.computeShortString(draftCount, 'd');
-  }
-
-  /**
-   * Computes a shortened string with the number of comments.
-   *
-   * @param {!Object} changeComments
-   * @param {!Object} patchRange
-   * @param {string} path
-   * @return {string}
-   */
-  _computeCommentsStringMobile(changeComments, patchRange, path) {
-    if ([changeComments, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    const commentCount =
-        changeComments.computeCommentCount({
-          patchNum: patchRange.basePatchNum,
-          path,
-        }) +
-        changeComments.computeCommentCount({
-          patchNum: patchRange.patchNum,
-          path,
-        });
-    return GrCountStringFormatter.computeShortString(commentCount, 'c');
-  }
-
-  /**
-   * @param {string} path
-   * @param {boolean=} opt_reviewed
-   */
-  _reviewFile(path, opt_reviewed) {
-    if (this.editMode) { return; }
-    const index = this._files.findIndex(file => file.__path === path);
-    const reviewed = opt_reviewed || !this._files[index].isReviewed;
-
-    this.set(['_files', index, 'isReviewed'], reviewed);
-    if (index < this._shownFiles.length) {
-      this.notifyPath(`_shownFiles.${index}.isReviewed`);
-    }
-
-    this._saveReviewedState(path, reviewed);
-  }
-
-  _saveReviewedState(path, reviewed) {
-    return this.$.restAPI.saveFileReviewed(this.changeNum,
-        this.patchRange.patchNum, path, reviewed);
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _getReviewedFiles() {
-    if (this.editMode) { return Promise.resolve([]); }
-    return this.$.restAPI.getReviewedFiles(this.changeNum,
-        this.patchRange.patchNum);
-  }
-
-  _getFiles() {
-    return this.$.restAPI.getChangeOrEditFiles(
-        this.changeNum, this.patchRange);
-  }
-
-  /**
-   *
-   * @returns {!Array<FileInfo>}
-   */
-  _normalizeChangeFilesResponse(response) {
-    if (!response) { return []; }
-    const paths = Object.keys(response).sort(specialFilePathCompare);
-    const files = [];
-    for (let i = 0; i < paths.length; i++) {
-      const info = response[paths[i]];
-      info.__path = paths[i];
-      info.lines_inserted = info.lines_inserted || 0;
-      info.lines_deleted = info.lines_deleted || 0;
-      info.size_delta = info.size_delta || 0;
-      files.push(info);
-    }
-    return files;
-  }
-
-  /**
-   * Returns true if the event e is a click on an element.
-   *
-   * The click is: mouse click or pressing Enter or Space key
-   * P.S> Screen readers sends click event as well
-   */
-  _isClickEvent(e) {
-    if (e.type === 'click') {
-      return true;
-    }
-    const isSpaceOrEnter = (e.key === 'Enter' || e.key === ' ');
-    return e.type === 'keydown' && isSpaceOrEnter;
-  }
-
-  _fileActionClick(e, fileAction) {
-    if (this._isClickEvent(e)) {
-      const fileRow = this._getFileRowFromEvent(e);
-      if (!fileRow) {
-        return;
-      }
-      // Prevent default actions (e.g. scrolling for space key)
-      e.preventDefault();
-      // Prevent _handleFileListClick handler call
-      e.stopPropagation();
-      this.$.fileCursor.setCursor(fileRow.element);
-      fileAction(fileRow.file);
-    }
-  }
-
-  _reviewedClick(e) {
-    this._fileActionClick(e,
-        file => this._reviewFile(file.path));
-  }
-
-  _expandedClick(e) {
-    this._fileActionClick(e,
-        file => this._toggleFileExpanded(file));
-  }
-
-  /**
-   * Handle all events from the file list dom-repeat so event handleers don't
-   * have to get registered for potentially very long lists.
-   */
-  _handleFileListClick(e) {
-    const fileRow = this._getFileRowFromEvent(e);
-    if (!fileRow) {
-      return;
-    }
-    const file = fileRow.file;
-    const path = file.path;
-    // If a path cannot be interpreted from the click target (meaning it's not
-    // somewhere in the row, e.g. diff content) or if the user clicked the
-    // link, defer to the native behavior.
-    if (!path || descendedFromClass(e.target, 'pathLink')) { return; }
-
-    // Disregard the event if the click target is in the edit controls.
-    if (descendedFromClass(e.target, 'editFileControls')) { return; }
-
-    e.preventDefault();
-    this.$.fileCursor.setCursor(fileRow.element);
-    this._toggleFileExpanded(file);
-  }
-
-  _getFileRowFromEvent(e) {
-    // Traverse upwards to find the row element if the target is not the row.
-    let row = e.target;
-    while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
-      row = row.parentElement;
-    }
-
-    // No action needed for item without a valid file
-    if (!row.dataset['file']) {
-      return null;
-    }
-
-    return {
-      file: JSON.parse(row.dataset['file']),
-      element: row,
-    };
-  }
-
-  /**
-   * Generates file range from file info object.
-   *
-   * @param {FileInfo} file
-   * @returns {Gerrit.FileRange}
-   */
-  _computeFileRange(file) {
-    const fileData = {
-      path: file.__path,
-    };
-    if (file.old_path) {
-      fileData.basePath = file.old_path;
-    }
-    return fileData;
-  }
-
-  _handleLeftPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    this.$.diffCursor.moveLeft();
-  }
-
-  _handleRightPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    this.$.diffCursor.moveRight();
-  }
-
-  _handleToggleInlineDiff(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e) ||
-        this.$.fileCursor.index === -1) { return; }
-
-    e.preventDefault();
-    this._toggleFileExpandedByIndex(this.$.fileCursor.index);
-  }
-
-  _handleToggleAllInlineDiffs(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this._toggleInlineDiffs();
-  }
-
-  _handleToggleHideAllCommentThreads(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    this.toggleClass('hideComments');
-  }
-
-  _handleCursorNext(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    if (this._showInlineDiffs) {
-      e.preventDefault();
-      this.$.diffCursor.moveDown();
-      this._displayLine = true;
-    } else {
-      // Down key
-      if (this.getKeyboardEvent(e).keyCode === 40) { return; }
-      e.preventDefault();
-      this.$.fileCursor.next();
-      this.selectedIndex = this.$.fileCursor.index;
-    }
-  }
-
-  _handleCursorPrev(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    if (this._showInlineDiffs) {
-      e.preventDefault();
-      this.$.diffCursor.moveUp();
-      this._displayLine = true;
-    } else {
-      // Up key
-      if (this.getKeyboardEvent(e).keyCode === 38) { return; }
-      e.preventDefault();
-      this.$.fileCursor.previous();
-      this.selectedIndex = this.$.fileCursor.index;
-    }
-  }
-
-  _handleNewComment(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-    this.$.diffCursor.createCommentInPlace();
-  }
-
-  _handleOpenLastFile(e) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.getKeyboardEvent(e).metaKey) { return; }
-
-    e.preventDefault();
-    this._openSelectedFile(this._files.length - 1);
-  }
-
-  _handleOpenFirstFile(e) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.getKeyboardEvent(e).metaKey) { return; }
-
-    e.preventDefault();
-    this._openSelectedFile(0);
-  }
-
-  _handleOpenFile(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-
-    if (this._showInlineDiffs) {
-      this._openCursorFile();
-      return;
-    }
-
-    this._openSelectedFile();
-  }
-
-  _handleNextChunk(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
-        this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    if (this.isModifierPressed(e, 'shiftKey')) {
-      this.$.diffCursor.moveToNextCommentThread();
-    } else {
-      this.$.diffCursor.moveToNextChunk();
-    }
-  }
-
-  _handlePrevChunk(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
-        this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    if (this.isModifierPressed(e, 'shiftKey')) {
-      this.$.diffCursor.moveToPreviousCommentThread();
-    } else {
-      this.$.diffCursor.moveToPreviousChunk();
-    }
-  }
-
-  _handleToggleFileReviewed(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    if (!this._files[this.$.fileCursor.index]) { return; }
-    this._reviewFile(this._files[this.$.fileCursor.index].__path);
-  }
-
-  _handleToggleLeftPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this._forEachDiff(diff => {
-      diff.toggleLeftDiff();
-    });
-  }
-
-  _toggleInlineDiffs() {
-    if (this._showInlineDiffs) {
-      this.collapseAllDiffs();
-    } else {
-      this.expandAllDiffs();
-    }
-  }
-
-  _openCursorFile() {
-    const diff = this.$.diffCursor.getTargetDiffElement();
-    GerritNav.navigateToDiff(this.change, diff.path,
-        diff.patchRange.patchNum, this.patchRange.basePatchNum);
-  }
-
-  /**
-   * @param {number=} opt_index
-   */
-  _openSelectedFile(opt_index) {
-    if (opt_index != null) {
-      this.$.fileCursor.setCursorAtIndex(opt_index);
-    }
-    if (!this._files[this.$.fileCursor.index]) { return; }
-    GerritNav.navigateToDiff(this.change,
-        this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
-        this.patchRange.basePatchNum);
-  }
-
-  _addDraftAtTarget() {
-    const diff = this.$.diffCursor.getTargetDiffElement();
-    const target = this.$.diffCursor.getTargetLineElement();
-    if (diff && target) {
-      diff.addDraftAtLine(target);
-    }
-  }
-
-  _shouldHideChangeTotals(_patchChange) {
-    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
-  }
-
-  _shouldHideBinaryChangeTotals(_patchChange) {
-    return _patchChange.size_delta_inserted === 0 &&
-        _patchChange.size_delta_deleted === 0;
-  }
-
-  _computeFileStatus(status) {
-    return status || 'M';
-  }
-
-  _computeDiffURL(change, patchRange, path, editMode) {
-    // Polymer 2: check for undefined
-    if ([change, patchRange, path, editMode]
-        .some(arg => arg === undefined)) {
-      return;
-    }
-    if (editMode && path !== SpecialFilePath.MERGE_LIST) {
-      return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum,
-          patchRange.basePatchNum);
-    }
-    return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
-        patchRange.basePatchNum);
-  }
-
-  _formatBytes(bytes) {
-    if (bytes == 0) return '+/-0 B';
-    const bits = 1024;
-    const decimals = 1;
-    const sizes =
-        ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
-    const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
-    const prepend = bytes > 0 ? '+' : '';
-    return prepend + parseFloat((bytes / Math.pow(bits, exponent))
-        .toFixed(decimals)) + ' ' + sizes[exponent];
-  }
-
-  _formatPercentage(size, delta) {
-    const oldSize = size - delta;
-
-    if (oldSize === 0) { return ''; }
-
-    const percentage = Math.round(Math.abs(delta * 100 / oldSize));
-    return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
-  }
-
-  _computeBinaryClass(delta) {
-    if (delta === 0) { return; }
-    return delta >= 0 ? 'added' : 'removed';
-  }
-
-  /**
-   * @param {string} baseClass
-   * @param {string} path
-   */
-  _computeClass(baseClass, path) {
-    const classes = [];
-    if (baseClass) {
-      classes.push(baseClass);
-    }
-    if (path === SpecialFilePath.COMMIT_MESSAGE ||
-      path === SpecialFilePath.MERGE_LIST) {
-      classes.push('invisible');
-    }
-    return classes.join(' ');
-  }
-
-  _computeStatusClass(file) {
-    const classStr = this._computeClass('status', file.__path);
-    return `${classStr} ${this._computeFileStatus(file.status)}`;
-  }
-
-  _computePathClass(path, expandedFilesRecord) {
-    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
-  }
-
-  _computeShowHideIcon(path, expandedFilesRecord) {
-    return this._isFileExpanded(path, expandedFilesRecord) ?
-      'gr-icons:expand-less' : 'gr-icons:expand-more';
-  }
-
-  _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
-    // Polymer 2: check for undefined
-    if ([
-      filesByPath,
-      changeComments,
-      patchRange,
-      reviewed,
-      loading,
-    ].includes(undefined)) {
-      return;
-    }
-
-    // Await all promises resolving from reload. @See Issue 9057
-    if (loading || !changeComments) { return; }
-
-    const commentedPaths = changeComments.getPaths(patchRange);
-    const files = {...filesByPath};
-    addUnmodifiedFiles(files, commentedPaths);
-    const reviewedSet = new Set(reviewed || []);
-    for (const filePath in files) {
-      if (!files.hasOwnProperty(filePath)) { continue; }
-      files[filePath].isReviewed = reviewedSet.has(filePath);
-    }
-
-    this._files = this._normalizeChangeFilesResponse(files);
-  }
-
-  _computeFilesShown(numFilesShown, files) {
-    // Polymer 2: check for undefined
-    if ([numFilesShown, files].includes(undefined)) {
-      return undefined;
-    }
-
-    const previousNumFilesShown = this._shownFiles ?
-      this._shownFiles.length : 0;
-
-    const filesShown = files.slice(0, numFilesShown);
-    this.dispatchEvent(new CustomEvent('files-shown-changed', {
-      detail: {length: filesShown.length},
-      composed: true, bubbles: true,
-    }));
-
-    // Start the timer for the rendering work hwere because this is where the
-    // _shownFiles property is being set, and _shownFiles is used in the
-    // dom-repeat binding.
-    this.reporting.time(RENDER_TIMING_LABEL);
-
-    // How many more files are being shown (if it's an increase).
-    this._reportinShownFilesIncrement =
-        Math.max(0, filesShown.length - previousNumFilesShown);
-
-    return filesShown;
-  }
-
-  _updateDiffCursor() {
-    // Overwrite the cursor's list of diffs:
-    this.$.diffCursor.splice(
-        ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
-  }
-
-  _filesChanged() {
-    if (this._files && this._files.length > 0) {
-      flush();
-      this.$.fileCursor.stops = Array.from(
-          this.root.querySelectorAll(`.${FILE_ROW_CLASS}`));
-      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
-    }
-  }
-
-  _incrementNumFilesShown() {
-    this.numFilesShown += this.fileListIncrement;
-  }
-
-  _computeFileListControlClass(numFilesShown, files) {
-    return numFilesShown >= files.length ? 'invisible' : '';
-  }
-
-  _computeIncrementText(numFilesShown, files) {
-    if (!files) { return ''; }
-    const text =
-        Math.min(this.fileListIncrement, files.length - numFilesShown);
-    return 'Show ' + text + ' more';
-  }
-
-  _computeShowAllText(files) {
-    if (!files) { return ''; }
-    return 'Show all ' + files.length + ' files';
-  }
-
-  _computeWarnShowAll(files) {
-    return files.length > WARN_SHOW_ALL_THRESHOLD;
-  }
-
-  _computeShowAllWarning(files) {
-    if (!this._computeWarnShowAll(files)) { return ''; }
-    return 'Warning: showing all ' + files.length +
-        ' files may take several seconds.';
-  }
-
-  _showAllFiles() {
-    this.numFilesShown = this._files.length;
-  }
-
-  /**
-   * Get a descriptive label for use in the status indicator's tooltip and
-   * ARIA label.
-   *
-   * @param {string} status
-   * @return {string}
-   */
-  _computeFileStatusLabel(status) {
-    const statusCode = this._computeFileStatus(status);
-    return FileStatus.hasOwnProperty(statusCode) ?
-      FileStatus[statusCode] : 'Status Unknown';
-  }
-
-  /**
-   * Converts any boolean-like variable to the string 'true' or 'false'
-   *
-   * This method is useful when you bind aria-checked attribute to a boolean
-   * value. The aria-checked attribute is string attribute. Binding directly
-   * to boolean variable causes problem on gerrit-CI.
-   *
-   * @param {object} val
-   * @return {string} 'true' if val is true-like, otherwise false
-   */
-  _booleanToString(val) {
-    return val ? 'true' : 'false';
-  }
-
-  _isFileExpanded(path, expandedFilesRecord) {
-    return expandedFilesRecord.base.some(f => f.path === path);
-  }
-
-  _isFileExpandedStr(path, expandedFilesRecord) {
-    return this._booleanToString(
-        this._isFileExpanded(path, expandedFilesRecord));
-  }
-
-  _computeExpandedFiles(expandedCount, totalCount) {
-    if (expandedCount === 0) {
-      return FilesExpandedState.NONE;
-    } else if (expandedCount === totalCount) {
-      return FilesExpandedState.ALL;
-    }
-    return FilesExpandedState.SOME;
-  }
-
-  /**
-   * Handle splices to the list of expanded file paths. If there are any new
-   * entries in the expanded list, then render each diff corresponding in
-   * order by waiting for the previous diff to finish before starting the next
-   * one.
-   *
-   * @param {!Array} record The splice record in the expanded paths list.
-   */
-  _expandedFilesChanged(record) {
-    // Clear content for any diffs that are not open so if they get re-opened
-    // the stale content does not flash before it is cleared and reloaded.
-    const collapsedDiffs = this.diffs.filter(diff =>
-      this._expandedFiles.findIndex(f => f.path === diff.path) === -1);
-    this._clearCollapsedDiffs(collapsedDiffs);
-
-    if (!record) { return; } // Happens after "Collapse all" clicked.
-
-    this.filesExpanded = this._computeExpandedFiles(
-        this._expandedFiles.length, this._files.length);
-
-    // Find the paths introduced by the new index splices:
-    const newFiles = record.indexSplices
-        .map(splice => splice.object.slice(
-            splice.index, splice.index + splice.addedCount))
-        .reduce((acc, paths) => acc.concat(paths), []);
-
-    // Required so that the newly created diff view is included in this.diffs.
-    flush();
-
-    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
-
-    if (newFiles.length) {
-      this._renderInOrder(newFiles, this.diffs, newFiles.length);
-    }
-
-    this._updateDiffCursor();
-    this.$.diffCursor.reInitAndUpdateStops();
-  }
-
-  _clearCollapsedDiffs(collapsedDiffs) {
-    for (const diff of collapsedDiffs) {
-      diff.cancel();
-      diff.clearDiffContent();
-    }
-  }
-
-  /**
-   * Given an array of paths and a NodeList of diff elements, render the diff
-   * for each path in order, awaiting the previous render to complete before
-   * continuing.
-   *
-   * @param  {!Array<Gerrit.FileRange>} files
-   * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
-   * @param  {number} initialCount The total number of paths in the pass. This
-   *   is used to generate log messages.
-   * @return {!Promise}
-   */
-  _renderInOrder(files, diffElements, initialCount) {
-    let iter = 0;
-
-    for (const file of files) {
-      const path = file.path;
-      const diffElem = this._findDiffByPath(path, diffElements);
-      if (diffElem) {
-        diffElem.prefetchDiff();
-      }
-    }
-
-    return (new Promise(resolve => {
-      this.dispatchEvent(new CustomEvent('reload-drafts', {
-        detail: {resolve},
-        composed: true, bubbles: true,
-      }));
-    })).then(() => asyncForeach(files, (file, cancel) => {
-      const path = file.path;
-      this._cancelForEachDiff = cancel;
-
-      iter++;
-      console.info('Expanding diff', iter, 'of', initialCount, ':',
-          path);
-      const diffElem = this._findDiffByPath(path, diffElements);
-      if (!diffElem) {
-        console.warn(`Did not find <gr-diff-host> element for ${path}`);
-        return Promise.resolve();
-      }
-      diffElem.comments = this.changeComments.getCommentsBySideForFile(
-          file, this.patchRange, this.projectConfig);
-      const promises = [diffElem.reload()];
-      if (this._loggedIn && !this.diffPrefs.manual_review) {
-        promises.push(this._reviewFile(path, true));
-      }
-      return Promise.all(promises);
-    }).then(() => {
-      this._cancelForEachDiff = null;
-      this._nextRenderParams = null;
-      console.info('Finished expanding', initialCount, 'diff(s)');
-      this.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
-          EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
-      /* Block diff cursor from auto scrolling after files are done rendering.
-       * This prevents the bug where the screen jumps to the first diff chunk
-       * after files are done being rendered after the user has already begun
-       * scrolling.
-       * This also however results in the fact that the cursor does not auto
-       * focus on the first diff chunk on a small screen. This is however, a use
-       * case we are willing to not support for now.
-
-       * Using handleDiffUpdate resulted in diffCursor.row being set which
-       * prevented the issue of scrolling to top when we expand the second
-       * file individually.
-       */
-      this.$.diffCursor.reInitAndUpdateStops();
-    }));
-  }
-
-  /** Cancel the rendering work of every diff in the list */
-  _cancelDiffs() {
-    if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
-    this._forEachDiff(d => d.cancel());
-  }
-
-  /**
-   * In the given NodeList of diff elements, find the diff for the given path.
-   *
-   * @param  {string} path
-   * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
-   * @return {!Object|undefined} (GrDiffElement)
-   */
-  _findDiffByPath(path, diffElements) {
-    for (let i = 0; i < diffElements.length; i++) {
-      if (diffElements[i].path === path) {
-        return diffElements[i];
-      }
-    }
-  }
-
-  /**
-   * Reset the comments of a modified thread
-   *
-   * @param  {string} rootId
-   * @param  {string} path
-   */
-  reloadCommentsForThreadWithRootId(rootId, path) {
-    // Don't bother continuing if we already know that the path that contains
-    // the updated comment thread is not expanded.
-    if (!this._expandedFiles.some(f => f.path === path)) { return; }
-    const diff = this.diffs.find(d => d.path === path);
-
-    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
-    if (!threadEl) { return; }
-
-    const newComments = this.changeComments.getCommentsForThread(rootId);
-
-    // If newComments is null, it means that a single draft was
-    // removed from a thread in the thread view, and the thread should
-    // no longer exist. Remove the existing thread element in the diff
-    // view.
-    if (!newComments) {
-      threadEl.fireRemoveSelf();
-      return;
-    }
-
-    // Comments are not returned with the commentSide attribute from
-    // the api, but it's necessary to be stored on the diff's
-    // comments due to use in the _handleCommentUpdate function.
-    // The comment thread already has a side associated with it, so
-    // set the comment's side to match.
-    threadEl.comments = newComments.map(c => Object.assign(
-        c, {__commentSide: threadEl.commentSide}
-    ));
-    flush();
-  }
-
-  _handleEscKey(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-    this._displayLine = false;
-  }
-
-  /**
-   * Update the loading class for the file list rows. The update is inside a
-   * debouncer so that the file list doesn't flash gray when the API requests
-   * are reasonably fast.
-   *
-   * @param {boolean} loading
-   */
-  _loadingChanged(loading) {
-    this.debounce('loading-change', () => {
-      // Only show set the loading if there have been files loaded to show. In
-      // this way, the gray loading style is not shown on initial loads.
-      this.classList.toggle('loading', loading && !!this._files.length);
-    }, LOADING_DEBOUNCE_INTERVAL);
-  }
-
-  _editModeChanged(editMode) {
-    this.classList.toggle('editMode', editMode);
-  }
-
-  _computeReviewedClass(isReviewed) {
-    return isReviewed ? 'isReviewed' : '';
-  }
-
-  _computeReviewedText(isReviewed) {
-    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
-  }
-
-  /**
-   * Given a file path, return whether that path should have visible size bars
-   * and be included in the size bars calculation.
-   *
-   * @param {string} path
-   * @return {boolean}
-   */
-  _showBarsForPath(path) {
-    return path !== SpecialFilePath.COMMIT_MESSAGE &&
-      path !== SpecialFilePath.MERGE_LIST;
-  }
-
-  /**
-   * Compute size bar layout values from the file list.
-   *
-   * @return {Gerrit.LayoutStats|undefined}
-   *
-   */
-  _computeSizeBarLayout(shownFilesRecord) {
-    if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
-    const stats = {
-      maxInserted: 0,
-      maxDeleted: 0,
-      maxAdditionWidth: 0,
-      maxDeletionWidth: 0,
-      deletionOffset: 0,
-    };
-    shownFilesRecord.base
-        .filter(f => this._showBarsForPath(f.__path))
-        .forEach(f => {
-          if (f.lines_inserted) {
-            stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
-          }
-          if (f.lines_deleted) {
-            stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
-          }
-        });
-    const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
-    if (!isNaN(ratio)) {
-      stats.maxAdditionWidth =
-          (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
-      stats.maxDeletionWidth =
-          SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
-      stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
-    }
-    return stats;
-  }
-
-  /**
-   * Get the width of the addition bar for a file.
-   *
-   * @param {Object} file
-   * @param {Gerrit.LayoutStats} stats
-   * @return {number}
-   */
-  _computeBarAdditionWidth(file, stats) {
-    if (stats.maxInserted === 0 ||
-        !file.lines_inserted ||
-        !this._showBarsForPath(file.__path)) {
-      return 0;
-    }
-    const width =
-        stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
-    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
-  }
-
-  /**
-   * Get the x-offset of the addition bar for a file.
-   *
-   * @param {Object} file
-   * @param {Gerrit.LayoutStats} stats
-   * @return {number}
-   */
-  _computeBarAdditionX(file, stats) {
-    return stats.maxAdditionWidth -
-        this._computeBarAdditionWidth(file, stats);
-  }
-
-  /**
-   * Get the width of the deletion bar for a file.
-   *
-   * @param {Object} file
-   * @param {Gerrit.LayoutStats} stats
-   * @return {number}
-   */
-  _computeBarDeletionWidth(file, stats) {
-    if (stats.maxDeleted === 0 ||
-        !file.lines_deleted ||
-        !this._showBarsForPath(file.__path)) {
-      return 0;
-    }
-    const width =
-        stats.maxDeletionWidth * file.lines_deleted / stats.maxDeleted;
-    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
-  }
-
-  /**
-   * Get the x-offset of the deletion bar for a file.
-   *
-   * @param {Gerrit.LayoutStats} stats
-   *
-   * @return {number}
-   */
-  _computeBarDeletionX(stats) {
-    return stats.deletionOffset;
-  }
-
-  _computeShowSizeBars(userPrefs) {
-    return !!userPrefs.size_bar_in_change_table;
-  }
-
-  _computeSizeBarsClass(showSizeBars, path) {
-    let hideClass = '';
-    if (!showSizeBars) {
-      hideClass = 'hide';
-    } else if (!this._showBarsForPath(path)) {
-      hideClass = 'invisible';
-    }
-    return `sizeBars desktop ${hideClass}`;
-  }
-
-  /**
-   * Shows registered dynamic columns iff the 'header', 'content' and
-   * 'summary' endpoints are registered the exact same number of times.
-   * Ideally, there should be a better way to enforce the expectation of the
-   * dependencies between dynamic endpoints.
-   */
-  _computeShowDynamicColumns(
-      headerEndpoints, contentEndpoints, summaryEndpoints) {
-    return headerEndpoints && contentEndpoints && summaryEndpoints &&
-           headerEndpoints.length &&
-           headerEndpoints.length === contentEndpoints.length &&
-           headerEndpoints.length === summaryEndpoints.length;
-  }
-
-  /**
-   * Shows registered dynamic prepended columns iff the 'header', 'content'
-   * endpoints are registered the exact same number of times.
-   */
-  _computeShowPrependedDynamicColumns(
-      headerEndpoints, contentEndpoints) {
-    return headerEndpoints && contentEndpoints &&
-           headerEndpoints.length &&
-           headerEndpoints.length === contentEndpoints.length;
-  }
-
-  /**
-   * Returns true if none of the inline diffs have been expanded.
-   *
-   * @return {boolean}
-   */
-  _noDiffsExpanded() {
-    return this.filesExpanded === FilesExpandedState.NONE;
-  }
-
-  /**
-   * Method to call via binding when each file list row is rendered. This
-   * allows approximate detection of when the dom-repeat has completed
-   * rendering.
-   *
-   * @param {number} index The index of the row being rendered.
-   * @return {string} an empty string.
-   */
-  _reportRenderedRow(index) {
-    if (index === this._shownFiles.length - 1) {
-      this.async(() => {
-        this.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
-            RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
-      }, 1);
-    }
-    return '';
-  }
-
-  _reviewedTitle(reviewed) {
-    if (reviewed) {
-      return 'Mark as not reviewed (shortcut: r)';
-    }
-
-    return 'Mark as reviewed (shortcut: r)';
-  }
-
-  _handleReloadingDiffPreference() {
-    this._getDiffPreferences().then(prefs => {
-      this.diffPrefs = prefs;
-    });
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeDisplayPath(path) {
-    return computeDisplayPath(path);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeTruncatedPath(path) {
-    return computeTruncatedPath(path);
-  }
-}
-
-customElements.define(GrFileList.is, GrFileList);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
new file mode 100644
index 0000000..3a2c858
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -0,0 +1,1901 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../styles/shared-styles';
+import '../../diff/gr-diff-cursor/gr-diff-cursor';
+import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-file-list_html';
+import {asyncForeach} from '../../../utils/async-util';
+import {
+  CustomKeyboardEvent,
+  KeyboardShortcutMixin,
+  Modifier,
+  Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {DiffViewMode, SpecialFilePath} from '../../../constants/constants';
+import {descendedFromClass} from '../../../utils/dom-util';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  computeTruncatedPath,
+  isMagicPath,
+  specialFilePathCompare,
+} from '../../../utils/path-list-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ConfigInfo,
+  DiffPreferencesInfo,
+  ElementPropertyDeepChange,
+  FileInfo,
+  FileNameToFileInfoMap,
+  NumericChangeId,
+  PatchRange,
+  PreferencesInfo,
+  RevisionInfo,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
+import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
+import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {PatchSetFile, UIDraft} from '../../../utils/comment-util';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+export const DEFAULT_NUM_FILES_SHOWN = 200;
+
+const WARN_SHOW_ALL_THRESHOLD = 1000;
+const LOADING_DEBOUNCE_INTERVAL = 100;
+
+const SIZE_BAR_MAX_WIDTH = 61;
+const SIZE_BAR_GAP_WIDTH = 1;
+const SIZE_BAR_MIN_WIDTH = 1.5;
+
+const RENDER_TIMING_LABEL = 'FileListRenderTime';
+const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
+const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
+const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
+
+const FileStatus = {
+  A: 'Added',
+  C: 'Copied',
+  D: 'Deleted',
+  M: 'Modified',
+  R: 'Renamed',
+  W: 'Rewritten',
+  U: 'Unchanged',
+};
+
+const FILE_ROW_CLASS = 'file-row';
+
+export interface GrFileList {
+  $: {
+    restAPI: RestApiService & Element;
+    diffPreferencesDialog: GrDiffPreferencesDialog;
+    diffCursor: GrDiffCursor;
+    fileCursor: GrCursorManager;
+  };
+}
+
+interface ReviewedFileInfo extends FileInfo {
+  isReviewed?: boolean;
+}
+interface NormalizedFileInfo extends ReviewedFileInfo {
+  __path: string;
+}
+
+interface PatchChange {
+  inserted: number;
+  deleted: number;
+  size_delta_inserted: number;
+  size_delta_deleted: number;
+  total_size: number;
+}
+
+function createDefaultPatchChange(): PatchChange {
+  // Use function instead of const to prevent unexpected changes in the default
+  // values.
+  return {
+    inserted: 0,
+    deleted: 0,
+    size_delta_inserted: 0,
+    size_delta_deleted: 0,
+    total_size: 0,
+  };
+}
+
+interface SizeBarLayout {
+  maxInserted: number;
+  maxDeleted: number;
+  maxAdditionWidth: number;
+  maxDeletionWidth: number;
+  deletionOffset: number;
+}
+
+function createDefaultSizeBarLayout(): SizeBarLayout {
+  // Use function instead of const to prevent unexpected changes in the default
+  // values.
+  return {
+    maxInserted: 0,
+    maxDeleted: 0,
+    maxAdditionWidth: 0,
+    maxDeletionWidth: 0,
+    deletionOffset: 0,
+  };
+}
+
+interface FileRow {
+  file: PatchSetFile;
+  element: HTMLElement;
+}
+
+export type FileNameToReviewedFileInfoMap = {[name: string]: ReviewedFileInfo};
+
+/**
+ * Type for FileInfo
+ *
+ * This should match with the type returned from `files` API plus
+ * additional info like `__path`.
+ *
+ * @typedef {Object} FileInfo
+ * @property {string} __path
+ * @property {?string} old_path
+ * @property {number} size
+ * @property {number} size_delta - fallback to 0 if not present in api
+ * @property {number} lines_deleted - fallback to 0 if not present in api
+ * @property {number} lines_inserted - fallback to 0 if not present in api
+ */
+
+@customElement('gr-file-list')
+export class GrFileList extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when a draft refresh should get triggered
+   *
+   * @event reload-drafts
+   */
+
+  @property({type: Object})
+  patchRange?: PatchRange;
+
+  @property({type: String})
+  patchNum?: string;
+
+  @property({type: Number})
+  changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Object})
+  drafts?: {[path: string]: UIDraft[]};
+
+  @property({type: Array})
+  revisions?: {[revisionId: string]: RevisionInfo};
+
+  @property({type: Object})
+  projectConfig?: ConfigInfo;
+
+  @property({type: Number, notify: true})
+  selectedIndex = -1;
+
+  @property({type: Object})
+  keyEventTarget = document.body;
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: String, notify: true, observer: '_updateDiffPreferences'})
+  diffViewMode?: DiffViewMode;
+
+  @property({type: Boolean, observer: '_editModeChanged'})
+  editMode?: boolean;
+
+  @property({type: String, notify: true})
+  filesExpanded = FilesExpandedState.NONE;
+
+  @property({type: Object})
+  _filesByPath?: FileNameToFileInfoMap;
+
+  @property({type: Array, observer: '_filesChanged'})
+  _files: NormalizedFileInfo[] = [];
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Array})
+  _reviewed?: string[] = [];
+
+  @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
+  diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  _userPrefs?: PreferencesInfo;
+
+  @property({type: Boolean})
+  _showInlineDiffs?: boolean;
+
+  @property({type: Number, notify: true})
+  numFilesShown: number = DEFAULT_NUM_FILES_SHOWN;
+
+  @property({type: Object, computed: '_calculatePatchChange(_files)'})
+  _patchChange: PatchChange = createDefaultPatchChange();
+
+  @property({type: Number})
+  fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
+
+  @property({type: Boolean, computed: '_shouldHideChangeTotals(_patchChange)'})
+  _hideChangeTotals = true;
+
+  @property({
+    type: Boolean,
+    computed: '_shouldHideBinaryChangeTotals(_patchChange)',
+  })
+  _hideBinaryChangeTotals = true;
+
+  @property({
+    type: Array,
+    computed: '_computeFilesShown(numFilesShown, _files)',
+  })
+  _shownFiles: NormalizedFileInfo[] = [];
+
+  @property({type: Number})
+  _reportinShownFilesIncrement = 0;
+
+  @property({type: Array})
+  _expandedFiles: PatchSetFile[] = [];
+
+  @property({type: Boolean})
+  _displayLine?: boolean;
+
+  @property({type: Boolean, observer: '_loadingChanged'})
+  _loading?: boolean;
+
+  @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
+  _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
+
+  @property({type: Boolean, computed: '_computeShowSizeBars(_userPrefs)'})
+  _showSizeBars = true;
+
+  private _cancelForEachDiff?: () => void;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
+      '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
+  })
+  _showDynamicColumns = false;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeShowPrependedDynamicColumns(' +
+      '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
+  })
+  _showPrependedDynamicColumns = false;
+
+  @property({type: Array})
+  _dynamicHeaderEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicContentEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicSummaryEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicPrependedHeaderEndpoints?: string[];
+
+  @property({type: Array})
+  _dynamicPrependedContentEndpoints?: string[];
+
+  private readonly reporting = appContext.reportingService;
+
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+    };
+  }
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
+      [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+        '_handleToggleHideAllCommentThreads',
+      [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
+      [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
+      [Shortcut.NEXT_LINE]: '_handleCursorNext',
+      [Shortcut.PREV_LINE]: '_handleCursorPrev',
+      [Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
+      [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
+      [Shortcut.OPEN_FILE]: '_handleOpenFile',
+      [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
+      [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
+      [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+
+      // Final two are actually handled by gr-comment-thread.
+      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-header'
+        );
+        this._dynamicContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-content'
+        );
+        this._dynamicPrependedHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-header-prepend'
+        );
+        this._dynamicPrependedContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-content-prepend'
+        );
+        this._dynamicSummaryEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-file-list-summary'
+        );
+
+        if (
+          this._dynamicHeaderEndpoints.length !==
+          this._dynamicContentEndpoints.length
+        ) {
+          console.warn(
+            'Different number of dynamic file-list header and content.'
+          );
+        }
+        if (
+          this._dynamicPrependedHeaderEndpoints.length !==
+          this._dynamicPrependedContentEndpoints.length
+        ) {
+          console.warn(
+            'Different number of dynamic file-list header and content.'
+          );
+        }
+        if (
+          this._dynamicHeaderEndpoints.length !==
+          this._dynamicSummaryEndpoints.length
+        ) {
+          console.warn(
+            'Different number of dynamic file-list headers and summary.'
+          );
+        }
+      });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this._cancelDiffs();
+  }
+
+  /**
+   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+   * events must be scoped to a component level (e.g. `enter`) in order to not
+   * override native browser functionality.
+   *
+   * Context: Issue 7277
+   */
+  _scopedKeydownHandler(e: KeyboardEvent) {
+    if (e.keyCode === 13) {
+      // TODO(TS): e is not an instance of CustomKeyboardEvent.
+      // However, to fix it we should fix keyboard-shortcut-mixin first
+      // The keyboard-shortcut-mixin will be updated in a separate change
+      this._handleOpenFile((e as unknown) as CustomKeyboardEvent);
+    }
+  }
+
+  reload() {
+    if (!this.changeNum || !this.patchRange?.patchNum) {
+      return Promise.resolve();
+    }
+    const changeNum = this.changeNum;
+    const patchRange = this.patchRange;
+
+    this._loading = true;
+
+    this.collapseAllDiffs();
+    const promises = [];
+
+    promises.push(
+      this.$.restAPI
+        .getChangeOrEditFiles(changeNum, patchRange)
+        .then(filesByPath => {
+          this._filesByPath = filesByPath;
+        })
+    );
+    promises.push(
+      this._getLoggedIn()
+        .then(loggedIn => (this._loggedIn = loggedIn))
+        .then(loggedIn => {
+          if (!loggedIn) {
+            return;
+          }
+
+          return this._getReviewedFiles(changeNum, patchRange).then(
+            reviewed => {
+              this._reviewed = reviewed;
+            }
+          );
+        })
+    );
+
+    promises.push(
+      this._getDiffPreferences().then(prefs => {
+        this.diffPrefs = prefs;
+      })
+    );
+
+    promises.push(
+      this._getPreferences().then(prefs => {
+        this._userPrefs = prefs;
+      })
+    );
+
+    return Promise.all(promises).then(() => {
+      this._loading = false;
+      this._detectChromiteButler();
+      this.reporting.fileListDisplayed();
+    });
+  }
+
+  _detectChromiteButler() {
+    const hasButler = !!document.getElementById('butler-suggested-owners');
+    if (hasButler) {
+      this.reporting.reportExtension('butler');
+    }
+  }
+
+  get diffs(): GrDiffHost[] {
+    const diffs = this.root!.querySelectorAll('gr-diff-host');
+    // It is possible that a bogus diff element is hanging around invisibly
+    // from earlier with a different patch set choice and associated with a
+    // different entry in the files array. So filter on visible items only.
+    return Array.from(diffs).filter(
+      el => !!el && !!el.style && el.style.display !== 'none'
+    );
+  }
+
+  openDiffPrefs() {
+    this.$.diffPreferencesDialog.open();
+  }
+
+  _calculatePatchChange(files: NormalizedFileInfo[]): PatchChange {
+    const magicFilesExcluded = files.filter(
+      files => !isMagicPath(files.__path)
+    );
+
+    return magicFilesExcluded.reduce((acc, obj) => {
+      const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
+      const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+      const total_size = obj.size && obj.binary ? obj.size : 0;
+      const size_delta_inserted =
+        obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
+      const size_delta_deleted =
+        obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
+
+      return {
+        inserted: acc.inserted + inserted,
+        deleted: acc.deleted + deleted,
+        size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
+        size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
+        total_size: acc.total_size + total_size,
+      };
+    }, createDefaultPatchChange());
+  }
+
+  _getDiffPreferences() {
+    return this.$.restAPI.getDiffPreferences();
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  private _toggleFileExpanded(file: PatchSetFile) {
+    // Is the path in the list of expanded diffs? IF so remove it, otherwise
+    // add it to the list.
+    const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
+    if (pathIndex === -1) {
+      this.push('_expandedFiles', file);
+    } else {
+      this.splice('_expandedFiles', pathIndex, 1);
+    }
+  }
+
+  _toggleFileExpandedByIndex(index: number) {
+    this._toggleFileExpanded(this._computePatchSetFile(this._files[index]));
+  }
+
+  _updateDiffPreferences() {
+    if (!this.diffs.length) {
+      return;
+    }
+    // Re-render all expanded diffs sequentially.
+    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+    this._renderInOrder(
+      this._expandedFiles,
+      this.diffs,
+      this._expandedFiles.length
+    );
+  }
+
+  _forEachDiff(fn: (host: GrDiffHost) => void) {
+    const diffs = this.diffs;
+    for (let i = 0; i < diffs.length; i++) {
+      fn(diffs[i]);
+    }
+  }
+
+  expandAllDiffs() {
+    this._showInlineDiffs = true;
+
+    // Find the list of paths that are in the file list, but not in the
+    // expanded list.
+    const newFiles: PatchSetFile[] = [];
+    let path: string;
+    for (let i = 0; i < this._shownFiles.length; i++) {
+      path = this._shownFiles[i].__path;
+      if (!this._expandedFiles.some(f => f.path === path)) {
+        newFiles.push(this._computePatchSetFile(this._shownFiles[i]));
+      }
+    }
+
+    this.splice('_expandedFiles', 0, 0, ...newFiles);
+  }
+
+  collapseAllDiffs() {
+    this._showInlineDiffs = false;
+    this._expandedFiles = [];
+    this.filesExpanded = this._computeExpandedFiles(
+      this._expandedFiles.length,
+      this._files.length
+    );
+    this.$.diffCursor.handleDiffUpdate();
+  }
+
+  /**
+   * Computes a string with the number of comments and unresolved comments.
+   */
+  _computeCommentsString(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const unresolvedCount =
+      changeComments.computeUnresolvedNum({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeUnresolvedNum({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    const commentCount =
+      changeComments.computeCommentCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeCommentCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    const commentString = GrCountStringFormatter.computePluralString(
+      commentCount,
+      'comment'
+    );
+    const unresolvedString = GrCountStringFormatter.computeString(
+      unresolvedCount,
+      'unresolved'
+    );
+
+    return (
+      commentString +
+      // Add a space if both comments and unresolved
+      (commentString && unresolvedString ? ' ' : '') +
+      // Add parentheses around unresolved if it exists.
+      (unresolvedString ? `(${unresolvedString})` : '')
+    );
+  }
+
+  /**
+   * Computes a string with the number of drafts.
+   */
+  _computeDraftsString(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const draftCount =
+      changeComments.computeDraftCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeDraftCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+  }
+
+  /**
+   * Computes a shortened string with the number of drafts.
+   */
+  _computeDraftsStringMobile(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const draftCount =
+      changeComments.computeDraftCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeDraftCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    return GrCountStringFormatter.computeShortString(draftCount, 'd');
+  }
+
+  /**
+   * Computes a shortened string with the number of comments.
+   */
+  _computeCommentsStringMobile(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    path?: string
+  ) {
+    if (
+      changeComments === undefined ||
+      patchRange === undefined ||
+      path === undefined
+    ) {
+      return '';
+    }
+    const commentCount =
+      changeComments.computeCommentCount({
+        patchNum: patchRange.basePatchNum,
+        path,
+      }) +
+      changeComments.computeCommentCount({
+        patchNum: patchRange.patchNum,
+        path,
+      });
+    return GrCountStringFormatter.computeShortString(commentCount, 'c');
+  }
+
+  private _reviewFile(path: string, reviewed?: boolean) {
+    if (this.editMode) {
+      return Promise.resolve();
+    }
+    const index = this._files.findIndex(file => file.__path === path);
+    reviewed = reviewed || !this._files[index].isReviewed;
+
+    this.set(['_files', index, 'isReviewed'], reviewed);
+    if (index < this._shownFiles.length) {
+      this.notifyPath(`_shownFiles.${index}.isReviewed`);
+    }
+
+    return this._saveReviewedState(path, reviewed);
+  }
+
+  _saveReviewedState(path: string, reviewed: boolean) {
+    if (!this.changeNum || !this.patchRange) {
+      throw new Error('changeNum and patchRange must be set');
+    }
+
+    return this.$.restAPI.saveFileReviewed(
+      this.changeNum,
+      this.patchRange.patchNum,
+      path,
+      reviewed
+    );
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
+    if (this.editMode) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI.getReviewedFiles(changeNum, patchRange.patchNum);
+  }
+
+  _normalizeChangeFilesResponse(
+    response: FileNameToReviewedFileInfoMap
+  ): NormalizedFileInfo[] {
+    const paths = Object.keys(response).sort(specialFilePathCompare);
+    const files: NormalizedFileInfo[] = [];
+    for (let i = 0; i < paths.length; i++) {
+      // TODO(TS): make copy instead of as NormalizedFileInfo
+      const info = response[paths[i]] as NormalizedFileInfo;
+      info.__path = paths[i];
+      info.lines_inserted = info.lines_inserted || 0;
+      info.lines_deleted = info.lines_deleted || 0;
+      info.size_delta = info.size_delta || 0;
+      files.push(info);
+    }
+    return files;
+  }
+
+  /**
+   * Returns true if the event e is a click on an element.
+   *
+   * The click is: mouse click or pressing Enter or Space key
+   * P.S> Screen readers sends click event as well
+   */
+  _isClickEvent(e: MouseEvent | KeyboardEvent) {
+    if (e.type === 'click') {
+      return true;
+    }
+    const ke = e as KeyboardEvent;
+    const isSpaceOrEnter = ke.key === 'Enter' || ke.key === ' ';
+    return ke.type === 'keydown' && isSpaceOrEnter;
+  }
+
+  _fileActionClick(
+    e: MouseEvent | KeyboardEvent,
+    fileAction: (file: PatchSetFile) => void
+  ) {
+    if (this._isClickEvent(e)) {
+      const fileRow = this._getFileRowFromEvent(e);
+      if (!fileRow) {
+        return;
+      }
+      // Prevent default actions (e.g. scrolling for space key)
+      e.preventDefault();
+      // Prevent _handleFileListClick handler call
+      e.stopPropagation();
+      this.$.fileCursor.setCursor(fileRow.element);
+      fileAction(fileRow.file);
+    }
+  }
+
+  _reviewedClick(e: MouseEvent | KeyboardEvent) {
+    this._fileActionClick(e, file => this._reviewFile(file.path));
+  }
+
+  _expandedClick(e: MouseEvent | KeyboardEvent) {
+    this._fileActionClick(e, file => this._toggleFileExpanded(file));
+  }
+
+  /**
+   * Handle all events from the file list dom-repeat so event handleers don't
+   * have to get registered for potentially very long lists.
+   */
+  _handleFileListClick(e: MouseEvent) {
+    if (!e.target) {
+      return;
+    }
+    const fileRow = this._getFileRowFromEvent(e);
+    if (!fileRow) {
+      return;
+    }
+    const file = fileRow.file;
+    const path = file.path;
+    // If a path cannot be interpreted from the click target (meaning it's not
+    // somewhere in the row, e.g. diff content) or if the user clicked the
+    // link, defer to the native behavior.
+    if (!path || descendedFromClass(e.target as Element, 'pathLink')) {
+      return;
+    }
+
+    // Disregard the event if the click target is in the edit controls.
+    if (descendedFromClass(e.target as Element, 'editFileControls')) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.fileCursor.setCursor(fileRow.element);
+    this._toggleFileExpanded(file);
+  }
+
+  _getFileRowFromEvent(e: Event): FileRow | null {
+    // Traverse upwards to find the row element if the target is not the row.
+    let row = e.target as HTMLElement;
+    while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
+      row = row.parentElement;
+    }
+
+    // No action needed for item without a valid file
+    if (!row.dataset['file']) {
+      return null;
+    }
+
+    return {
+      file: JSON.parse(row.dataset['file']) as PatchSetFile,
+      element: row,
+    };
+  }
+
+  /**
+   * Generates file range from file info object.
+   */
+  _computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
+    const fileData: PatchSetFile = {
+      path: file.__path,
+    };
+    if (file.old_path) {
+      fileData.basePath = file.old_path;
+    }
+    return fileData;
+  }
+
+  _handleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveLeft();
+  }
+
+  _handleRightPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffCursor.moveRight();
+  }
+
+  _handleToggleInlineDiff(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      this.modifierPressed(e) ||
+      this.$.fileCursor.index === -1
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleFileExpandedByIndex(this.$.fileCursor.index);
+  }
+
+  _handleToggleAllInlineDiffs(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleInlineDiffs();
+  }
+
+  _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.toggleClass('hideComments');
+  }
+
+  _handleCursorNext(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._showInlineDiffs) {
+      e.preventDefault();
+      this.$.diffCursor.moveDown();
+      this._displayLine = true;
+    } else {
+      // Down key
+      if (this.getKeyboardEvent(e).keyCode === 40) {
+        return;
+      }
+      e.preventDefault();
+      this.$.fileCursor.next();
+      this.selectedIndex = this.$.fileCursor.index;
+    }
+  }
+
+  _handleCursorPrev(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._showInlineDiffs) {
+      e.preventDefault();
+      this.$.diffCursor.moveUp();
+      this._displayLine = true;
+    } else {
+      // Up key
+      if (this.getKeyboardEvent(e).keyCode === 38) {
+        return;
+      }
+      e.preventDefault();
+      this.$.fileCursor.previous();
+      this.selectedIndex = this.$.fileCursor.index;
+    }
+  }
+
+  _handleNewComment(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+    this.$.diffCursor.createCommentInPlace();
+  }
+
+  _handleOpenLastFile(e: CustomKeyboardEvent) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      this.getKeyboardEvent(e).metaKey
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this._openSelectedFile(this._files.length - 1);
+  }
+
+  _handleOpenFirstFile(e: CustomKeyboardEvent) {
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      this.getKeyboardEvent(e).metaKey
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this._openSelectedFile(0);
+  }
+
+  _handleOpenFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+
+    if (this._showInlineDiffs) {
+      this._openCursorFile();
+      return;
+    }
+
+    this._openSelectedFile();
+  }
+
+  _handleNextChunk(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+      this._noDiffsExpanded()
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+      this.$.diffCursor.moveToNextCommentThread();
+    } else {
+      this.$.diffCursor.moveToNextChunk();
+    }
+  }
+
+  _handlePrevChunk(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+      this._noDiffsExpanded()
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+      this.$.diffCursor.moveToPreviousCommentThread();
+    } else {
+      this.$.diffCursor.moveToPreviousChunk();
+    }
+  }
+
+  _handleToggleFileReviewed(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (!this._files[this.$.fileCursor.index]) {
+      return;
+    }
+    this._reviewFile(this._files[this.$.fileCursor.index].__path);
+  }
+
+  _handleToggleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._forEachDiff(diff => {
+      diff.toggleLeftDiff();
+    });
+  }
+
+  _toggleInlineDiffs() {
+    if (this._showInlineDiffs) {
+      this.collapseAllDiffs();
+    } else {
+      this.expandAllDiffs();
+    }
+  }
+
+  _openCursorFile() {
+    const diff = this.$.diffCursor.getTargetDiffElement();
+    if (
+      !this.change ||
+      !diff ||
+      !this.patchRange ||
+      !diff.path ||
+      !diff.patchRange
+    ) {
+      throw new Error('change, diff and patchRange must be all set and valid');
+    }
+    GerritNav.navigateToDiff(
+      this.change,
+      diff.path,
+      diff.patchRange.patchNum,
+      this.patchRange.basePatchNum
+    );
+  }
+
+  _openSelectedFile(index?: number) {
+    if (index !== undefined) {
+      this.$.fileCursor.setCursorAtIndex(index);
+    }
+    if (!this._files[this.$.fileCursor.index]) {
+      return;
+    }
+    if (!this.change || !this.patchRange) {
+      throw new Error('change and patchRange must be set');
+    }
+    GerritNav.navigateToDiff(
+      this.change,
+      this._files[this.$.fileCursor.index].__path,
+      this.patchRange.patchNum,
+      this.patchRange.basePatchNum
+    );
+  }
+
+  _addDraftAtTarget() {
+    const diff = this.$.diffCursor.getTargetDiffElement();
+    const target = this.$.diffCursor.getTargetLineElement();
+    if (diff && target) {
+      diff.addDraftAtLine(target);
+    }
+  }
+
+  _shouldHideChangeTotals(_patchChange: PatchChange): boolean {
+    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
+  }
+
+  _shouldHideBinaryChangeTotals(_patchChange: PatchChange) {
+    return (
+      _patchChange.size_delta_inserted === 0 &&
+      _patchChange.size_delta_deleted === 0
+    );
+  }
+
+  _computeFileStatus(
+    status?: keyof typeof FileStatus
+  ): keyof typeof FileStatus {
+    return status || 'M';
+  }
+
+  _computeDiffURL(
+    change?: ParsedChangeInfo,
+    patchRange?: PatchRange,
+    path?: string,
+    editMode?: boolean
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      change === undefined ||
+      patchRange === undefined ||
+      path === undefined ||
+      editMode === undefined
+    ) {
+      return;
+    }
+    if (editMode && path !== SpecialFilePath.MERGE_LIST) {
+      return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum);
+    }
+    return GerritNav.getUrlForDiff(
+      change,
+      path,
+      patchRange.patchNum,
+      patchRange.basePatchNum
+    );
+  }
+
+  _formatBytes(bytes?: number) {
+    if (!bytes) return '+/-0 B';
+    const bits = 1024;
+    const decimals = 1;
+    const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+    const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
+    const prepend = bytes > 0 ? '+' : '';
+    const value = parseFloat(
+      (bytes / Math.pow(bits, exponent)).toFixed(decimals)
+    );
+    return `${prepend}${value} ${sizes[exponent]}`;
+  }
+
+  _formatPercentage(size?: number, delta?: number) {
+    if (size === undefined || delta === undefined) {
+      return '';
+    }
+    const oldSize = size - delta;
+
+    if (oldSize === 0) {
+      return '';
+    }
+
+    const percentage = Math.round(Math.abs((delta * 100) / oldSize));
+    return `(${delta > 0 ? '+' : '-'}${percentage}%)`;
+  }
+
+  _computeBinaryClass(delta?: number) {
+    if (!delta) {
+      return;
+    }
+    return delta > 0 ? 'added' : 'removed';
+  }
+
+  _computeClass(baseClass?: string, path?: string) {
+    const classes = [];
+    if (baseClass) {
+      classes.push(baseClass);
+    }
+    if (
+      path === SpecialFilePath.COMMIT_MESSAGE ||
+      path === SpecialFilePath.MERGE_LIST
+    ) {
+      classes.push('invisible');
+    }
+    return classes.join(' ');
+  }
+
+  _computeStatusClass(file?: NormalizedFileInfo) {
+    if (!file) return '';
+    const classStr = this._computeClass('status', file.__path);
+    return `${classStr} ${this._computeFileStatus(file.status)}`;
+  }
+
+  _computePathClass(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
+  }
+
+  _computeShowHideIcon(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._isFileExpanded(path, expandedFilesRecord)
+      ? 'gr-icons:expand-less'
+      : 'gr-icons:expand-more';
+  }
+
+  @observe(
+    '_filesByPath',
+    'changeComments',
+    'patchRange',
+    '_reviewed',
+    '_loading'
+  )
+  _computeFiles(
+    filesByPath?: FileNameToFileInfoMap,
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    reviewed?: string[],
+    loading?: boolean
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      filesByPath === undefined ||
+      changeComments === undefined ||
+      patchRange === undefined ||
+      reviewed === undefined ||
+      loading === undefined
+    ) {
+      return;
+    }
+
+    // Await all promises resolving from reload. @See Issue 9057
+    if (loading || !changeComments) {
+      return;
+    }
+
+    const commentedPaths = changeComments.getPaths(patchRange);
+    const files: FileNameToReviewedFileInfoMap = {...filesByPath};
+    addUnmodifiedFiles(files, commentedPaths);
+    const reviewedSet = new Set(reviewed || []);
+    for (const filePath in files) {
+      if (!hasOwnProperty(files, filePath)) {
+        continue;
+      }
+      files[filePath].isReviewed = reviewedSet.has(filePath);
+    }
+
+    this._files = this._normalizeChangeFilesResponse(files);
+  }
+
+  _computeFilesShown(
+    numFilesShown: number,
+    files: NormalizedFileInfo[]
+  ): NormalizedFileInfo[] | undefined {
+    // Polymer 2: check for undefined
+    if (numFilesShown === undefined || files === undefined) return undefined;
+
+    const previousNumFilesShown = this._shownFiles
+      ? this._shownFiles.length
+      : 0;
+
+    const filesShown = files.slice(0, numFilesShown);
+    this.dispatchEvent(
+      new CustomEvent('files-shown-changed', {
+        detail: {length: filesShown.length},
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    // Start the timer for the rendering work hwere because this is where the
+    // _shownFiles property is being set, and _shownFiles is used in the
+    // dom-repeat binding.
+    this.reporting.time(RENDER_TIMING_LABEL);
+
+    // How many more files are being shown (if it's an increase).
+    this._reportinShownFilesIncrement = Math.max(
+      0,
+      filesShown.length - previousNumFilesShown
+    );
+
+    return filesShown;
+  }
+
+  _updateDiffCursor() {
+    // Overwrite the cursor's list of diffs:
+    this.$.diffCursor.splice(
+      'diffs',
+      0,
+      this.$.diffCursor.diffs.length,
+      ...this.diffs
+    );
+  }
+
+  _filesChanged() {
+    if (this._files && this._files.length > 0) {
+      flush();
+      this.$.fileCursor.stops = Array.from(
+        this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)
+      );
+      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
+    }
+  }
+
+  _incrementNumFilesShown() {
+    this.numFilesShown += this.fileListIncrement;
+  }
+
+  _computeFileListControlClass(
+    numFilesShown?: number,
+    files?: NormalizedFileInfo[]
+  ) {
+    if (numFilesShown === undefined || files === undefined) return 'invisible';
+    return numFilesShown >= files.length ? 'invisible' : '';
+  }
+
+  _computeIncrementText(numFilesShown?: number, files?: NormalizedFileInfo[]) {
+    if (numFilesShown === undefined || files === undefined) return '';
+    const text = Math.min(this.fileListIncrement, files.length - numFilesShown);
+    return `Show ${text} more`;
+  }
+
+  _computeShowAllText(files: NormalizedFileInfo[]) {
+    if (!files) {
+      return '';
+    }
+    return `Show all ${files.length} files`;
+  }
+
+  _computeWarnShowAll(files: NormalizedFileInfo[]) {
+    return files.length > WARN_SHOW_ALL_THRESHOLD;
+  }
+
+  _computeShowAllWarning(files: NormalizedFileInfo[]) {
+    if (!this._computeWarnShowAll(files)) {
+      return '';
+    }
+    return `Warning: showing all ${files.length} files may take several seconds.`;
+  }
+
+  _showAllFiles() {
+    this.numFilesShown = this._files.length;
+  }
+
+  /**
+   * Get a descriptive label for use in the status indicator's tooltip and
+   * ARIA label.
+   */
+  _computeFileStatusLabel(status?: keyof typeof FileStatus) {
+    const statusCode = this._computeFileStatus(status);
+    return hasOwnProperty(FileStatus, statusCode)
+      ? FileStatus[statusCode]
+      : 'Status Unknown';
+  }
+
+  /**
+   * Converts any boolean-like variable to the string 'true' or 'false'
+   *
+   * This method is useful when you bind aria-checked attribute to a boolean
+   * value. The aria-checked attribute is string attribute. Binding directly
+   * to boolean variable causes problem on gerrit-CI.
+   *
+   * @return 'true' if val is true-like, otherwise false
+   */
+  _booleanToString(val?: unknown) {
+    return val ? 'true' : 'false';
+  }
+
+  _isFileExpanded(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return expandedFilesRecord.base.some(f => f.path === path);
+  }
+
+  _isFileExpandedStr(
+    path: string | undefined,
+    expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+  ) {
+    return this._booleanToString(
+      this._isFileExpanded(path, expandedFilesRecord)
+    );
+  }
+
+  private _computeExpandedFiles(
+    expandedCount: number,
+    totalCount: number
+  ): FilesExpandedState {
+    if (expandedCount === 0) {
+      return FilesExpandedState.NONE;
+    } else if (expandedCount === totalCount) {
+      return FilesExpandedState.ALL;
+    }
+    return FilesExpandedState.SOME;
+  }
+
+  /**
+   * Handle splices to the list of expanded file paths. If there are any new
+   * entries in the expanded list, then render each diff corresponding in
+   * order by waiting for the previous diff to finish before starting the next
+   * one.
+   *
+   * @param record The splice record in the expanded paths list.
+   */
+  @observe('_expandedFiles.splices')
+  _expandedFilesChanged(record?: PolymerSpliceChange<PatchSetFile[]>) {
+    // Clear content for any diffs that are not open so if they get re-opened
+    // the stale content does not flash before it is cleared and reloaded.
+    const collapsedDiffs = this.diffs.filter(
+      diff => this._expandedFiles.findIndex(f => f.path === diff.path) === -1
+    );
+    this._clearCollapsedDiffs(collapsedDiffs);
+
+    if (!record) {
+      return;
+    } // Happens after "Collapse all" clicked.
+
+    this.filesExpanded = this._computeExpandedFiles(
+      this._expandedFiles.length,
+      this._files.length
+    );
+
+    // Find the paths introduced by the new index splices:
+    const newFiles = record.indexSplices
+      .map(splice =>
+        splice.object.slice(splice.index, splice.index + splice.addedCount)
+      )
+      .reduce((acc, paths) => acc.concat(paths), []);
+
+    // Required so that the newly created diff view is included in this.diffs.
+    flush();
+
+    this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+
+    if (newFiles.length) {
+      this._renderInOrder(newFiles, this.diffs, newFiles.length);
+    }
+
+    this._updateDiffCursor();
+    this.$.diffCursor.reInitAndUpdateStops();
+  }
+
+  private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
+    for (const diff of collapsedDiffs) {
+      diff.cancel();
+      diff.clearDiffContent();
+    }
+  }
+
+  /**
+   * Given an array of paths and a NodeList of diff elements, render the diff
+   * for each path in order, awaiting the previous render to complete before
+   * continuing.
+   *
+   * @param initialCount The total number of paths in the pass. This
+   * is used to generate log messages.
+   */
+  private _renderInOrder(
+    files: PatchSetFile[],
+    diffElements: GrDiffHost[],
+    initialCount: number
+  ) {
+    let iter = 0;
+
+    for (const file of files) {
+      const path = file.path;
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (diffElem) {
+        diffElem.prefetchDiff();
+      }
+    }
+
+    return new Promise(resolve => {
+      this.dispatchEvent(
+        new CustomEvent('reload-drafts', {
+          detail: {resolve},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }).then(() =>
+      asyncForeach(files, (file, cancel) => {
+        const path = file.path;
+        this._cancelForEachDiff = cancel;
+
+        iter++;
+        console.info('Expanding diff', iter, 'of', initialCount, ':', path);
+        const diffElem = this._findDiffByPath(path, diffElements);
+        if (!diffElem) {
+          console.warn(`Did not find <gr-diff-host> element for ${path}`);
+          return Promise.resolve();
+        }
+        if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
+          throw new Error(
+            'changeComments, patchRange and diffPrefs must be set'
+          );
+        }
+        diffElem.comments = this.changeComments.getCommentsBySideForFile(
+          file,
+          this.patchRange,
+          this.projectConfig
+        );
+        const promises: Array<Promise<unknown>> = [diffElem.reload()];
+        if (this._loggedIn && !this.diffPrefs.manual_review) {
+          promises.push(this._reviewFile(path, true));
+        }
+        return Promise.all(promises);
+      }).then(() => {
+        this._cancelForEachDiff = undefined;
+        console.info('Finished expanding', initialCount, 'diff(s)');
+        this.reporting.timeEndWithAverage(
+          EXPAND_ALL_TIMING_LABEL,
+          EXPAND_ALL_AVG_TIMING_LABEL,
+          initialCount
+        );
+        /* Block diff cursor from auto scrolling after files are done rendering.
+       * This prevents the bug where the screen jumps to the first diff chunk
+       * after files are done being rendered after the user has already begun
+       * scrolling.
+       * This also however results in the fact that the cursor does not auto
+       * focus on the first diff chunk on a small screen. This is however, a use
+       * case we are willing to not support for now.
+
+       * Using handleDiffUpdate resulted in diffCursor.row being set which
+       * prevented the issue of scrolling to top when we expand the second
+       * file individually.
+       */
+        this.$.diffCursor.reInitAndUpdateStops();
+      })
+    );
+  }
+
+  /** Cancel the rendering work of every diff in the list */
+  _cancelDiffs() {
+    if (this._cancelForEachDiff) {
+      this._cancelForEachDiff();
+    }
+    this._forEachDiff(d => d.cancel());
+  }
+
+  /**
+   * In the given NodeList of diff elements, find the diff for the given path.
+   */
+  private _findDiffByPath(path: string, diffElements: GrDiffHost[]) {
+    for (let i = 0; i < diffElements.length; i++) {
+      if (diffElements[i].path === path) {
+        return diffElements[i];
+      }
+    }
+    return undefined;
+  }
+
+  /**
+   * Reset the comments of a modified thread
+   */
+  reloadCommentsForThreadWithRootId(rootId: UrlEncodedCommentId, path: string) {
+    // Don't bother continuing if we already know that the path that contains
+    // the updated comment thread is not expanded.
+    if (!this._expandedFiles.some(f => f.path === path)) {
+      return;
+    }
+    const diff = this.diffs.find(d => d.path === path);
+
+    if (!diff) {
+      throw new Error("Can't find diff by path");
+    }
+
+    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
+    if (!threadEl) {
+      return;
+    }
+
+    if (!this.changeComments) {
+      throw new Error('changeComments must be set');
+    }
+
+    const newComments = this.changeComments.getCommentsForThread(rootId);
+
+    // If newComments is null, it means that a single draft was
+    // removed from a thread in the thread view, and the thread should
+    // no longer exist. Remove the existing thread element in the diff
+    // view.
+    if (!newComments) {
+      threadEl.fireRemoveSelf();
+      return;
+    }
+
+    // Comments are not returned with the commentSide attribute from
+    // the api, but it's necessary to be stored on the diff's
+    // comments due to use in the _handleCommentUpdate function.
+    // The comment thread already has a side associated with it, so
+    // set the comment's side to match.
+    threadEl.comments = newComments.map(c =>
+      Object.assign(c, {__commentSide: threadEl.commentSide})
+    );
+    flush();
+  }
+
+  _handleEscKey(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+    this._displayLine = false;
+  }
+
+  /**
+   * Update the loading class for the file list rows. The update is inside a
+   * debouncer so that the file list doesn't flash gray when the API requests
+   * are reasonably fast.
+   */
+  _loadingChanged(loading?: boolean) {
+    this.debounce(
+      'loading-change',
+      () => {
+        // Only show set the loading if there have been files loaded to show. In
+        // this way, the gray loading style is not shown on initial loads.
+        this.classList.toggle('loading', loading && !!this._files.length);
+      },
+      LOADING_DEBOUNCE_INTERVAL
+    );
+  }
+
+  _editModeChanged(editMode?: boolean) {
+    this.classList.toggle('editMode', editMode);
+  }
+
+  _computeReviewedClass(isReviewed?: boolean) {
+    return isReviewed ? 'isReviewed' : '';
+  }
+
+  _computeReviewedText(isReviewed?: boolean) {
+    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
+  }
+
+  /**
+   * Given a file path, return whether that path should have visible size bars
+   * and be included in the size bars calculation.
+   */
+  _showBarsForPath(path?: string) {
+    return (
+      path !== SpecialFilePath.COMMIT_MESSAGE &&
+      path !== SpecialFilePath.MERGE_LIST
+    );
+  }
+
+  /**
+   * Compute size bar layout values from the file list.
+   */
+  _computeSizeBarLayout(
+    shownFilesRecord?: ElementPropertyDeepChange<GrFileList, '_shownFiles'>
+  ) {
+    const stats: SizeBarLayout = createDefaultSizeBarLayout();
+    if (!shownFilesRecord || !shownFilesRecord.base) {
+      return stats;
+    }
+    shownFilesRecord.base
+      .filter(f => this._showBarsForPath(f.__path))
+      .forEach(f => {
+        if (f.lines_inserted) {
+          stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
+        }
+        if (f.lines_deleted) {
+          stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
+        }
+      });
+    const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
+    if (!isNaN(ratio)) {
+      stats.maxAdditionWidth =
+        (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
+      stats.maxDeletionWidth =
+        SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
+      stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
+    }
+    return stats;
+  }
+
+  /**
+   * Get the width of the addition bar for a file.
+   */
+  _computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (
+      !file ||
+      !stats ||
+      stats.maxInserted === 0 ||
+      !file.lines_inserted ||
+      !this._showBarsForPath(file.__path)
+    ) {
+      return 0;
+    }
+    const width =
+      (stats.maxAdditionWidth * file.lines_inserted) / stats.maxInserted;
+    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+  }
+
+  /**
+   * Get the x-offset of the addition bar for a file.
+   */
+  _computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (!file || !stats) return;
+    return stats.maxAdditionWidth - this._computeBarAdditionWidth(file, stats);
+  }
+
+  /**
+   * Get the width of the deletion bar for a file.
+   */
+  _computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+    if (
+      !file ||
+      !stats ||
+      stats.maxDeleted === 0 ||
+      !file.lines_deleted ||
+      !this._showBarsForPath(file.__path)
+    ) {
+      return 0;
+    }
+    const width =
+      (stats.maxDeletionWidth * file.lines_deleted) / stats.maxDeleted;
+    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+  }
+
+  /**
+   * Get the x-offset of the deletion bar for a file.
+   */
+  _computeBarDeletionX(stats: SizeBarLayout) {
+    return stats.deletionOffset;
+  }
+
+  _computeShowSizeBars(userPrefs?: PreferencesInfo) {
+    return !!userPrefs?.size_bar_in_change_table;
+  }
+
+  _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
+    let hideClass = '';
+    if (!showSizeBars) {
+      hideClass = 'hide';
+    } else if (!this._showBarsForPath(path)) {
+      hideClass = 'invisible';
+    }
+    return `sizeBars desktop ${hideClass}`;
+  }
+
+  /**
+   * Shows registered dynamic columns iff the 'header', 'content' and
+   * 'summary' endpoints are registered the exact same number of times.
+   * Ideally, there should be a better way to enforce the expectation of the
+   * dependencies between dynamic endpoints.
+   */
+  _computeShowDynamicColumns(
+    headerEndpoints?: string,
+    contentEndpoints?: string,
+    summaryEndpoints?: string
+  ) {
+    return (
+      headerEndpoints &&
+      contentEndpoints &&
+      summaryEndpoints &&
+      headerEndpoints.length &&
+      headerEndpoints.length === contentEndpoints.length &&
+      headerEndpoints.length === summaryEndpoints.length
+    );
+  }
+
+  /**
+   * Shows registered dynamic prepended columns iff the 'header', 'content'
+   * endpoints are registered the exact same number of times.
+   */
+  _computeShowPrependedDynamicColumns(
+    headerEndpoints?: string,
+    contentEndpoints?: string
+  ) {
+    return (
+      headerEndpoints &&
+      contentEndpoints &&
+      headerEndpoints.length &&
+      headerEndpoints.length === contentEndpoints.length
+    );
+  }
+
+  /**
+   * Returns true if none of the inline diffs have been expanded.
+   */
+  _noDiffsExpanded() {
+    return this.filesExpanded === FilesExpandedState.NONE;
+  }
+
+  /**
+   * Method to call via binding when each file list row is rendered. This
+   * allows approximate detection of when the dom-repeat has completed
+   * rendering.
+   *
+   * @param index The index of the row being rendered.
+   */
+  _reportRenderedRow(index: number) {
+    if (index === this._shownFiles.length - 1) {
+      this.async(() => {
+        this.reporting.timeEndWithAverage(
+          RENDER_TIMING_LABEL,
+          RENDER_AVG_TIMING_LABEL,
+          this._reportinShownFilesIncrement
+        );
+      }, 1);
+    }
+    return '';
+  }
+
+  _reviewedTitle(reviewed?: boolean) {
+    if (reviewed) {
+      return 'Mark as not reviewed (shortcut: r)';
+    }
+
+    return 'Mark as reviewed (shortcut: r)';
+  }
+
+  _handleReloadingDiffPreference() {
+    this._getDiffPreferences().then(prefs => {
+      this.diffPrefs = prefs;
+    });
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeDisplayPath(path: string) {
+    return computeDisplayPath(path);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeTruncatedPath(path: string) {
+    return computeTruncatedPath(path);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-file-list': GrFileList;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index a577eb6..1c30a65 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -375,7 +375,7 @@
       <div class="stickyArea">
         <div
           class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
-          data-file$="[[_computeFileRange(file)]]"
+          data-file$="[[_computePatchSetFile(file)]]"
           tabindex="-1"
           role="row"
         >
@@ -657,7 +657,7 @@
             hidden="[[!_isFileExpanded(file.__path, _expandedFiles.*)]]"
             change-num="[[changeNum]]"
             patch-range="[[patchRange]]"
-            file="[[_computeFileRange(file)]]"
+            file="[[_computePatchSetFile(file)]]"
             path="[[file.__path]]"
             prefs="[[diffPrefs]]"
             project-name="[[change.project]]"
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 8aebf9b..129b65e 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
@@ -312,7 +312,7 @@
 
       for (const bytes in table) {
         if (table.hasOwnProperty(bytes)) {
-          assert.equal(element._formatBytes(bytes), table[bytes]);
+          assert.equal(element._formatBytes(Number(bytes)), table[bytes]);
         }
       }
     });
@@ -1329,15 +1329,23 @@
 
   suite('size bars', () => {
     test('_computeSizeBarLayout', () => {
-      assert.isUndefined(element._computeSizeBarLayout(null));
-      assert.isUndefined(element._computeSizeBarLayout({}));
-      assert.deepEqual(element._computeSizeBarLayout({base: []}), {
+      const defaultSizeBarLayout = {
         maxInserted: 0,
         maxDeleted: 0,
         maxAdditionWidth: 0,
         maxDeletionWidth: 0,
         deletionOffset: 0,
-      });
+      };
+
+      assert.deepEqual(
+          element._computeSizeBarLayout(null),
+          defaultSizeBarLayout);
+      assert.deepEqual(
+          element._computeSizeBarLayout({}),
+          defaultSizeBarLayout);
+      assert.deepEqual(
+          element._computeSizeBarLayout({base: []}),
+          defaultSizeBarLayout);
 
       const files = [
         {__path: '/COMMIT_MSG', lines_inserted: 10000},
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
deleted file mode 100644
index 88c4cef..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ /dev/null
@@ -1,363 +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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-account-dropdown/gr-account-dropdown.js';
-import '../gr-smart-search/gr-smart-search.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-main-header_html.js';
-import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {getAdminLinks} from '../../../utils/admin-nav-util.js';
-
-const DEFAULT_LINKS = [{
-  title: 'Changes',
-  links: [
-    {
-      url: '/q/status:open+-is:wip',
-      name: 'Open',
-    },
-    {
-      url: '/q/status:merged',
-      name: 'Merged',
-    },
-    {
-      url: '/q/status:abandoned',
-      name: 'Abandoned',
-    },
-  ],
-}];
-
-const DOCUMENTATION_LINKS = [
-  {
-    url: '/index.html',
-    name: 'Table of Contents',
-  },
-  {
-    url: '/user-search.html',
-    name: 'Searching',
-  },
-  {
-    url: '/user-upload.html',
-    name: 'Uploading',
-  },
-  {
-    url: '/access-control.html',
-    name: 'Access Control',
-  },
-  {
-    url: '/rest-api.html',
-    name: 'REST API',
-  },
-  {
-    url: '/intro-project-owner.html',
-    name: 'Project Owner Guide',
-  },
-];
-
-// Set of authentication methods that can provide custom registration page.
-const AUTH_TYPES_WITH_REGISTER_URL = new Set([
-  'LDAP',
-  'LDAP_BIND',
-  'CUSTOM_EXTENSION',
-]);
-
-/**
- * @extends PolymerElement
- */
-class GrMainHeader extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-main-header'; }
-
-  static get properties() {
-    return {
-      searchQuery: {
-        type: String,
-        notify: true,
-      },
-      loggedIn: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-      loading: {
-        type: Boolean,
-        reflectToAttribute: true,
-      },
-
-      /** @type {?Object} */
-      _account: Object,
-      _adminLinks: {
-        type: Array,
-        value() { return []; },
-      },
-      _defaultLinks: {
-        type: Array,
-        value() {
-          return DEFAULT_LINKS;
-        },
-      },
-      _docBaseUrl: {
-        type: String,
-        value: null,
-      },
-      _links: {
-        type: Array,
-        computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
-          '_topMenus, _docBaseUrl)',
-      },
-      loginUrl: {
-        type: String,
-        value: '/login',
-      },
-      _userLinks: {
-        type: Array,
-        value() { return []; },
-      },
-      _topMenus: {
-        type: Array,
-        value() { return []; },
-      },
-      _registerText: {
-        type: String,
-        value: 'Sign up',
-      },
-      _registerURL: {
-        type: String,
-        value: null,
-      },
-      mobileSearchHidden: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_accountLoaded(_account)',
-    ];
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'banner');
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadAccount();
-    this._loadConfig();
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-  }
-
-  reload() {
-    this._loadAccount();
-  }
-
-  _computeRelativeURL(path) {
-    return '//' + window.location.host + getBaseUrl() + path;
-  }
-
-  _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
-    // Polymer 2: check for undefined
-    if ([
-      defaultLinks,
-      userLinks,
-      adminLinks,
-      topMenus,
-      docBaseUrl,
-    ].includes(undefined)) {
-      return undefined;
-    }
-
-    const links = defaultLinks.map(menu => {
-      return {
-        title: menu.title,
-        links: menu.links.slice(),
-      };
-    });
-    if (userLinks && userLinks.length > 0) {
-      links.push({
-        title: 'Your',
-        links: userLinks.slice(),
-      });
-    }
-    const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
-    if (docLinks.length) {
-      links.push({
-        title: 'Documentation',
-        links: docLinks,
-        class: 'hideOnMobile',
-      });
-    }
-    links.push({
-      title: 'Browse',
-      links: adminLinks.slice(),
-    });
-    const topMenuLinks = [];
-    links.forEach(link => { topMenuLinks[link.title] = link.links; });
-    for (const m of topMenus) {
-      const items = m.items.map(this._fixCustomMenuItem).filter(link =>
-        // Ignore GWT project links
-        !link.url.includes('${projectName}')
-      );
-      if (m.name in topMenuLinks) {
-        items.forEach(link => { topMenuLinks[m.name].push(link); });
-      } else {
-        links.push({
-          title: m.name,
-          links: topMenuLinks[m.name] = items,
-        });
-      }
-    }
-    return links;
-  }
-
-  _getDocLinks(docBaseUrl, docLinks) {
-    if (!docBaseUrl || !docLinks) {
-      return [];
-    }
-    return docLinks.map(link => {
-      let url = docBaseUrl;
-      if (url && url[url.length - 1] === '/') {
-        url = url.substring(0, url.length - 1);
-      }
-      return {
-        url: url + link.url,
-        name: link.name,
-        target: '_blank',
-      };
-    });
-  }
-
-  _loadAccount() {
-    this.loading = true;
-    const promises = [
-      this.$.restAPI.getAccount(),
-      this.$.restAPI.getTopMenus(),
-      getPluginLoader().awaitPluginsLoaded(),
-    ];
-
-    return Promise.all(promises).then(result => {
-      const account = result[0];
-      this._account = account;
-      this.loggedIn = !!account;
-      this.loading = false;
-      this._topMenus = result[1];
-
-      return getAdminLinks(account,
-          params => this.$.restAPI.getAccountCapabilities(params),
-          () => this.$.jsAPI.getAdminMenuLinks())
-          .then(res => {
-            this._adminLinks = res.links;
-          });
-    });
-  }
-
-  _loadConfig() {
-    this.$.restAPI.getConfig()
-        .then(config => {
-          this._retrieveRegisterURL(config);
-          return getDocsBaseUrl(config, this.$.restAPI);
-        })
-        .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
-  }
-
-  _accountLoaded(account) {
-    if (!account) { return; }
-
-    this.$.restAPI.getPreferences().then(prefs => {
-      this._userLinks = prefs && prefs.my ?
-        prefs.my.map(this._fixCustomMenuItem) : [];
-    });
-  }
-
-  _retrieveRegisterURL(config) {
-    if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
-      this._registerURL = config.auth.register_url;
-      if (config.auth.register_text) {
-        this._registerText = config.auth.register_text;
-      }
-    }
-  }
-
-  _computeIsInvisible(registerURL) {
-    return registerURL ? '' : 'invisible';
-  }
-
-  _fixCustomMenuItem(linkObj) {
-    // Normalize all urls to PolyGerrit style.
-    if (linkObj.url.startsWith('#')) {
-      linkObj.url = linkObj.url.slice(1);
-    }
-
-    // Delete target property due to complications of
-    // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
-    //
-    // The server tries to guess whether URL is a view within the UI.
-    // If not, it sets target='_blank' on the menu item. The server
-    // makes assumptions that work for the GWT UI, but not PolyGerrit,
-    // so we'll just disable it altogether for now.
-    delete linkObj.target;
-
-    return linkObj;
-  }
-
-  _generateSettingsLink() {
-    return getBaseUrl() + '/settings/';
-  }
-
-  _onMobileSearchTap(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(new CustomEvent('mobile-search', {
-      composed: true, bubbles: false,
-    }));
-  }
-
-  _computeLinkGroupClass(linkGroup) {
-    if (linkGroup && linkGroup.class) {
-      return linkGroup.class;
-    }
-
-    return '';
-  }
-
-  _computeShowHideAriaLabel(mobileSearchHidden) {
-    if (mobileSearchHidden) {
-      return 'Show Searchbar';
-    } else {
-      return 'Hide Searchbar';
-    }
-  }
-}
-
-customElements.define(GrMainHeader.is, GrMainHeader);
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
new file mode 100644
index 0000000..5ad0d92
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -0,0 +1,396 @@
+/**
+ * @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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-account-dropdown/gr-account-dropdown';
+import '../gr-smart-search/gr-smart-search';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-main-header_html';
+import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  AccountDetailInfo,
+  ServerInfo,
+  TopMenuEntryInfo,
+  TopMenuItemInfo,
+} from '../../../types/common';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {AuthType} from '../../../constants/constants';
+
+interface FixedTopMenuItemInfo extends Omit<TopMenuItemInfo, 'target'> {
+  target?: never;
+}
+interface MainHeaderLink {
+  url: string;
+  name: string;
+}
+interface MainHeaderLinkGroup {
+  title: string;
+  links: MainHeaderLink[];
+  class?: string;
+}
+
+const DEFAULT_LINKS: MainHeaderLinkGroup[] = [
+  {
+    title: 'Changes',
+    links: [
+      {
+        url: '/q/status:open+-is:wip',
+        name: 'Open',
+      },
+      {
+        url: '/q/status:merged',
+        name: 'Merged',
+      },
+      {
+        url: '/q/status:abandoned',
+        name: 'Abandoned',
+      },
+    ],
+  },
+];
+
+const DOCUMENTATION_LINKS: MainHeaderLink[] = [
+  {
+    url: '/index.html',
+    name: 'Table of Contents',
+  },
+  {
+    url: '/user-search.html',
+    name: 'Searching',
+  },
+  {
+    url: '/user-upload.html',
+    name: 'Uploading',
+  },
+  {
+    url: '/access-control.html',
+    name: 'Access Control',
+  },
+  {
+    url: '/rest-api.html',
+    name: 'REST API',
+  },
+  {
+    url: '/intro-project-owner.html',
+    name: 'Project Owner Guide',
+  },
+];
+
+// Set of authentication methods that can provide custom registration page.
+const AUTH_TYPES_WITH_REGISTER_URL: Set<AuthType> = new Set([
+  AuthType.LDAP,
+  AuthType.LDAP_BIND,
+  AuthType.CUSTOM_EXTENSION,
+]);
+
+export interface GrMainHeader {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: JsApiService & Element;
+  };
+}
+
+@customElement('gr-main-header')
+export class GrMainHeader extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String, notify: true})
+  searchQuery?: string;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  loggedIn?: boolean;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  loading?: boolean;
+
+  @property({type: Object})
+  _account?: AccountDetailInfo;
+
+  @property({type: Array})
+  _adminLinks: NavLink[] = [];
+
+  @property({type: String})
+  _docBaseUrl: string | null = null;
+
+  @property({
+    type: Array,
+    computed: '_computeLinks(_userLinks, _adminLinks, _topMenus, _docBaseUrl)',
+  })
+  _links?: MainHeaderLinkGroup[];
+
+  @property({type: String})
+  loginUrl = '/login';
+
+  @property({type: Array})
+  _userLinks: FixedTopMenuItemInfo[] = [];
+
+  @property({type: Array})
+  _topMenus?: TopMenuEntryInfo[] = [];
+
+  @property({type: String})
+  _registerText = 'Sign up';
+
+  @property({type: String})
+  _registerURL?: string;
+
+  @property({type: Boolean})
+  mobileSearchHidden = false;
+
+  /** @override */
+  ready() {
+    super.ready();
+    this._ensureAttribute('role', 'banner');
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadAccount();
+    this._loadConfig();
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+  }
+
+  reload() {
+    this._loadAccount();
+  }
+
+  _computeRelativeURL(path: string) {
+    return '//' + window.location.host + getBaseUrl() + path;
+  }
+
+  _computeLinks(
+    userLinks?: FixedTopMenuItemInfo[],
+    adminLinks?: NavLink[],
+    topMenus?: TopMenuEntryInfo[],
+    docBaseUrl?: string | null,
+    // defaultLinks parameter is used in tests only
+    defaultLinks = DEFAULT_LINKS
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      userLinks === undefined ||
+      adminLinks === undefined ||
+      topMenus === undefined ||
+      docBaseUrl === undefined
+    ) {
+      return undefined;
+    }
+
+    const links: MainHeaderLinkGroup[] = defaultLinks.map(menu => {
+      return {
+        title: menu.title,
+        links: menu.links.slice(),
+      };
+    });
+    if (userLinks && userLinks.length > 0) {
+      links.push({
+        title: 'Your',
+        links: userLinks.slice(),
+      });
+    }
+    const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+    if (docLinks.length) {
+      links.push({
+        title: 'Documentation',
+        links: docLinks,
+        class: 'hideOnMobile',
+      });
+    }
+    links.push({
+      title: 'Browse',
+      links: adminLinks.slice(),
+    });
+    const topMenuLinks: {[name: string]: MainHeaderLink[]} = {};
+    links.forEach(link => {
+      topMenuLinks[link.title] = link.links;
+    });
+    for (const m of topMenus) {
+      const items = m.items.map(this._fixCustomMenuItem).filter(
+        link =>
+          // Ignore GWT project links
+          !link.url.includes('${projectName}')
+      );
+      if (m.name in topMenuLinks) {
+        items.forEach(link => {
+          topMenuLinks[m.name].push(link);
+        });
+      } else {
+        links.push({
+          title: m.name,
+          links: topMenuLinks[m.name] = items,
+        });
+      }
+    }
+    return links;
+  }
+
+  _getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
+    if (!docBaseUrl) {
+      return [];
+    }
+    return docLinks.map(link => {
+      let url = docBaseUrl;
+      if (url && url[url.length - 1] === '/') {
+        url = url.substring(0, url.length - 1);
+      }
+      return {
+        url: url + link.url,
+        name: link.name,
+        target: '_blank',
+      };
+    });
+  }
+
+  _loadAccount() {
+    this.loading = true;
+
+    return Promise.all([
+      this.$.restAPI.getAccount(),
+      this.$.restAPI.getTopMenus(),
+      getPluginLoader().awaitPluginsLoaded(),
+    ]).then(result => {
+      const account = result[0];
+      this._account = account;
+      this.loggedIn = !!account;
+      this.loading = false;
+      this._topMenus = result[1];
+
+      return getAdminLinks(
+        account,
+        () =>
+          this.$.restAPI.getAccountCapabilities().then(capabilities => {
+            if (!capabilities) {
+              throw new Error('getAccountCapabilities returns undefined');
+            }
+            return capabilities;
+          }),
+        () => this.$.jsAPI.getAdminMenuLinks()
+      ).then(res => {
+        this._adminLinks = res.links;
+      });
+    });
+  }
+
+  _loadConfig() {
+    this.$.restAPI
+      .getConfig()
+      .then(config => {
+        if (!config) {
+          throw new Error('getConfig returned undefined');
+        }
+        this._retrieveRegisterURL(config);
+        return getDocsBaseUrl(config, this.$.restAPI);
+      })
+      .then(docBaseUrl => {
+        this._docBaseUrl = docBaseUrl;
+      });
+  }
+
+  @observe('_account')
+  _accountLoaded(account?: AccountDetailInfo) {
+    if (!account) {
+      return;
+    }
+
+    this.$.restAPI.getPreferences().then(prefs => {
+      this._userLinks =
+        prefs && prefs.my ? prefs.my.map(this._fixCustomMenuItem) : [];
+    });
+  }
+
+  _retrieveRegisterURL(config: ServerInfo) {
+    if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
+      this._registerURL = config.auth.register_url;
+      if (config.auth.register_text) {
+        this._registerText = config.auth.register_text;
+      }
+    }
+  }
+
+  _computeIsInvisible(registerURL?: string) {
+    return registerURL ? '' : 'invisible';
+  }
+
+  _fixCustomMenuItem(linkObj: TopMenuItemInfo): FixedTopMenuItemInfo {
+    // TODO(TS): make a copy of linkObj instead of modifying the existing one
+    // Normalize all urls to PolyGerrit style.
+    if (linkObj.url.startsWith('#')) {
+      linkObj.url = linkObj.url.slice(1);
+    }
+
+    // Delete target property due to complications of
+    // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
+    //
+    // The server tries to guess whether URL is a view within the UI.
+    // If not, it sets target='_blank' on the menu item. The server
+    // makes assumptions that work for the GWT UI, but not PolyGerrit,
+    // so we'll just disable it altogether for now.
+    delete linkObj.target;
+
+    return (linkObj as unknown) as FixedTopMenuItemInfo;
+  }
+
+  _generateSettingsLink() {
+    return getBaseUrl() + '/settings/';
+  }
+
+  _onMobileSearchTap(e: Event) {
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(
+      new CustomEvent('mobile-search', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+  }
+
+  _computeLinkGroupClass(linkGroup: MainHeaderLinkGroup) {
+    return linkGroup.class ?? '';
+  }
+
+  _computeShowHideAriaLabel(mobileSearchHidden: boolean) {
+    if (mobileSearchHidden) {
+      return 'Show Searchbar';
+    } else {
+      return 'Hide Searchbar';
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-main-header': GrMainHeader;
+  }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
index 48194a6..e4db65f 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
@@ -103,22 +103,22 @@
 
     // When no admin links are passed, it should use the default.
     assert.deepEqual(element._computeLinks(
-        defaultLinks,
         /* userLinks= */[],
         adminLinks,
         /* topMenus= */[],
-        /* docBaseUrl= */ ''
+        /* docBaseUrl= */ '',
+        defaultLinks
     ),
     defaultLinks.concat({
       title: 'Browse',
       links: adminLinks,
     }));
     assert.deepEqual(element._computeLinks(
-        defaultLinks,
         userLinks,
         adminLinks,
         /* topMenus= */[],
-        /* docBaseUrl= */ ''
+        /* docBaseUrl= */ '',
+        defaultLinks
     ),
     defaultLinks.concat([
       {
@@ -142,7 +142,6 @@
 
     assert.deepEqual(element._getDocLinks(null, docLinks), []);
     assert.deepEqual(element._getDocLinks('', docLinks), []);
-    assert.deepEqual(element._getDocLinks('base', null), []);
     assert.deepEqual(element._getDocLinks('base', []), []);
 
     assert.deepEqual(element._getDocLinks('base', docLinks), [{
@@ -172,11 +171,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
     ), [{
       title: 'Browse',
       links: adminLinks,
@@ -208,11 +207,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
     ), [{
       title: 'Browse',
       links: adminLinks,
@@ -247,11 +246,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
     ), [{
       title: 'Browse',
       links: adminLinks,
@@ -284,11 +283,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        defaultLinks,
         /* userLinks= */ [],
         /* adminLinks= */ [],
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        defaultLinks
     ), [{
       title: 'Faves',
       links: defaultLinks[0].links.concat([{
@@ -315,11 +314,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
         userLinks,
         /* adminLinks= */ [],
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
     ), [{
       title: 'Your',
       links: userLinks.concat([{
@@ -346,11 +345,11 @@
       }],
     }];
     assert.deepEqual(element._computeLinks(
-        /* defaultLinks= */ [],
         /* userLinks= */ [],
         adminLinks,
         topMenus,
-        /* baseDocUrl= */ ''
+        /* baseDocUrl= */ '',
+        /* defaultLinks= */ []
     ), [{
       title: 'Browse',
       links: adminLinks.concat([{
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 0bb334a..8470611 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -32,6 +32,7 @@
   ParentPatchSetNum,
   ServerInfo,
 } from '../../../types/common';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 
 // Navigation parameters object format:
 //
@@ -664,7 +665,7 @@
    * @param basePatchNum The string 'PARENT' can be used for none.
    */
   getUrlForDiff(
-    change: ChangeInfo,
+    change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
     basePatchNum?: PatchSetNum,
@@ -723,7 +724,7 @@
   },
 
   getEditUrlForDiff(
-    change: ChangeInfo,
+    change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
     lineNum?: number
@@ -763,7 +764,7 @@
    * @param basePatchNum The string 'PARENT' can be used for none.
    */
   navigateToDiff(
-    change: ChangeInfo,
+    change: ChangeInfo | ParsedChangeInfo,
     filePath: string,
     patchNum?: PatchSetNum,
     basePatchNum?: PatchSetNum,
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index acc2cbd..180e4a7 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -124,11 +124,15 @@
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
 
-type SuggestionProvider = (
+export type SuggestionProvider = (
   predicate: string,
   expression: string
 ) => Promise<AutocompleteSuggestion[]>;
 
+export interface SearchBarHandleSearchDetail {
+  inputVal: string;
+}
+
 export interface GrSearchBar {
   $: {
     restAPI: RestApiService & Element;
@@ -254,7 +258,8 @@
     } else {
       target.blur();
     }
-    const trimmedInput = this._inputVal && this._inputVal.trim();
+    if (!this._inputVal) return;
+    const trimmedInput = this._inputVal.trim();
     if (trimmedInput) {
       const predefinedOpOnlyQuery = [
         ...SEARCH_OPERATORS_WITH_NEGATIONS_SET,
@@ -262,9 +267,12 @@
       if (predefinedOpOnlyQuery) {
         return;
       }
+      const detail: SearchBarHandleSearchDetail = {
+        inputVal: this._inputVal,
+      };
       this.dispatchEvent(
         new CustomEvent('handle-search', {
-          detail: {inputVal: this._inputVal},
+          detail,
         })
       );
     }
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
deleted file mode 100644
index 813298c..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ /dev/null
@@ -1,180 +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 '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-search-bar/gr-search-bar.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-smart-search_html.js';
-import {GerritNav} from '../gr-navigation/gr-navigation.js';
-import {getUserName} from '../../../utils/display-name-util.js';
-
-const MAX_AUTOCOMPLETE_RESULTS = 10;
-const SELF_EXPRESSION = 'self';
-const ME_EXPRESSION = 'me';
-
-/**
- * @extends PolymerElement
- */
-class GrSmartSearch extends GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement)) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-smart-search'; }
-
-  static get properties() {
-    return {
-      searchQuery: String,
-      _config: Object,
-      _projectSuggestions: {
-        type: Function,
-        value() {
-          return (predicate, expression) =>
-            this._fetchProjects(predicate, expression);
-        },
-      },
-      _groupSuggestions: {
-        type: Function,
-        value() {
-          return (predicate, expression) =>
-            this._fetchGroups(predicate, expression);
-        },
-      },
-      _accountSuggestions: {
-        type: Function,
-        value() {
-          return (predicate, expression) =>
-            this._fetchAccounts(predicate, expression);
-        },
-      },
-      /**
-       * Invisible label for input element. This label is exposed to
-       * screen readers by nested element
-       */
-      label: {
-        type: String,
-        value: '',
-      },
-    };
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.$.restAPI.getConfig().then(cfg => {
-      this._config = cfg;
-    });
-  }
-
-  _handleSearch(e) {
-    const input = e.detail.inputVal;
-    if (input) {
-      GerritNav.navigateToSearchQuery(input);
-    }
-  }
-
-  /**
-   * Fetch from the API the predicted projects.
-   *
-   * @param {string} predicate - The first part of the search term, e.g.
-   *     'project'
-   * @param {string} expression - The second part of the search term, e.g.
-   *     'gerr'
-   * @return {!Promise} This returns a promise that resolves to an array of
-   *     strings.
-   */
-  _fetchProjects(predicate, expression) {
-    return this.$.restAPI.getSuggestedProjects(
-        expression,
-        MAX_AUTOCOMPLETE_RESULTS)
-        .then(projects => {
-          if (!projects) { return []; }
-          const keys = Object.keys(projects);
-          return keys.map(key => { return {text: predicate + ':' + key}; });
-        });
-  }
-
-  /**
-   * Fetch from the API the predicted groups.
-   *
-   * @param {string} predicate - The first part of the search term, e.g.
-   *     'ownerin'
-   * @param {string} expression - The second part of the search term, e.g.
-   *     'polyger'
-   * @return {!Promise} This returns a promise that resolves to an array of
-   *     strings.
-   */
-  _fetchGroups(predicate, expression) {
-    if (expression.length === 0) { return Promise.resolve([]); }
-    return this.$.restAPI.getSuggestedGroups(
-        expression,
-        MAX_AUTOCOMPLETE_RESULTS)
-        .then(groups => {
-          if (!groups) { return []; }
-          const keys = Object.keys(groups);
-          return keys.map(key => { return {text: predicate + ':' + key}; });
-        });
-  }
-
-  /**
-   * Fetch from the API the predicted accounts.
-   *
-   * @param {string} predicate - The first part of the search term, e.g.
-   *     'owner'
-   * @param {string} expression - The second part of the search term, e.g.
-   *     'kasp'
-   * @return {!Promise} This returns a promise that resolves to an array of
-   *     strings.
-   */
-  _fetchAccounts(predicate, expression) {
-    if (expression.length === 0) { return Promise.resolve([]); }
-    return this.$.restAPI.getSuggestedAccounts(
-        expression,
-        MAX_AUTOCOMPLETE_RESULTS)
-        .then(accounts => {
-          if (!accounts) { return []; }
-          return this._mapAccountsHelper(accounts, predicate);
-        })
-        .then(accounts => {
-          // When the expression supplied is a beginning substring of 'self',
-          // add it as an autocomplete option.
-          if (SELF_EXPRESSION.startsWith(expression)) {
-            return accounts.concat(
-                [{text: predicate + ':' + SELF_EXPRESSION}]);
-          } else if (ME_EXPRESSION.startsWith(expression)) {
-            return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
-          } else {
-            return accounts;
-          }
-        });
-  }
-
-  _mapAccountsHelper(accounts, predicate) {
-    return accounts.map(account => {
-      const userName = getUserName(this._serverConfig, account);
-      return {
-        label: account.name || '',
-        text: account.email ?
-          `${predicate}:${account.email}` :
-          `${predicate}:"${userName}"`,
-      };
-    });
-  }
-}
-
-customElements.define(GrSmartSearch.is, GrSmartSearch);
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
new file mode 100644
index 0000000..a818c59
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -0,0 +1,197 @@
+/**
+ * @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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-search-bar/gr-search-bar';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-smart-search_html';
+import {GerritNav} from '../gr-navigation/gr-navigation';
+import {getUserName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {
+  SearchBarHandleSearchDetail,
+  SuggestionProvider,
+} from '../gr-search-bar/gr-search-bar';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+const MAX_AUTOCOMPLETE_RESULTS = 10;
+const SELF_EXPRESSION = 'self';
+const ME_EXPRESSION = 'me';
+
+export interface GrSmartSearch {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+
+@customElement('gr-smart-search')
+export class GrSmartSearch extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  searchQuery?: string;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  @property({type: Object})
+  _projectSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchProjects(predicate, expression);
+
+  @property({type: Object})
+  _groupSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchGroups(predicate, expression);
+
+  @property({type: Object})
+  _accountSuggestions: SuggestionProvider = (predicate, expression) =>
+    this._fetchAccounts(predicate, expression);
+
+  @property({type: String})
+  label = '';
+
+  /** @override */
+  attached() {
+    super.attached();
+    this.$.restAPI.getConfig().then(cfg => {
+      this._config = cfg;
+    });
+  }
+
+  _handleSearch(e: CustomEvent<SearchBarHandleSearchDetail>) {
+    const input = e.detail.inputVal;
+    if (input) {
+      GerritNav.navigateToSearchQuery(input);
+    }
+  }
+
+  /**
+   * Fetch from the API the predicted projects.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'project'
+   * @param expression - The second part of the search term, e.g.
+   * 'gerr'
+   */
+  _fetchProjects(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    return this.$.restAPI
+      .getSuggestedProjects(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .then(projects => {
+        if (!projects) {
+          return [];
+        }
+        const keys = Object.keys(projects);
+        return keys.map(key => {
+          return {text: predicate + ':' + key};
+        });
+      });
+  }
+
+  /**
+   * Fetch from the API the predicted groups.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'ownerin'
+   * @param expression - The second part of the search term, e.g.
+   * 'polyger'
+   */
+  _fetchGroups(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    if (expression.length === 0) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI
+      .getSuggestedGroups(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .then(groups => {
+        if (!groups) {
+          return [];
+        }
+        const keys = Object.keys(groups);
+        return keys.map(key => {
+          return {text: predicate + ':' + key};
+        });
+      });
+  }
+
+  /**
+   * Fetch from the API the predicted accounts.
+   *
+   * @param predicate - The first part of the search term, e.g.
+   * 'owner'
+   * @param expression - The second part of the search term, e.g.
+   * 'kasp'
+   */
+  _fetchAccounts(
+    predicate: string,
+    expression: string
+  ): Promise<AutocompleteSuggestion[]> {
+    if (expression.length === 0) {
+      return Promise.resolve([]);
+    }
+    return this.$.restAPI
+      .getSuggestedAccounts(expression, MAX_AUTOCOMPLETE_RESULTS)
+      .then(accounts => {
+        if (!accounts) {
+          return [];
+        }
+        return this._mapAccountsHelper(accounts, predicate);
+      })
+      .then(accounts => {
+        // When the expression supplied is a beginning substring of 'self',
+        // add it as an autocomplete option.
+        if (SELF_EXPRESSION.startsWith(expression)) {
+          return accounts.concat([{text: predicate + ':' + SELF_EXPRESSION}]);
+        } else if (ME_EXPRESSION.startsWith(expression)) {
+          return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
+        } else {
+          return accounts;
+        }
+      });
+  }
+
+  _mapAccountsHelper(
+    accounts: AccountInfo[],
+    predicate: string
+  ): AutocompleteSuggestion[] {
+    return accounts.map(account => {
+      const userName = getUserName(this._config, account);
+      return {
+        label: account.name || '',
+        text: account.email
+          ? `${predicate}:${account.email}`
+          : `${predicate}:"${userName}"`,
+      };
+    });
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-smart-search': GrSmartSearch;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 4bfa6a9..fa31d09 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -1212,7 +1212,7 @@
   }
 
   _computeModeSelectHideClass(_diff) {
-    return _diff.binary ? 'hide' : '';
+    return (!_diff || _diff.binary) ? 'hide' : '';
   }
 
   _onLineSelected(e, detail) {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index de283d8..a9df3f9 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -143,7 +143,7 @@
     notify: true,
     computed: '_computeRootId(comments.*)',
   })
-  rootId?: string;
+  rootId?: UrlEncodedCommentId;
 
   @property({type: Boolean})
   showFilePath = false;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 26bf50a..8c26d4a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -26,7 +26,7 @@
   RevisionInfo,
 } from '../../../types/common';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
-import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
+import {GrAdminApi, MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
 import {
   JsApiService,
   EventCallback,
@@ -294,8 +294,8 @@
       );
   }
 
-  getAdminMenuLinks() {
-    const links = [];
+  getAdminMenuLinks(): MenuLink[] {
+    const links: MenuLink[] = [];
     for (const cb of this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
       const adminApi = (cb as unknown) as GrAdminApi;
       links.push(...adminApi.getMenuLinks());
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 505e62e..261298b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -18,6 +18,7 @@
 import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
 import {DiffLayer} from '../../../types/types';
 import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
 
 export interface ShowChangeDetail {
   change: ChangeInfo;
@@ -50,5 +51,6 @@
   getDiffLayers(path: string, changeNum: number): DiffLayer[];
   disposeDiffLayers(path: string): void;
   getCoverageAnnotationApi(): Promise<GrAnnotationActionsInterface | undefined>;
+  getAdminMenuLinks(): MenuLink[];
   // TODO(TS): Add more methods when needed for the TS conversion.
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index d1459a3..15cdac4 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -138,6 +138,7 @@
   RevisionId,
   GroupName,
   Hashtag,
+  TopMenuEntryInfo,
 } from '../../../types/common';
 import {
   CancelConditionCallback,
@@ -1603,7 +1604,10 @@
     }) as Promise<string[] | undefined>;
   }
 
-  getChangeOrEditFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
+  getChangeOrEditFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined> {
     if (patchNumEquals(patchRange.patchNum, EditPatchSetNum)) {
       return this.getChangeEditFiles(changeNum, patchRange).then(
         res => res && res.files
@@ -2086,13 +2090,16 @@
     }) as Promise<ChangeInfo[] | undefined>;
   }
 
-  getReviewedFiles(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+  getReviewedFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<string[] | undefined> {
     return this._getChangeURLAndFetch({
       changeNum,
       endpoint: '/files?reviewed',
       patchNum,
       reportEndpointAsIs: true,
-    });
+    }) as Promise<string[] | undefined>;
   }
 
   saveFileReviewed(
@@ -3215,12 +3222,12 @@
     }) as Promise<CapabilityInfoMap | undefined>;
   }
 
-  getTopMenus(errFn?: ErrorCallback) {
+  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined> {
     return this._fetchSharedCacheURL({
       url: '/config/server/top-menus',
       errFn,
       reportUrlAsIs: true,
-    });
+    }) as Promise<TopMenuEntryInfo[] | undefined>;
   }
 
   setAssignee(
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 80df630..b255ea5 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
@@ -1082,7 +1082,7 @@
   _shortcut_v_key_last_pressed: number | null;
   _shortcut_go_table: Map<string, string>;
   _shortcut_v_table: Map<string, string>;
-  keyboardShortcuts(): {[key: string]: string};
+  keyboardShortcuts(): {[key: string]: string | null};
   createTitle(name: Shortcut, section: ShortcutSection): string;
   bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
   shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 3082aab..6b93082 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -96,6 +96,8 @@
   DashboardId,
   HashtagsInput,
   Hashtag,
+  FileNameToFileInfoMap,
+  TopMenuEntryInfo,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {HttpMethod, IgnoreWhitespaceType} from '../../../constants/constants';
@@ -815,4 +817,31 @@
     changeNum: NumericChangeId,
     topic: string | null
   ): Promise<string>;
+
+  getChangeOrEditFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined>;
+
+  getReviewedFiles(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<string[] | undefined>;
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean
+  ): Promise<Response>;
+
+  saveFileReviewed(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    path: string,
+    reviewed: boolean,
+    errFn: ErrorCallback
+  ): Promise<Response | undefined>;
+
+  getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined>;
 }
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index ed36412..5bcf0b8 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -43,6 +43,7 @@
   DraftsAction,
   NotifyType,
   EmailFormat,
+  AuthType,
 } from '../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 
@@ -743,10 +744,10 @@
 /**
  * The AuthInfo entity contains information about the authentication
  * configuration of the Gerrit server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
  */
 export interface AuthInfo {
-  type: string;
+  auth_type: AuthType; // docs incorrectly names it 'type'
   use_contributor_agreements: boolean;
   contributor_agreements?: ContributorAgreementInfo;
   editable_account_fields: string;
@@ -1124,17 +1125,17 @@
 
 /**
  * The TopMenuEntryInfo entity contains information about a top menu entry.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-entry-info
  */
 export interface TopMenuEntryInfo {
   name: string;
-  items: string;
+  items: TopMenuItemInfo[];
 }
 
 /**
  * The TopMenuItemInfo entity contains information about a menu item ina top
  * menu entry.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-item-info
  */
 export interface TopMenuItemInfo {
   url: string;
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 7e63f70..119b09b 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -26,7 +26,7 @@
  */
 export function asyncForeach<T>(
   array: T[],
-  fn: (item: T, stopCallback: () => void) => Promise<T>
+  fn: (item: T, stopCallback: () => void) => Promise<unknown>
 ): Promise<T | void> {
   if (!array.length) {
     return Promise.resolve();
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index ae6d616..76db40b 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -211,13 +211,13 @@
 export function descendedFromClass(
   element: Element,
   className: string,
-  opt_stopElement: Element
+  stopElement?: Element
 ) {
   let isDescendant = element.classList.contains(className);
   while (
     !isDescendant &&
     element.parentElement &&
-    (!opt_stopElement || element.parentElement !== opt_stopElement)
+    (!stopElement || element.parentElement !== stopElement)
   ) {
     isDescendant = element.classList.contains(className);
     element = element.parentElement;