Separate file list header into new component

Change-Id: I45619dfaaf005a89f8de6d3e9dc39a2bcbca1893
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index d6ad5ae..39ae763 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -21,12 +21,10 @@
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../diff/gr-diff-preferences/gr-diff-preferences.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-content/gr-editable-content.html">
-<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
@@ -37,6 +35,7 @@
 <link rel="import" href="../gr-commit-info/gr-commit-info.html">
 <link rel="import" href="../gr-download-dialog/gr-download-dialog.html">
 <link rel="import" href="../gr-file-list/gr-file-list.html">
+<link rel="import" href="../gr-file-list-header/gr-file-list-header.html">
 <link rel="import" href="../gr-messages-list/gr-messages-list.html">
 <link rel="import" href="../gr-related-changes-list/gr-related-changes-list.html">
 <link rel="import" href="../gr-reply-dialog/gr-reply-dialog.html">
@@ -80,9 +79,6 @@
         font-size: 1.2em;
         font-weight: bold;
       }
-      .prefsButton {
-        float: right;
-      }
       gr-change-star {
         margin-right: .25em;
         vertical-align: -.425em;
@@ -152,45 +148,10 @@
         flex: 1;
         overflow-x: hidden;
       }
-      .collapseToggleButton {
-        text-decoration: none;
-      }
       .relatedChanges {
         flex: 1 1 auto;
         overflow: hidden;
       }
-      .patchInfo {
-        border: 1px solid #ddd;
-        margin: 1em var(--default-horizontal-margin);
-      }
-      .patchInfoEdit .patchInfo-header {
-        background-color: #fcfad6;
-      }
-      .patchInfoOldPatchSet .patchInfo-header {
-        background-color: #fff9c4;
-      }
-      .patchInfoOldPatchSet .latestPatchContainer {
-        display: initial;
-      }
-      .patchInfo-header,
-      .fileList {
-        padding: .5em calc(var(--default-horizontal-margin) / 2);
-      }
-      .patchInfo-header {
-        background-color: #f6f6f6;
-        border-bottom: 1px solid #ebebeb;
-        display: flex;
-        justify-content: space-between;
-      }
-      .latestPatchContainer {
-        display: none;
-      }
-      .patchSetSelect {
-        max-width: 8em;
-      }
-      gr-editable-label.descriptionLabel {
-        max-width: 100%;
-      }
       .mobile {
         display: none;
       }
@@ -203,13 +164,6 @@
         height: 0;
         margin-bottom: 1em;
       }
-      #diffPrefsContainer,
-      .rightControls {
-        margin: auto 0 auto auto;
-      }
-      .patchInfo-header-wrapper {
-        width: 100%;
-      }
       #commitMessage.collapsed {
         max-height: 36em;
         overflow: hidden;
@@ -246,32 +200,12 @@
       .showOnEdit {
         display: none;
       }
-      .editLoaded .hideOnEdit {
-        display: none;
+      .patchInfo {
+        border: 1px solid #ddd;
+        margin: 1em var(--default-horizontal-margin);
       }
-      .editLoaded .showOnEdit {
-        display: initial;
-      }
-      .fileList-header {
-        display: flex;
-        font-weight: bold;
-        justify-content: space-between;
-        margin-bottom: .5em;
-      }
-      .rightControls {
-        display: flex;
-        flex-wrap: wrap;
-        font-weight: normal;
-        justify-content: flex-end;
-      }
-      .separator {
-        margin: 0 .25em;
-      }
-      .expandInline {
-        padding-right: .25em;
-      }
-      .patchSetSelect {
-        max-width: 8em;
+      #fileList {
+        padding: .5em calc(var(--default-horizontal-margin) / 2);
       }
       @media screen and (min-width: 80em) {
         .commitMessage {
@@ -357,7 +291,7 @@
     <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
     <div
         id="mainContent"
-        class$="container [[_computeEditLoadedClass(_editLoaded)]]"
+        class="container"
         hidden$="{{_loading}}">
       <div class$="hideOnMobileOverlay [[_computeHeaderClass(_change)]]">
         <span class="header-title">
@@ -486,138 +420,44 @@
           </div>
         </div>
       </section>
-      <section class$="patchInfo hideOnMobileOverlay [[_computePatchInfoClass(_patchRange.patchNum,
-          _allPatchSets)]]">
-        <div class="patchInfo-header">
-          <div class="patchInfo-header-wrapper">
-            <label class="patchSelectLabel" for="patchSetSelect">
-              Patch set
-            </label>
-            <gr-select
-                id="patchSetSelect"
-                bind-value="{{_selectedPatchSet}}"
-                class="patchSetSelect"
-                on-change="_handlePatchChange">
-              <select>
-                <template is="dom-repeat" items="[[_allPatchSets]]"
-                    as="patchNum">
-                  <option
-                      value$="[[patchNum.num]]"
-                      disabled$="[[_computePatchSetDisabled(patchNum.num, _patchRange.basePatchNum, _sortedRevisions)]]">
-                    [[patchNum.num]]
-                    /
-                    [[computeLatestPatchNum(_allPatchSets)]]
-                    [[_computePatchSetCommentsString(_comments, patchNum.num)]]
-                    [[_computePatchSetDescription(_change, patchNum.num)]]
-                  </option>
-                </template>
-              </select>
-            </gr-select>
-            /
-            <gr-commit-info
-                change="[[_change]]"
-                server-config="[[_serverConfig]]"
-                commit-info="[[_commitInfo]]"></gr-commit-info>
-            <span class="latestPatchContainer">
-              /
-              <a href$="[[_computeChangeUrl(_change)]]">Go to latest patch set</a>
-            </span>
-            <span class="downloadContainer desktop">
-              /
-              <gr-button link
-                  class="download"
-                  on-tap="_handleDownloadTap">Download</gr-button>
-            </span>
-            <span class="descriptionContainer hideOnEdit">
-              /
-              <gr-editable-label
-                  id="descriptionLabel"
-                  class="descriptionLabel"
-                  value="[[_computePatchSetDescription(_change, _selectedPatchSet)]]"
-                  placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
-                  read-only="[[_descriptionReadOnly]]"
-                  on-changed="_handleDescriptionChanged"></gr-editable-label>
-            </span>
-            <span id="diffPrefsContainer"
-                class="hideOnEdit"
-                hidden$="[[_computePrefsButtonHidden(_diffPrefs, _loggedIn)]]"
-                hidden>
-              <gr-button link
-                  class="prefsButton desktop"
-                  on-tap="_handlePrefsTap">Diff Preferences</gr-button>
-            </span>
-          </div>
-        </div>
-        <div class="fileList">
-          <div class="fileList-header">
-            <div>Files</div>
-            <div class="rightControls">
-              <template is="dom-if"
-                  if="[[_fileListActionsVisible(_shownFileCount, _maxFilesForBulkActions)]]">
-                <gr-button
-                    id="expandBtn"
-                    link
-                    on-tap="_expandAllDiffs">Show diffs</gr-button>
-                <span class="separator">/</span>
-                <gr-button
-                    id="collapseBtn"
-                    link
-                    on-tap="_collapseAllDiffs">Hide diffs</gr-button>
-              </template>
-              <template is="dom-if"
-                  if="[[!_fileListActionsVisible(_shownFileCount, _maxFilesForBulkActions)]]">
-                <div class="warning">
-                  Bulk actions disabled because there are too many files.
-                </div>
-              </template>
-              <span class="separator">/</span>
-              <gr-select
-                  id="modeSelect"
-                  bind-value="{{viewState.diffMode}}">
-                <select>
-                  <option value="SIDE_BY_SIDE">Side By Side</option>
-                  <option value="UNIFIED_DIFF">Unified</option>
-                </select>
-              </gr-select>
-              <span class="separator">/</span>
-              <label>
-                Diff against
-                <gr-select id="patchChange" bind-value="{{_diffAgainst}}"
-                    class="patchSetSelect" on-change="_handleBasePatchChange">
-                  <select>
-                    <option value="PARENT">Base</option>
-                    <template
-                        is="dom-repeat"
-                        items="[[_allPatchSets]]"
-                        as="patchNum">
-                      <option
-                          disabled$="[[_computeBasePatchDisabled(patchNum.num, _patchRange.patchNum, _sortedRevisions)]]"
-                          value$="[[patchNum.num]]">
-                        [[patchNum.num]]
-                        [[patchNum.desc]]
-                      </option>
-                    </template>
-                  </select>
-                </gr-select>
-              </label>
-            </div>
-          </div>
-          <gr-file-list id="fileList"
-              diff-prefs="{{_diffPrefs}}"
-              change="[[_change]]"
-              change-num="[[_changeNum]]"
-              patch-range="{{_patchRange}}"
-              comments="[[_comments]]"
-              drafts="[[_diffDrafts]]"
-              revisions="[[_sortedRevisions]]"
-              project-config="[[_projectConfig]]"
-              selected-index="{{viewState.selectedFileIndex}}"
-              diff-view-mode="[[viewState.diffMode]]"
-              edit-loaded="[[_editLoaded]]"
-              num-files-shown="{{_numFilesShown}}"
-              file-list-increment="{{_numFilesShown}}"
-              on-files-shown-changed="_setShownFiles"></gr-file-list>
-        </div>
+      <section class="patchInfo hideOnMobileOverlay">
+        <gr-file-list-header
+            id="fileListHeader"
+            account="[[_account]]"
+            all-patch-sets="[[_allPatchSets]]"
+            change="[[_change]]"
+            change-num="[[_changeNum]]"
+            comments="[[_comments]]"
+            commit-info="[[_commitInfo]]"
+            change-url="[[_computeChangeUrl(_change)]]"
+            edit-loaded="[[_editLoaded]]"
+            logged-in="[[_loggedIn]]"
+            server-config="[[_serverConfig]]"
+            shown-file-count="[[_shownFileCount]]"
+            diff-prefs="[[_diffPrefs]]"
+            diff-view-mode="{{viewState.diffMode}}"
+            patch-range="{{_patchRange}}"
+            revisions="[[_sortedRevisions]]"
+            on-open-diff-prefs="_handleOpenDiffPrefs"
+            on-open-download-dialog="_handleOpenDownloadDialog"
+            on-expand-diffs="_expandAllDiffs"
+            on-collapse-diffs="_collapseAllDiffs">
+        </gr-file-list-header>
+        <gr-file-list id="fileList"
+            diff-prefs="{{_diffPrefs}}"
+            change="[[_change]]"
+            change-num="[[_changeNum]]"
+            patch-range="{{_patchRange}}"
+            comments="[[_comments]]"
+            drafts="[[_diffDrafts]]"
+            revisions="[[_sortedRevisions]]"
+            project-config="[[_projectConfig]]"
+            selected-index="{{viewState.selectedFileIndex}}"
+            diff-view-mode="[[viewState.diffMode]]"
+            edit-loaded="[[_editLoaded]]"
+            num-files-shown="{{_numFilesShown}}"
+            file-list-increment="{{_numFilesShown}}"
+            on-files-shown-changed="_setShownFiles"></gr-file-list>
       </section>
       <gr-messages-list id="messageList"
           class="hideOnMobileOverlay"
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 b60453b..1991c78 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
@@ -23,8 +23,6 @@
   const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
   const DEFAULT_NUM_FILES_SHOWN = 200;
 
-  // Maximum length for patch set descriptions.
-  const PATCH_DESC_MAX_LENGTH = 500;
   const REVIEWERS_REGEX = /^(R|CC)=/gm;
   const MIN_CHECK_INTERVAL_SECS = 0;
 
@@ -135,17 +133,9 @@
         computed:
           '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
       },
-      // Caps the number of files that can be shown and have the 'show diffs' /
-      // 'hide diffs' buttons still be functional.
-      _maxFilesForBulkActions: {
-        type: Number,
-        readOnly: true,
-        value: 225,
-      },
         /** @type {?} */
       _patchRange: {
         type: Object,
-        observer: '_updateSelected',
       },
       _relatedChangesLoading: {
         type: Boolean,
@@ -175,10 +165,6 @@
         type: Boolean,
         value: false,
       },
-      _descriptionReadOnly: {
-        type: Boolean,
-        computed: '_computeDescriptionReadOnly(_loggedIn, _change, _account)',
-      },
       _replyDisabled: {
         type: Boolean,
         value: true,
@@ -293,10 +279,6 @@
       this._sortedRevisions = this.sortRevisions(Object.values(revisions));
     },
 
-    _computePrefsButtonHidden(prefs, loggedIn) {
-      return !loggedIn || !prefs;
-    },
-
     _handleEditCommitMessage(e) {
       this._editingCommitMessage = true;
       this.$.commitMessageEditor.focusTextarea();
@@ -338,11 +320,6 @@
       return false;
     },
 
-    _handlePrefsTap(e) {
-      e.preventDefault();
-      this.$.fileList.openDiffPrefs();
-    },
-
     _handleCommentSave(e) {
       if (!e.target.comment.__draft) { return; }
 
@@ -409,21 +386,16 @@
       this._diffDrafts = diffDrafts;
     },
 
-    _handleBasePatchChange(e) {
-      this._changePatchNum(this._selectedPatchSet, e.target.value, true);
-    },
-
-    _handlePatchChange(e) {
-      this._changePatchNum(e.target.value, this._diffAgainst, true);
-    },
-
     _handleReplyTap(e) {
       e.preventDefault();
       this._openReplyDialog();
     },
 
-    _handleDownloadTap(e) {
-      e.preventDefault();
+    _handleOpenDiffPrefs() {
+      this.$.fileList.openDiffPrefs();
+    },
+
+    _handleOpenDownloadDialog() {
       this.$.downloadOverlay.open().then(() => {
         this.$.downloadOverlay
             .setFocusStops(this.$.downloadDialog.getFocusStops());
@@ -494,10 +466,6 @@
       this._shownFileCount = e.detail.length;
     },
 
-    _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
-      return shownFileCount <= maxFilesForBulkActions;
-    },
-
     _expandAllDiffs() {
       this.$.fileList.expandAllDiffs();
     },
@@ -666,39 +634,12 @@
           this._patchRange.patchNum ||
               this.computeLatestPatchNum(this._allPatchSets));
 
-      this._updateSelected();
+      this.$.fileListHeader.updateSelected();
 
       const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
       this.fire('title-change', {title});
     },
 
-    /**
-     * Change active patch to the provided patch num.
-     * @param {number|string} basePatchNum the base patch to be viewed.
-     * @param {number|string} patchNum the patch number to be viewed.
-     * @param {boolean} opt_forceParams When set to true, the resulting URL will
-     *     always include the patch range, even if the requested patchNum is
-     *     known to be the latest.
-     */
-    _changePatchNum(patchNum, basePatchNum, opt_forceParams) {
-      if (!opt_forceParams) {
-        let currentPatchNum;
-        if (this._change.current_revision) {
-          currentPatchNum =
-              this._change.revisions[this._change.current_revision]._number;
-        } else {
-          currentPatchNum = this.computeLatestPatchNum(this._allPatchSets);
-        }
-        if (this.patchNumEquals(patchNum, currentPatchNum) &&
-            basePatchNum === 'PARENT') {
-          Gerrit.Nav.navigateToChange(this._change);
-          return;
-        }
-      }
-      Gerrit.Nav.navigateToChange(this._change, patchNum,
-          basePatchNum);
-    },
-
     _computeChangeUrl(change) {
       return Gerrit.Nav.getUrlForChange(change);
     },
@@ -753,37 +694,6 @@
       return CHANGE_ID_ERROR.MISSING;
     },
 
-    _computePatchInfoClass(patchNum, allPatchSets) {
-      if (this.patchNumEquals(patchNum, this.EDIT_NAME)) {
-        return 'patchInfoEdit';
-      }
-
-      const latestNum = this.computeLatestPatchNum(allPatchSets);
-      if (this.patchNumEquals(patchNum, latestNum)) {
-        return '';
-      }
-      return 'patchInfoOldPatchSet';
-    },
-
-    /**
-     * Determines if a patch number should be disabled based on value of the
-     * basePatchNum from gr-file-list.
-     * @param {number} patchNum Patch number available in dropdown
-     * @param {number|string} basePatchNum Base patch number from file list
-     * @return {boolean}
-     */
-    _computePatchSetDisabled(patchNum, basePatchNum) {
-      if (basePatchNum === 'PARENT') { return false; }
-
-      return this.findSortedIndex(patchNum, this._sortedRevisions) <=
-          this.findSortedIndex(basePatchNum, this._sortedRevisions);
-    },
-
-    _computeBasePatchDisabled(patchNum, currentPatchNum) {
-      return this.findSortedIndex(patchNum, this._sortedRevisions) >=
-          this.findSortedIndex(currentPatchNum, this._sortedRevisions);
-    },
-
     _computeLabelNames(labels) {
       return Object.keys(labels).sort();
     },
@@ -1163,81 +1073,11 @@
       ]);
     },
 
-    _updateSelected() {
-      this._selectedPatchSet = this._patchRange.patchNum;
-      this._diffAgainst = this._patchRange.basePatchNum;
-    },
-
-    _computePatchSetDescription(change, patchNum) {
-      const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
-      return (rev && rev.description) ?
-          rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
-    },
-
-    _computePatchSetCommentsString(allComments, patchNum) {
-      let numComments = 0;
-      let numUnresolved = 0;
-      for (const file in allComments) {
-        if (allComments.hasOwnProperty(file)) {
-          numComments += this.$.fileList.getCommentsForPath(
-              allComments, patchNum, file).length;
-          numUnresolved += this.$.fileList.computeUnresolvedNum(
-              allComments, {}, patchNum, file);
-        }
-      }
-      let commentsStr = '';
-      if (numComments > 0) {
-        commentsStr = '(' + numComments + ' comments';
-        if (numUnresolved > 0) {
-          commentsStr += ', ' + numUnresolved + ' unresolved';
-        }
-        commentsStr += ')';
-      }
-      return commentsStr;
-    },
-
-    _computeDescriptionPlaceholder(readOnly) {
-      return (readOnly ? 'No' : 'Add a') + ' patch set description';
-    },
-
-    _handleDescriptionChanged(e) {
-      const desc = e.detail.trim();
-      const rev = this.getRevisionByPatchNum(this._change.revisions,
-          this._selectedPatchSet);
-      const sha = this._getPatchsetHash(this._change.revisions, rev);
-      this.$.restAPI.setDescription(this._changeNum,
-          this._selectedPatchSet, desc)
-          .then(res => {
-            if (res.ok) {
-              this.set(['_change', 'revisions', sha, 'description'], desc);
-            }
-          });
-    },
-
-
-    /**
-     * @param {!Object} revisions The revisions object keyed by revision hashes
-     * @param {?Object} patchSet A revision already fetched from {revisions}
-     * @return {string|undefined} the SHA hash corresponding to the revision.
-     */
-    _getPatchsetHash(revisions, patchSet) {
-      for (const rev in revisions) {
-        if (revisions.hasOwnProperty(rev) &&
-            revisions[rev] === patchSet) {
-          return rev;
-        }
-      }
-    },
-
     _computeCanStartReview(loggedIn, change, account) {
       return !!(loggedIn && change.work_in_progress &&
           change.owner._account_id === account._account_id);
     },
 
-    _computeDescriptionReadOnly(loggedIn, change, account) {
-      return !(loggedIn && (account._account_id === change.owner._account_id));
-    },
-
     _computeReplyDisabled() { return false; },
 
     _computeChangePermalinkAriaLabel(changeNum) {
@@ -1424,9 +1264,5 @@
       const patchRange = patchRangeRecord.base || {};
       return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
     },
-
-    _computeEditLoadedClass(editLoaded) {
-      return editLoaded ? 'editLoaded' : '';
-    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index 5389f1c6..24181e5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -171,6 +171,20 @@
         assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
       });
 
+      test('expand all messages when expand-diffs fired', () => {
+        const handleExpand =
+            sandbox.stub(element.$.fileList, 'expandAllDiffs');
+        element.$.fileListHeader.fire('expand-diffs');
+        assert.isTrue(handleExpand.called);
+      });
+
+      test('collapse all messages when collapse-diffs fired', () => {
+        const handleCollapse =
+        sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+        element.$.fileListHeader.fire('collapse-diffs');
+        assert.isTrue(handleCollapse.called);
+      });
+
       test('X should expand all messages', () => {
         const handleExpand =
             sandbox.stub(element.$.messageList, 'handleExpandCollapse');
@@ -238,79 +252,13 @@
       });
     });
 
-    test('Diff preferences hidden when no prefs or logged out', () => {
-      element._loggedIn = false;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-      element._loggedIn = true;
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-      element._loggedIn = false;
-      element._diffPrefs = {font_size: '12'};
-      flushAsynchronousOperations();
-      assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-      element._loggedIn = true;
-      flushAsynchronousOperations();
-      assert.isFalse(element.$.diffPrefsContainer.hidden);
-    });
-
-    test('prefsButton opens gr-diff-preferences', () => {
-      const handlePrefsTapSpy = sandbox.spy(element, '_handlePrefsTap');
+    test('diff preferences open when open-diff-prefs is fired', () => {
       const overlayOpenStub = sandbox.stub(element.$.fileList,
           'openDiffPrefs');
-      const prefsButton = Polymer.dom(element.root).querySelectorAll(
-          '.prefsButton')[0];
-
-      MockInteractions.tap(prefsButton);
-
-      assert.isTrue(handlePrefsTapSpy.called);
+      element.$.fileListHeader.fire('open-diff-prefs');
       assert.isTrue(overlayOpenStub.called);
     });
 
-    test('_computeDescriptionReadOnly', () => {
-      assert.equal(element._computeDescriptionReadOnly(false,
-          {owner: {_account_id: 1}}, {_account_id: 1}), true);
-      assert.equal(element._computeDescriptionReadOnly(true,
-          {owner: {_account_id: 0}}, {_account_id: 1}), true);
-      assert.equal(element._computeDescriptionReadOnly(true,
-          {owner: {_account_id: 1}}, {_account_id: 1}), false);
-    });
-
-    test('_computeDescriptionPlaceholder', () => {
-      assert.equal(element._computeDescriptionPlaceholder(true),
-          'No patch set description');
-      assert.equal(element._computeDescriptionPlaceholder(false),
-          'Add a patch set description');
-    });
-
-    test('_computePatchSetDisabled', () => {
-      element._sortedRevisions = [
-        {_number: 1},
-        {_number: 2},
-        {_number: element.EDIT_NAME, basePatchNum: 2},
-        {_number: 3},
-      ];
-      let basePatchNum = 'PARENT';
-      let patchNum = 1;
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          false);
-      basePatchNum = 1;
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          true);
-      patchNum = 2;
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          false);
-      basePatchNum = element.EDIT_NAME;
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          true);
-      patchNum = '3';
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          false);
-    });
-
     test('_prepareCommitMsgForLinkify', () => {
       let commitMessage = 'R=test@google.com';
       let result = element._prepareCommitMsgForLinkify(commitMessage);
@@ -325,80 +273,6 @@
       assert.equal(result, 'CC=\u200Btest@google.com');
     }),
 
-    test('_computePatchSetCommentsString', () => {
-      // Test string with unresolved comments.
-      comments = {
-        foo: 'foo comments',
-        bar: 'bar comments',
-        xyz: 'xyz comments',
-      };
-      sandbox.stub(element.$.fileList, 'getCommentsForPath', (c, p, f) => {
-        if (f == 'foo') {
-          return ['comment1', 'comment2'];
-        } else if (f == 'bar') {
-          return ['comment1'];
-        } else {
-          return [];
-        }
-      });
-      sandbox.stub(element.$.fileList, 'computeUnresolvedNum', (c, d, p, f) => {
-        if (f == 'foo') {
-          return 0;
-        } else if (f == 'bar') {
-          return 1;
-        } else {
-          return 0;
-        }
-      });
-      assert.equal(element._computePatchSetCommentsString(comments, 1),
-          '(3 comments, 1 unresolved)');
-
-      // Test string with no unresolved comments.
-      delete comments['bar'];
-      assert.equal(element._computePatchSetCommentsString(comments, 1),
-          '(2 comments)');
-
-      // Test string with no comments.
-      delete comments['foo'];
-      assert.equal(element._computePatchSetCommentsString(comments, 1), '');
-    });
-
-    test('_handleDescriptionChanged', () => {
-      const putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
-          .returns(Promise.resolve({ok: true}));
-      sandbox.stub(element, '_computeDescriptionReadOnly');
-
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      element._selectedPatchNum = '1';
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        actions: {},
-        owner: {_account_id: 1},
-      };
-      element._account = {_account_id: 1};
-      element._loggedIn = true;
-
-      flushAsynchronousOperations();
-      const label = element.$.descriptionLabel;
-      assert.equal(label.value, 'test');
-      label.editing = true;
-      label._inputText = 'test2';
-      label._save();
-      flushAsynchronousOperations();
-      assert.isTrue(putDescStub.called);
-      assert.equal(putDescStub.args[0][2], 'test2');
-    });
-
     test('_updateRebaseAction', () => {
       const currentRevisionActions = {
         cherrypick: {
@@ -572,106 +446,6 @@
       assert.equal(element._numFilesShown, 200);
     });
 
-    test('patch num change', done => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev2: {_number: 2},
-          rev1: {_number: 1},
-          rev13: {_number: 13},
-          rev3: {_number: 3},
-        },
-        current_revision: 'rev3',
-        status: 'NEW',
-        labels: {},
-      };
-      element.viewState.diffMode = 'UNIFIED';
-      flushAsynchronousOperations();
-
-      const selectEl = element.$$('.patchInfo-header gr-select');
-      assert.ok(selectEl);
-      const optionEls = Polymer.dom(element.root).querySelectorAll(
-          '.patchInfo-header option');
-      assert.equal(optionEls.length, 4);
-      const select = element.$$('.patchInfo-header #patchSetSelect').bindValue;
-      assert.notEqual(select, 1);
-      assert.equal(select, 2);
-      assert.notEqual(select, 3);
-      assert.equal(optionEls[3].value, 13);
-
-      let numEvents = 0;
-      selectEl.addEventListener('change', e => {
-        assert.equal(element.viewState.diffMode, 'UNIFIED');
-        numEvents++;
-        if (numEvents == 1) {
-          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
-              element._change, '1', 'PARENT'));
-          selectEl.nativeSelect.value = '3';
-          element.fire('change', {}, {node: selectEl.nativeSelect});
-        } else if (numEvents == 2) {
-          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
-              element._change, '3', 'PARENT'));
-          done();
-        }
-      });
-      selectEl.nativeSelect.value = '1';
-      element.fire('change', {}, {node: selectEl.nativeSelect});
-    });
-
-    test('patch num change with missing current_revision', done => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev2: {_number: 2},
-          rev1: {_number: 1},
-          rev13: {_number: 13},
-          rev3: {_number: 3},
-        },
-        status: 'NEW',
-        labels: {},
-      };
-      flushAsynchronousOperations();
-      const selectEl = element.$$('.patchInfo-header gr-select');
-      assert.ok(selectEl);
-      const optionEls = Polymer.dom(element.root).querySelectorAll(
-          '.patchInfo-header option');
-      assert.equal(optionEls.length, 4);
-      assert.notEqual(
-          element.$$('.patchInfo-header #patchSetSelect').bindValue, 1);
-      assert.equal(
-          element.$$('.patchInfo-header #patchSetSelect').bindValue, 2);
-      assert.notEqual(
-          element.$$('.patchInfo-header #patchSetSelect').bindValue, 3);
-      assert.equal(optionEls[3].value, 13);
-
-      let numEvents = 0;
-      selectEl.addEventListener('change', e => {
-        numEvents++;
-        if (numEvents == 1) {
-          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
-              element._change, '1', 'PARENT'));
-          selectEl.nativeSelect.value = '3';
-          element.fire('change', {}, {node: selectEl.nativeSelect});
-        } else if (numEvents == 2) {
-          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
-              element._change, '3', 'PARENT'));
-          done();
-        }
-      });
-      selectEl.nativeSelect.value = '1';
-      element.fire('change', {}, {node: selectEl.nativeSelect});
-    });
-
     test('diffMode defaults to side by side without preferences', done => {
       sandbox.stub(element.$.restAPI, 'getPreferences').returns(
           Promise.resolve({}));
@@ -703,101 +477,6 @@
       });
     });
 
-    test('diff against dropdown', done => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '3',
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-          rev3: {_number: 'edit', basePatchNum: 2},
-          rev4: {_number: 3},
-        },
-        status: 'NEW',
-        labels: {},
-      };
-
-      flush(() => {
-        const selectEl = element.$.patchChange;
-        assert.equal(selectEl.nativeSelect.value, 'PARENT');
-        assert.isTrue(element.$$('#patchChange option[value="3"]')
-            .hasAttribute('disabled'));
-        selectEl.addEventListener('change', () => {
-          assert.equal(selectEl.nativeSelect.value, 'edit');
-          assert(navigateToChangeStub.lastCall.calledWithExactly(
-              element._change, '3', 'edit'),
-              'Should navigate to /c/42/edit..3');
-          done();
-        });
-        selectEl.nativeSelect.value = 'edit';
-        element.fire('change', {}, {node: selectEl.nativeSelect});
-      });
-    });
-
-    test('expandAllDiffs called when expand button clicked', () => {
-      element._shownFileCount = 1;
-      flushAsynchronousOperations();
-      sandbox.stub(element.$.fileList, 'expandAllDiffs');
-      MockInteractions.tap(Polymer.dom(element.root).querySelector(
-          '#expandBtn'));
-      assert.isTrue(element.$.fileList.expandAllDiffs.called);
-    });
-
-    test('collapseAllDiffs called when expand button clicked', () => {
-      element._shownFileCount = 1;
-      flushAsynchronousOperations();
-      sandbox.stub(element.$.fileList, 'collapseAllDiffs');
-      MockInteractions.tap(Polymer.dom(element.root).querySelector(
-          '#collapseBtn'));
-      assert.isTrue(element.$.fileList.collapseAllDiffs.called);
-    });
-
-    test('show/hide diffs disabled for large amounts of files', done => {
-      const computeSpy = sandbox.spy(element, '_fileListActionsVisible');
-      element._files = [];
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      element._shownFileCount = 1;
-      flush(() => {
-        assert.isTrue(computeSpy.lastCall.returnValue);
-        _.times(element._maxFilesForBulkActions + 1, () => {
-          element._shownFileCount = element._shownFileCount + 1;
-        });
-        assert.isFalse(computeSpy.lastCall.returnValue);
-        done();
-      });
-    });
-
-    test('diff mode selector initializes from preferences', () => {
-      let resolvePrefs;
-      const prefsPromise = new Promise(resolve => {
-        resolvePrefs = resolve;
-      });
-      sandbox.stub(element.$.restAPI, 'getPreferences').returns(prefsPromise);
-
-      // Attach a new gr-change-view so we can intercept the preferences fetch.
-      const view = document.createElement('gr-change-view');
-      const select = view.$.modeSelect;
-      fixture('blank').appendChild(view);
-      flushAsynchronousOperations();
-
-      // At this point the diff mode doesn't yet have the user's preference.
-      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
-
-      // Receive the overriding preference.
-      resolvePrefs({default_diff_view: 'UNIFIED'});
-      flushAsynchronousOperations();
-      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
-      document.getElementById('blank').restore();
-    });
-
     test('don’t reload entire page when patchRange changes', () => {
       const reloadStub = sandbox.stub(element, '_reload',
           () => { return Promise.resolve(); });
@@ -841,35 +520,6 @@
       assert.isTrue(collapseStub.calledTwice);
     });
 
-    test('include base patch when not parent', () => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: '2',
-        patchNum: '3',
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev2: {_number: 2},
-          rev1: {_number: 1},
-          rev13: {_number: 13},
-          rev3: {_number: 3},
-        },
-        status: 'NEW',
-        labels: {},
-      };
-
-      element._changePatchNum(13, 2);
-      assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
-          element._change, 13, 2));
-
-      element._patchRange.basePatchNum = 'PARENT';
-
-      element._changePatchNum(3, 'PARENT');
-      assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
-          element._change, 3, 'PARENT'));
-    });
-
     test('related changes are updated and new patch selected after rebase',
         done => {
           element._changeNum = '42';
@@ -888,7 +538,6 @@
 
     test('related changes are not updated after other action', done => {
       sandbox.stub(element, '_reload', () => { return Promise.resolve(); });
-      sandbox.stub(element, '_updateSelected');
       sandbox.stub(element.$.relatedChanges, 'reload');
       const e = {detail: {action: 'abandon'}};
       element._handleReloadChange(e).then(() => {
@@ -1092,15 +741,6 @@
           '_openReplyDialog should have been passed CCS');
     });
 
-    test('class is applied to file list on old patch set', () => {
-      const allPatchSets = [{num: 1}, {num: 2}, {num: 4}];
-      assert.equal(element._computePatchInfoClass('1', allPatchSets),
-          'patchInfoOldPatchSet');
-      assert.equal(element._computePatchInfoClass('2', allPatchSets),
-          'patchInfoOldPatchSet');
-      assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
-    });
-
     test('getUrlParameter functionality', () => {
       const locationStub = sandbox.stub(element, '_getLocationSearch');
 
@@ -1510,30 +1150,14 @@
       assert.equal(element._patchRange.patchNum, 'baz');
     });
 
-    suite('editLoaded behavior', () => {
-      setup(() => {
-        element._loggedIn = true;
-        element._diffPrefs = {};
-      });
+    test('_editLoaded set when patchNum is an edit', () => {
+      sandbox.stub(element, 'computeLatestPatchNum').returns('2');
+      element._patchRange = {patchNum: element.EDIT_NAME};
 
-      const isVisible = el => {
-        assert.ok(el);
-        return getComputedStyle(el).getPropertyValue('display') !== 'none';
-      };
+      assert.isTrue(element._editLoaded);
+      element.set('_patchRange.patchNum', 1);
 
-      test('patch specific elements', () => {
-        sandbox.stub(element, 'computeLatestPatchNum').returns('2');
-        element._patchRange = {patchNum: element.EDIT_NAME};
-        flushAsynchronousOperations();
-
-        assert.isFalse(isVisible(element.$.diffPrefsContainer));
-        assert.isFalse(isVisible(element.$$('.descriptionContainer')));
-        element.set('_patchRange.patchNum', 1);
-        flushAsynchronousOperations();
-
-        assert.isTrue(isVisible(element.$$('.descriptionContainer')));
-        assert.isTrue(isVisible(element.$.diffPrefsContainer));
-      });
+      assert.isFalse(element._editLoaded);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
new file mode 100644
index 0000000..edd5389
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -0,0 +1,226 @@
+<!--
+Copyright (C) 2017 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+
+
+<dom-module id="gr-file-list-header">
+  <template>
+    <style include="shared-styles">
+      .prefsButton {
+        float: right;
+      }
+      .collapseToggleButton {
+        text-decoration: none;
+      }
+      .patchInfoEdit.patchInfo-header {
+        background-color: #fcfad6;
+      }
+      .patchInfoOldPatchSet.patchInfo-header {
+        background-color: #fff9c4;
+      }
+      .patchInfoOldPatchSet .latestPatchContainer {
+        display: initial;
+      }
+      .patchInfo-header {
+        padding: .5em calc(var(--default-horizontal-margin) / 2);
+      }
+      .patchInfo-header {
+        background-color: #f6f6f6;
+        border-bottom: 1px solid #ebebeb;
+        display: flex;
+        justify-content: space-between;
+      }
+      .latestPatchContainer {
+        display: none;
+      }
+      .patchSetSelect {
+        max-width: 8em;
+      }
+      gr-editable-label.descriptionLabel {
+        max-width: 100%;
+      }
+      .mobile {
+        display: none;
+      }
+      #diffPrefsContainer,
+      .rightControls {
+        margin: auto 0 auto auto;
+      }
+      .patchInfo-header-wrapper {
+        width: 100%;
+      }
+      .showOnEdit {
+        display: none;
+      }
+      .editLoaded .hideOnEdit {
+        display: none;
+      }
+      .editLoaded .showOnEdit {
+        display: initial;
+      }
+      .fileList-header {
+        display: flex;
+        font-weight: bold;
+        justify-content: space-between;
+        margin: .5em calc(var(--default-horizontal-margin) / 2);
+      }
+      .rightControls {
+        display: flex;
+        flex-wrap: wrap;
+        font-weight: normal;
+        justify-content: flex-end;
+      }
+      .separator {
+        margin: 0 .25em;
+      }
+      .expandInline {
+        padding-right: .25em;
+      }
+      .patchSetSelect {
+        max-width: 8em;
+      }
+      .editLoaded .hideOnEdit {
+        display: none;
+      }
+      .editLoaded .showOnEdit {
+        display: initial;
+      }
+    </style>
+    <div class$="patchInfo-header [[_computeEditLoadedClass(editLoaded)]] [[_computePatchInfoClass(patchRange.patchNum, allPatchSets)]]">
+      <div class="patchInfo-header-wrapper">
+        <label class="patchSelectLabel" for="patchSetSelect">
+          Patch set
+        </label>
+        <gr-select
+            id="patchSetSelect"
+            bind-value="{{_selectedPatchSet}}"
+            class="patchSetSelect"
+            on-change="_handlePatchChange">
+          <select>
+            <template is="dom-repeat" items="[[allPatchSets]]"
+                as="patchNum">
+              <option
+                  value$="[[patchNum.num]]"
+                  disabled$="[[_computePatchSetDisabled(patchNum.num, patchRange.basePatchNum, revisions)]]">
+                [[patchNum.num]]
+                /
+                [[computeLatestPatchNum(allPatchSets)]]
+                [[_computePatchSetCommentsString(comments, patchNum.num)]]
+                [[_computePatchSetDescription(change, patchNum.num)]]
+              </option>
+            </template>
+          </select>
+        </gr-select>
+        /
+        <gr-commit-info
+            change="[[change]]"
+            server-config="[[serverConfig]]"
+            commit-info="[[commitInfo]]"></gr-commit-info>
+        <span class="latestPatchContainer">
+          /
+          <a href$="[[changeUrl]]">Go to latest patch set</a>
+        </span>
+        <span class="downloadContainer desktop">
+          /
+          <gr-button link
+              class="download"
+              on-tap="_handleDownloadTap">Download</gr-button>
+        </span>
+        <span class="descriptionContainer hideOnEdit">
+          /
+          <gr-editable-label
+              id="descriptionLabel"
+              class="descriptionLabel"
+              value="[[_computePatchSetDescription(change, _selectedPatchSet)]]"
+              placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
+              read-only="[[_descriptionReadOnly]]"
+              on-changed="_handleDescriptionChanged"></gr-editable-label>
+        </span>
+        <span id="diffPrefsContainer"
+            class="hideOnEdit"
+            hidden$="[[_computePrefsButtonHidden(diffPrefs, loggedIn)]]"
+            hidden>
+          <gr-button link
+              class="prefsButton desktop"
+              on-tap="_handlePrefsTap">Diff Preferences</gr-button>
+        </span>
+      </div>
+    </div>
+    <div class="fileList-header">
+      <div>Files</div>
+      <div class="rightControls">
+        <template is="dom-if"
+            if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
+          <gr-button
+              id="expandBtn"
+              link
+              on-tap="_expandAllDiffs">Show diffs</gr-button>
+          <span class="separator">/</span>
+          <gr-button
+              id="collapseBtn"
+              link
+              on-tap="_collapseAllDiffs">Hide diffs</gr-button>
+        </template>
+        <template is="dom-if"
+            if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
+          <div class="warning">
+            Bulk actions disabled because there are too many files.
+          </div>
+        </template>
+        <span class="separator">/</span>
+        <gr-select
+            id="modeSelect"
+            bind-value="{{diffViewMode}}">
+          <select>
+            <option value="SIDE_BY_SIDE">Side By Side</option>
+            <option value="UNIFIED_DIFF">Unified</option>
+          </select>
+        </gr-select>
+        <span class="separator">/</span>
+        <label>
+          Diff against
+          <gr-select id="patchChange" bind-value="{{_diffAgainst}}"
+              class="patchSetSelect" on-change="_handleBasePatchChange">
+            <select>
+              <option value="PARENT">Base</option>
+              <template
+                  is="dom-repeat"
+                  items="[[allPatchSets]]"
+                  as="patchNum">
+                <option
+                    disabled$="[[_computeBasePatchDisabled(patchNum.num, patchRange.patchNum, revisions)]]"
+                    value$="[[patchNum.num]]">
+                  [[patchNum.num]]
+                  [[patchNum.desc]]
+                </option>
+              </template>
+            </select>
+          </gr-select>
+        </label>
+      </div>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-file-list-header.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
new file mode 100644
index 0000000..4d56cd8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
@@ -0,0 +1,264 @@
+// Copyright (C) 2017 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.
+(function() {
+  'use strict';
+
+  // Maximum length for patch set descriptions.
+  const PATCH_DESC_MAX_LENGTH = 500;
+
+  Polymer({
+    is: 'gr-file-list-header',
+
+    properties: {
+      account: Object,
+      allPatchSets: Array,
+      change: Object,
+      changeNum: String,
+      changeUrl: String,
+      comments: Object,
+      commitInfo: Object,
+      editLoaded: Boolean,
+      loggedIn: Boolean,
+      serverConfig: Object,
+      shownFileCount: Number,
+      diffPrefs: Object,
+      diffViewMode: String,
+      /** @type {?} */
+      patchRange: {
+        type: Object,
+        observer: 'updateSelected',
+      },
+      revisions: Array,
+      // Caps the number of files that can be shown and have the 'show diffs' /
+      // 'hide diffs' buttons still be functional.
+      _maxFilesForBulkActions: {
+        type: Number,
+        readOnly: true,
+        value: 225,
+      },
+      _descriptionReadOnly: {
+        type: Boolean,
+        computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
+      },
+      _selectedPatchSet: String,
+      _diffAgainst: String,
+    },
+
+    behaviors: [
+      Gerrit.PatchSetBehavior,
+    ],
+
+    _expandAllDiffs() {
+      this.fire('expand-diffs');
+    },
+
+    _collapseAllDiffs() {
+      this.fire('collapse-diffs');
+    },
+
+    updateSelected() {
+      this._selectedPatchSet = this.patchRange.patchNum;
+      this._diffAgainst = this.patchRange.basePatchNum;
+    },
+
+    _computeDescriptionPlaceholder(readOnly) {
+      return (readOnly ? 'No' : 'Add a') + ' patch set description';
+    },
+
+    _computeDescriptionReadOnly(loggedIn, change, account) {
+      return !(loggedIn && (account._account_id === change.owner._account_id));
+    },
+
+    _computePatchSetDescription(change, patchNum) {
+      const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
+      return (rev && rev.description) ?
+          rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
+    },
+
+
+    /**
+     * @param {!Object} revisions The revisions object keyed by revision hashes
+     * @param {?Object} patchSet A revision already fetched from {revisions}
+     * @return {string|undefined} the SHA hash corresponding to the revision.
+     */
+    _getPatchsetHash(revisions, patchSet) {
+      for (const rev in revisions) {
+        if (revisions.hasOwnProperty(rev) &&
+            revisions[rev] === patchSet) {
+          return rev;
+        }
+      }
+    },
+
+    _handleDescriptionChanged(e) {
+      const desc = e.detail.trim();
+      const rev = this.getRevisionByPatchNum(this.change.revisions,
+          this._selectedPatchSet);
+      const sha = this._getPatchsetHash(this.change.revisions, rev);
+      this.$.restAPI.setDescription(this.changeNum,
+          this._selectedPatchSet, desc)
+          .then(res => {
+            if (res.ok) {
+              this.set(['_change', 'revisions', sha, 'description'], desc);
+            }
+          });
+    },
+
+    _computeBasePatchDisabled(patchNum, currentPatchNum) {
+      return this.findSortedIndex(patchNum, this.revisions) >=
+          this.findSortedIndex(currentPatchNum, this.revisions);
+    },
+
+    _computePrefsButtonHidden(prefs, loggedIn) {
+      return !loggedIn || !prefs;
+    },
+
+    // Copied from gr-file-list
+    _getCommentsForPath(comments, patchNum, path) {
+      return (comments[path] || []).filter(c => {
+        return this.patchNumEquals(c.patch_set, patchNum);
+      });
+    },
+
+    // Copied from gr-file-list
+    _computeUnresolvedNum(comments, drafts, patchNum, path) {
+      comments = this._getCommentsForPath(comments, patchNum, path);
+      drafts = this._getCommentsForPath(drafts, patchNum, path);
+      comments = comments.concat(drafts);
+
+      // Create an object where every comment ID is the key of an unresolved
+      // comment.
+
+      const idMap = comments.reduce((acc, comment) => {
+        if (comment.unresolved) {
+          acc[comment.id] = true;
+        }
+        return acc;
+      }, {});
+
+      // Set false for the comments that are marked as parents.
+      for (const comment of comments) {
+        idMap[comment.in_reply_to] = false;
+      }
+
+      // The unresolved comments are the comments that still have true.
+      const unresolvedLeaves = Object.keys(idMap).filter(key => {
+        return idMap[key];
+      });
+
+      return unresolvedLeaves.length;
+    },
+
+    _computePatchSetCommentsString(allComments, patchNum) {
+      let numComments = 0;
+      let numUnresolved = 0;
+      for (const file in allComments) {
+        if (allComments.hasOwnProperty(file)) {
+          numComments += this._getCommentsForPath(
+              allComments, patchNum, file).length;
+          numUnresolved += this._computeUnresolvedNum(
+              allComments, {}, patchNum, file);
+        }
+      }
+      let commentsStr = '';
+      if (numComments > 0) {
+        commentsStr = '(' + numComments + ' comments';
+        if (numUnresolved > 0) {
+          commentsStr += ', ' + numUnresolved + ' unresolved';
+        }
+        commentsStr += ')';
+      }
+      return commentsStr;
+    },
+
+    _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
+      return shownFileCount <= maxFilesForBulkActions;
+    },
+
+    /**
+     * Determines if a patch number should be disabled based on value of the
+     * basePatchNum from gr-file-list.
+     * @param {number} patchNum Patch number available in dropdown
+     * @param {number|string} basePatchNum Base patch number from file list
+     * @return {boolean}
+     */
+    _computePatchSetDisabled(patchNum, basePatchNum) {
+      if (basePatchNum === 'PARENT') { return false; }
+
+      return this.findSortedIndex(patchNum, this.revisions) <=
+          this.findSortedIndex(basePatchNum, this.revisions);
+    },
+
+        /**
+     * Change active patch to the provided patch num.
+     * @param {number|string} basePatchNum the base patch to be viewed.
+     * @param {number|string} patchNum the patch number to be viewed.
+     * @param {boolean} opt_forceParams When set to true, the resulting URL will
+     *     always include the patch range, even if the requested patchNum is
+     *     known to be the latest.
+     */
+    _changePatchNum(patchNum, basePatchNum, opt_forceParams) {
+      if (!opt_forceParams) {
+        let currentPatchNum;
+        if (this.change.current_revision) {
+          currentPatchNum =
+              this.change.revisions[this.change.current_revision]._number;
+        } else {
+          currentPatchNum = this.computeLatestPatchNum(this.allPatchSets);
+        }
+        if (this.patchNumEquals(patchNum, currentPatchNum) &&
+            basePatchNum === 'PARENT') {
+          Gerrit.Nav.navigateToChange(this.change);
+          return;
+        }
+      }
+      Gerrit.Nav.navigateToChange(this.change, patchNum,
+          basePatchNum);
+    },
+
+    _handleBasePatchChange(e) {
+      this._changePatchNum(this._selectedPatchSet, e.target.value, true);
+    },
+
+    _handlePatchChange(e) {
+      this._changePatchNum(e.target.value, this._diffAgainst, true);
+    },
+
+    _handlePrefsTap(e) {
+      e.preventDefault();
+      this.fire('open-diff-prefs');
+    },
+
+    _handleDownloadTap(e) {
+      e.preventDefault();
+      this.fire('open-download-dialog');
+    },
+
+    _computeEditLoadedClass(editLoaded) {
+      return editLoaded ? 'editLoaded' : '';
+    },
+
+    _computePatchInfoClass(patchNum, allPatchSets) {
+      if (this.patchNumEquals(patchNum, this.EDIT_NAME)) {
+        return 'patchInfoEdit';
+      }
+
+      const latestNum = this.computeLatestPatchNum(allPatchSets);
+      if (this.patchNumEquals(patchNum, latestNum)) {
+        return '';
+      }
+      return 'patchInfoOldPatchSet';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
new file mode 100644
index 0000000..320e13d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
@@ -0,0 +1,435 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-file-list-header</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="../../../bower_components/page/page.js"></script>
+
+<link rel="import" href="gr-file-list-header.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-file-list-header></gr-file-list-header>
+  </template>
+</test-fixture>
+
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-file-list-header tests', () => {
+    let element;
+    let sandbox;
+    let navigateToChangeStub;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({test: 'config'}); },
+        getAccount() { return Promise.resolve(null); },
+        _fetchSharedCacheURL() { return Promise.resolve({}); },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(done => {
+      flush(() => {
+        sandbox.restore();
+        done();
+      });
+    });
+
+    test('Diff preferences hidden when no prefs or logged out', () => {
+      element.loggedIn = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element.loggedIn = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element.loggedIn = false;
+      element.diffPrefs = {font_size: '12'};
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element.loggedIn = true;
+      flushAsynchronousOperations();
+      assert.isFalse(element.$.diffPrefsContainer.hidden);
+    });
+
+    test('_computeDescriptionReadOnly', () => {
+      assert.equal(element._computeDescriptionReadOnly(false,
+          {owner: {_account_id: 1}}, {_account_id: 1}), true);
+      assert.equal(element._computeDescriptionReadOnly(true,
+          {owner: {_account_id: 0}}, {_account_id: 1}), true);
+      assert.equal(element._computeDescriptionReadOnly(true,
+          {owner: {_account_id: 1}}, {_account_id: 1}), false);
+    });
+
+    test('_computeDescriptionPlaceholder', () => {
+      assert.equal(element._computeDescriptionPlaceholder(true),
+          'No patch set description');
+      assert.equal(element._computeDescriptionPlaceholder(false),
+          'Add a patch set description');
+    });
+
+    test('_computePatchSetDisabled', () => {
+      element.revisions = [
+        {_number: 1},
+        {_number: 2},
+        {_number: element.EDIT_NAME, basePatchNum: 2},
+        {_number: 3},
+      ];
+      let basePatchNum = 'PARENT';
+      let patchNum = 1;
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          false);
+      basePatchNum = 1;
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          true);
+      patchNum = 2;
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          false);
+      basePatchNum = element.EDIT_NAME;
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          true);
+      patchNum = '3';
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          false);
+    });
+
+    test('_computePatchSetCommentsString', () => {
+      // Test string with unresolved comments.
+
+      comments = {
+        foo: [{
+          id: '27dcee4d_f7b77cfa',
+          message: 'test',
+          patch_set: 1,
+          unresolved: true,
+        }],
+        bar: [{
+          id: '27dcee4d_f7b77cfa',
+          message: 'test',
+          patch_set: 1,
+        },
+        {
+          id: '27dcee4d_f7b77cfa',
+          message: 'test',
+          patch_set: 1,
+        }],
+        abc: [],
+      };
+
+      assert.equal(element._computePatchSetCommentsString(comments, 1),
+          '(3 comments, 1 unresolved)');
+
+      // Test string with no unresolved comments.
+      delete comments['foo'];
+      assert.equal(element._computePatchSetCommentsString(comments, 1),
+          '(2 comments)');
+
+      // Test string with no comments.
+      delete comments['bar'];
+      assert.equal(element._computePatchSetCommentsString(comments, 1), '');
+    });
+
+    test('_handleDescriptionChanged', () => {
+      const putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
+          .returns(Promise.resolve({ok: true}));
+      sandbox.stub(element, '_computeDescriptionReadOnly');
+
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+      element._selectedPatchNum = '1';
+      element.change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+        actions: {},
+        owner: {_account_id: 1},
+      };
+      element.account = {_account_id: 1};
+      element.loggedIn = true;
+
+      flushAsynchronousOperations();
+      const label = element.$.descriptionLabel;
+      assert.equal(label.value, 'test');
+      label.editing = true;
+      label._inputText = 'test2';
+      label._save();
+      flushAsynchronousOperations();
+      assert.isTrue(putDescStub.called);
+      assert.equal(putDescStub.args[0][2], 'test2');
+    });
+
+    test('patch num change', done => {
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+
+      element.allPatchSets = [
+        {num: 1},
+        {num: 2},
+        {num: 3},
+        {num: 13},
+      ];
+
+      flushAsynchronousOperations();
+
+      const selectEl = element.$$('.patchInfo-header gr-select');
+      assert.ok(selectEl);
+      const optionEls = Polymer.dom(element.root).querySelectorAll(
+          '.patchInfo-header option');
+      assert.equal(optionEls.length, 4);
+      const select = element.$$('.patchInfo-header #patchSetSelect').bindValue;
+      assert.notEqual(select, 1);
+      assert.equal(select, 2);
+      assert.notEqual(select, 3);
+      assert.equal(optionEls[3].value, 13);
+
+      let numEvents = 0;
+      selectEl.addEventListener('change', e => {
+        assert.equal(element.diffViewMode, 'SIDE_BY_SIDE');
+        numEvents++;
+        if (numEvents == 1) {
+          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
+              element.change, '1', 'PARENT'));
+          selectEl.nativeSelect.value = '3';
+          element.fire('change', {}, {node: selectEl.nativeSelect});
+        } else if (numEvents == 2) {
+          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
+              element.change, '3', 'PARENT'));
+          done();
+        }
+      });
+      selectEl.nativeSelect.value = '1';
+      element.fire('change', {}, {node: selectEl.nativeSelect});
+    });
+
+    test('patch num change with missing current_revision', done => {
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+
+      element.allPatchSets = [
+        {num: 1},
+        {num: 2},
+        {num: 3},
+        {num: 13},
+      ];
+      flushAsynchronousOperations();
+      const selectEl = element.$$('.patchInfo-header gr-select');
+      assert.ok(selectEl);
+      const optionEls = Polymer.dom(element.root).querySelectorAll(
+          '.patchInfo-header option');
+      assert.equal(optionEls.length, 4);
+      assert.notEqual(
+          element.$$('.patchInfo-header #patchSetSelect').bindValue, 1);
+      assert.equal(
+          element.$$('.patchInfo-header #patchSetSelect').bindValue, 2);
+      assert.notEqual(
+          element.$$('.patchInfo-header #patchSetSelect').bindValue, 3);
+      assert.equal(optionEls[3].value, 13);
+
+      let numEvents = 0;
+      selectEl.addEventListener('change', e => {
+        numEvents++;
+        if (numEvents == 1) {
+          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
+              element.change, '1', 'PARENT'));
+          selectEl.nativeSelect.value = '3';
+          element.fire('change', {}, {node: selectEl.nativeSelect});
+        } else if (numEvents == 2) {
+          assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
+              element.change, '3', 'PARENT'));
+          done();
+        }
+      });
+      selectEl.nativeSelect.value = '1';
+      element.fire('change', {}, {node: selectEl.nativeSelect});
+    });
+
+    test('diff against dropdown', done => {
+      element.revisions = [
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+      ];
+      element.allPatchSets = [
+        {num: 1},
+        {num: 2},
+        {num: 3},
+        {num: 'edit'},
+      ];
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '3',
+      };
+
+      flush(() => {
+        const selectEl = element.$.patchChange;
+        assert.equal(selectEl.nativeSelect.value, 'PARENT');
+        assert.isTrue(element.$$('#patchChange option[value="3"]')
+            .hasAttribute('disabled'));
+        selectEl.addEventListener('change', () => {
+          assert.equal(selectEl.nativeSelect.value, 'edit');
+          assert(navigateToChangeStub.lastCall.calledWithExactly(
+              element.change, '3', 'edit'),
+              'Should navigate to /c/42/edit..3');
+          done();
+        });
+        selectEl.nativeSelect.value = 'edit';
+        element.fire('change', {}, {node: selectEl.nativeSelect});
+      });
+    });
+
+    test('expandAllDiffs called when expand button clicked', () => {
+      element.shownFileCount = 1;
+      flushAsynchronousOperations();
+      sandbox.stub(element, '_expandAllDiffs');
+      MockInteractions.tap(Polymer.dom(element.root).querySelector(
+          '#expandBtn'));
+      assert.isTrue(element._expandAllDiffs.called);
+    });
+
+    test('collapseAllDiffs called when expand button clicked', () => {
+      element.shownFileCount = 1;
+      flushAsynchronousOperations();
+      sandbox.stub(element, '_collapseAllDiffs');
+      MockInteractions.tap(Polymer.dom(element.root).querySelector(
+          '#collapseBtn'));
+      assert.isTrue(element._collapseAllDiffs.called);
+    });
+
+    test('show/hide diffs disabled for large amounts of files', done => {
+      const computeSpy = sandbox.spy(element, '_fileListActionsVisible');
+      element._files = [];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.shownFileCount = 1;
+      flush(() => {
+        assert.isTrue(computeSpy.lastCall.returnValue);
+        _.times(element._maxFilesForBulkActions + 1, () => {
+          element.shownFileCount = element.shownFileCount + 1;
+        });
+        assert.isFalse(computeSpy.lastCall.returnValue);
+        done();
+      });
+    });
+
+    test('diff mode selector is set correctly', () => {
+      const select = element.$.modeSelect;
+      element.diffViewMode = 'SIDE_BY_SIDE';
+      flushAsynchronousOperations();
+      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
+
+      element.diffViewMode = 'UNIFIED_DIFF';
+      flushAsynchronousOperations();
+      assert.equal(select.nativeSelect.value, 'UNIFIED_DIFF');
+    });
+
+    test('include base patch when not parent', () => {
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: '2',
+        patchNum: '3',
+      };
+      element.change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2},
+          rev1: {_number: 1},
+          rev13: {_number: 13},
+          rev3: {_number: 3},
+        },
+        status: 'NEW',
+        labels: {},
+      };
+
+      element._changePatchNum(13, 2);
+      assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
+          element.change, 13, 2));
+
+      element.patchRange.basePatchNum = 'PARENT';
+
+      element._changePatchNum(3, 'PARENT');
+      assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
+          element.change, 3, 'PARENT'));
+    });
+
+    test('class is applied to file list on old patch set', () => {
+      const allPatchSets = [{num: 1}, {num: 2}, {num: 4}];
+      assert.equal(element._computePatchInfoClass('1', allPatchSets),
+          'patchInfoOldPatchSet');
+      assert.equal(element._computePatchInfoClass('2', allPatchSets),
+          'patchInfoOldPatchSet');
+      assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
+    });
+
+    suite('editLoaded behavior', () => {
+      setup(() => {
+        element.loggedIn = true;
+        element.diffPrefs = {};
+      });
+
+      const isVisible = el => {
+        assert.ok(el);
+        return getComputedStyle(el).getPropertyValue('display') !== 'none';
+      };
+
+      test('patch specific elements', () => {
+        element.editLoaded = true;
+        sandbox.stub(element, 'computeLatestPatchNum').returns('2');
+        flushAsynchronousOperations();
+
+        assert.isFalse(isVisible(element.$.diffPrefsContainer));
+        assert.isFalse(isVisible(element.$$('.descriptionContainer')));
+
+        element.editLoaded = false;
+        flushAsynchronousOperations();
+
+        assert.isTrue(isVisible(element.$$('.descriptionContainer')));
+        assert.isTrue(isVisible(element.$.diffPrefsContainer));
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 912b0ff..38755c4 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -68,6 +68,7 @@
     'change/gr-label-scores/gr-label-scores_test.html',
     'change/gr-label-score-row/gr-label-score-row_test.html',
     'change/gr-file-list/gr-file-list_test.html',
+    'change/gr-file-list-header/gr-file-list-header_test.html',
     'change/gr-message/gr-message_test.html',
     'change/gr-messages-list/gr-messages-list_test.html',
     'change/gr-related-changes-list/gr-related-changes-list_test.html',