Support “diff against” in the file list

Bug: Issue 3936
Change-Id: I07f242a8cb7be063a2d56dbe3f4b6a28143588c0
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 8c6c216..1f5d9e1 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
@@ -273,7 +273,7 @@
       </section>
       <gr-file-list id="fileList"
           change-num="[[_changeNum]]"
-          patch-num="{{_patchRange.patchNum}}"
+          patch-range="[[_patchRange]]"
           comments="[[_comments]]"
           drafts="[[_diffDrafts]]"
           revisions="[[_change.revisions]]"
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 a8012a0..a3d2146 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
@@ -179,7 +179,14 @@
         basePatchNum: value.basePatchNum || 'PARENT',
       };
 
-      this._resetStateIfNecessary();
+      // If the change number or patch range is different, then reset the
+      // selected file index.
+      var patchRangeState = this.viewState.patchRange;
+      if (this.viewState.changeNum !== this._changeNum ||
+          patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
+          patchRangeState.patchNum !== this._patchRange.patchNum) {
+        this._resetFileListViewState();
+      }
 
       if (!this._changeNum) {
         return;
@@ -212,17 +219,10 @@
       }.bind(this));
     },
 
-    _resetStateIfNecessary: function() {
-      // If the change number or patch range is different, then reset the
-      // selected file index.
-      if (this.viewState.changeNum !== this._changeNum ||
-          this.viewState.patchRange.basePatchNum !==
-              this._patchRange.basePatchNum ||
-          this.viewState.patchRange.patchNum !== this._patchRange.patchNum) {
-        this.set('viewState.selectedFileIndex', 0);
-        this.set('viewState.changeNum', this._changeNum);
-        this.set('viewState.patchRange', this._patchRange);
-      }
+    _resetFileListViewState: function() {
+      this.set('viewState.selectedFileIndex', 0);
+      this.set('viewState.changeNum', this._changeNum);
+      this.set('viewState.patchRange', this._patchRange);
     },
 
     _changeChanged: function(change) {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index 104b27f2..7ce687c 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -34,7 +34,7 @@
         justify-content: space-between;
         margin-bottom: .5em;
       }
-      header label {
+      .diffAgainst {
         font-weight: normal;
       }
       .positionIndicator,
@@ -125,12 +125,22 @@
     </style>
     <header>
       <div>Files</div>
-      <label hidden>
-        Diff against
-        <select></select>
-      </label>
+      <div class="diffAgainst">
+        <label>
+          Diff against
+          <select on-change="_handlePatchChange">
+            <option value="PARENT">Base</option>
+            <template is="dom-repeat" items="[[_computePatchSets(revisions, patchRange.*)]]" as="patchNum">
+              <option
+                  value$="[[patchNum]]"
+                  selected$="[[_computePatchSetSelected(patchNum, patchRange.basePatchNum)]]"
+                  disabled$="[[_computePatchSetDisabled(patchNum, patchRange.patchNum)]]">[[patchNum]]</option>
+            </template>
+          </select>
+        </label>
+      </div>
     </header>
-    <template is="dom-repeat" items="{{_files}}" as="file">
+    <template is="dom-repeat" items="[[_files]]" as="file">
       <div class="row" selected$="[[_computeFileSelected(index, selectedIndex)]]">
         <div class="positionIndicator">&#x25b6;</div>
         <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
@@ -140,7 +150,7 @@
         <div class$="[[_computeClass('status', file.__path)]]">
           [[_computeFileStatus(file.status)]]
         </div>
-        <a class="path" href$="[[_computeDiffURL(changeNum, patchNum, file.__path)]]">
+        <a class="path" href$="[[_computeDiffURL(changeNum, patchRange, file.__path)]]">
           <div title$="[[_computeFileDisplayName(file.__path)]]">
             [[_computeFileDisplayName(file.__path)]]
           </div>
@@ -150,8 +160,8 @@
           </div>
         </a>
         <div class="comments">
-          <span class="drafts">[[_computeDraftsString(drafts, patchNum, file.__path)]]</span>
-          [[_computeCommentsString(comments, patchNum, file.__path)]]
+          <span class="drafts">[[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]</span>
+          [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
         </div>
         <div class$="[[_computeClass('stats', file.__path)]]">
           <span class="added">+[[file.lines_inserted]]</span>
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
index 47b6abd..a9ce44e 100644
--- 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
@@ -20,6 +20,7 @@
     is: 'gr-file-list',
 
     properties: {
+      patchRange: Object,
       patchNum: String,
       changeNum: String,
       comments: Object,
@@ -50,7 +51,7 @@
     ],
 
     reload: function() {
-      if (!this.changeNum || !this.patchNum) {
+      if (!this.changeNum || !this.patchRange.patchNum) {
         return Promise.resolve();
       }
 
@@ -73,6 +74,28 @@
       return Promise.all(promises);
     },
 
+    _computePatchSets: function(revisions) {
+      var patchNums = [];
+      for (var commit in revisions) {
+        patchNums.push(revisions[commit]._number);
+      }
+      return patchNums.sort(function(a, b) { return a - b; });
+    },
+
+    _computePatchSetDisabled: function(patchNum, currentPatchNum) {
+      return parseInt(patchNum, 10) >= parseInt(currentPatchNum, 10);
+    },
+
+    _computePatchSetSelected: function(patchNum, basePatchNum) {
+      return parseInt(patchNum, 10) === parseInt(basePatchNum, 10);
+    },
+
+    _handlePatchChange: function(e) {
+      this.set('patchRange.basePatchNum', Polymer.dom(e).rootTarget.value);
+      page.show('/c/' + encodeURIComponent(this.changeNum) + '/' +
+          encodeURIComponent(this._patchRangeStr(this.patchRange)));
+    },
+
     _computeCommentsString: function(comments, patchNum, path) {
       return this._computeCountString(comments, patchNum, path, 'comment');
     },
@@ -114,8 +137,8 @@
     },
 
     _saveReviewedState: function(path, reviewed) {
-      return this.$.restAPI.saveFileReviewed(this.changeNum, this.patchNum,
-          path, reviewed);
+      return this.$.restAPI.saveFileReviewed(this.changeNum,
+          this.patchRange.patchNum, path, reviewed);
     },
 
     _getLoggedIn: function() {
@@ -123,12 +146,13 @@
     },
 
     _getReviewedFiles: function() {
-      return this.$.restAPI.getReviewedFiles(this.changeNum, this.patchNum);
+      return this.$.restAPI.getReviewedFiles(this.changeNum,
+          this.patchRange.patchNum);
     },
 
     _getFiles: function() {
       return this.$.restAPI.getChangeFilesAsSpeciallySortedArray(
-          this.changeNum, this.patchNum);
+          this.changeNum, this.patchRange);
     },
 
     _handleKey: function(e) {
@@ -164,7 +188,7 @@
       if (opt_index != null) {
         this.selectedIndex = opt_index;
       }
-      page.show(this._computeDiffURL(this.changeNum, this.patchNum,
+      page.show(this._computeDiffURL(this.changeNum, this.patchRange,
           this._files[this.selectedIndex].__path));
     },
 
@@ -176,8 +200,19 @@
       return status || 'M';
     },
 
-    _computeDiffURL: function(changeNum, patchNum, path) {
-      return '/c/' + changeNum + '/' + patchNum + '/' + path;
+    _computeDiffURL: function(changeNum, patchRange, path) {
+      return '/c/' +
+          encodeURIComponent(changeNum) +
+          '/' +
+          encodeURIComponent(this._patchRangeStr(patchRange)) +
+          '/' +
+          path;
+    },
+
+    _patchRangeStr: function(patchRange) {
+      return patchRange.basePatchNum !== 'PARENT' ?
+          patchRange.basePatchNum + '..' + patchRange.patchNum :
+          patchRange.patchNum + '';
     },
 
     _computeFileDisplayName: function(path) {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index 64ef91f..f80d211 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -89,7 +89,10 @@
         {__path: 'myfile.txt'},
       ];
       element.changeNum = '42';
-      element.patchNum = '2';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
       element.selectedIndex = 0;
 
       flushAsynchronousOperations();
@@ -181,7 +184,10 @@
       ];
       element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
       element.changeNum = '42';
-      element.patchNum = '2';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
       element.selectedIndex = 0;
 
       flushAsynchronousOperations();
@@ -205,5 +211,52 @@
 
       saveStub.restore();
     });
+
+    test('patch set from revisions', function() {
+      var patchNums = element._computePatchSets({
+        rev3: {_number: 3},
+        rev1: {_number: 1},
+        rev4: {_number: 4},
+        rev2: {_number: 2},
+      });
+      assert.deepEqual(patchNums, [1, 2, 3, 4]);
+    });
+
+    test('patch range string', function() {
+      assert.equal(
+          element._patchRangeStr({basePatchNum: 'PARENT', patchNum: '1'}),
+          '1');
+      assert.equal(
+          element._patchRangeStr({basePatchNum: '1', patchNum: '3'}),
+          '1..3');
+    });
+
+    test('diff against dropdown', function(done) {
+      var showStub = sinon.stub(page, 'show');
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '3',
+      };
+      element.revisions = {
+        rev1: {_number: 1},
+        rev2: {_number: 2},
+        rev3: {_number: 3},
+      };
+      flush(function() {
+        var selectEl = element.$$('select');
+        assert.equal(selectEl.value, 'PARENT');
+        assert.isTrue(element.$$('option[value="3"]').hasAttribute('disabled'));
+        selectEl.addEventListener('change', function() {
+          assert.equal(selectEl.value, '2');
+          assert(showStub.lastCall.calledWithExactly('/c/42/2..3'),
+              'Should navigate to /c/42/2..3');
+          showStub.restore();
+          done();
+        });
+        selectEl.value = '2';
+        element.fire('change', {}, {node: selectEl});
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 41bf824..9145ba1 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -74,7 +74,6 @@
     // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>].
     page(/^\/c\/(\d+)\/?(((\d+)(\.\.(\d+))?))?$/, function(ctx) {
       // Parameter order is based on the regex group number matched.
-      console.info(ctx)
       var params = {
         changeNum: ctx.params[0],
         basePatchNum: ctx.params[3],
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 4480319..b916571 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -123,7 +123,7 @@
       }
     </style>
     <h3>
-      <a href$="[[_computeChangePath(_changeNum, _patchRange.patchNum, _change.revisions)]]">
+      <a href$="[[_computeChangePath(_changeNum, _patchRange.*, _change.revisions)]]">
         [[_changeNum]]</a><span>:</span>
       <span>[[_change.subject]]</span>
       <span class="dash">—</span>
@@ -140,7 +140,7 @@
         <iron-dropdown id="dropdown" vertical-align="top" vertical-offset="25">
           <div class="dropdown-content">
             <template is="dom-repeat" items="[[_fileList]]" as="path">
-              <a href$="[[_computeDiffURL(_changeNum, _patchRange, path)]]"
+              <a href$="[[_computeDiffURL(_changeNum, _patchRange.*, path)]]"
                  selected$="[[_computeFileSelected(path, _path)]]"
                  data-key-nav$="[[_computeKeyNav(path, _path, _fileList)]]"
                  on-tap="_handleFileTap">
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 6932d10..4b0ecdd 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
@@ -83,7 +83,7 @@
     observers: [
       '_getChangeDetail(_changeNum)',
       '_getProjectConfig(_change.project)',
-      '_getFiles(_changeNum, _patchRange.patchNum)',
+      '_getFiles(_changeNum, _patchRange.*)',
       '_updateModeSelect(_diffMode)',
     ],
 
@@ -127,9 +127,10 @@
           }.bind(this));
     },
 
-    _getFiles: function(changeNum, patchNum) {
+    _getFiles: function(changeNum, patchRangeRecord) {
+      var patchRange = patchRangeRecord.base;
       return this.$.restAPI.getChangeFilePathsAsSpeciallySortedArray(
-          changeNum, patchNum).then(function(files) {
+          changeNum, patchRange).then(function(files) {
             this._fileList = files;
           }.bind(this));
     },
@@ -196,9 +197,9 @@
         case 85:  // 'u'
           if (this._changeNum && this._patchRange.patchNum) {
             e.preventDefault();
-            page.show(this._computeChangePath(
+            page.show(this._getChangePath(
                 this._changeNum,
-                this._patchRange.patchNum,
+                this._patchRange,
                 this._change && this._change.revisions));
           }
           break;
@@ -221,15 +222,15 @@
 
       var idx = fileList.indexOf(this._path) + direction;
       if (idx < 0 || idx > fileList.length - 1) {
-        page.show(this._computeChangePath(
+        page.show(this._getChangePath(
             this._changeNum,
-            this._patchRange.patchNum,
+            this._patchRange,
             this._change && this._change.revisions));
         return;
       }
-      page.show(this._computeDiffURL(this._changeNum,
-                                     this._patchRange,
-                                     fileList[idx]));
+      page.show(this._getDiffURL(this._changeNum,
+                                 this._patchRange,
+                                 fileList[idx]));
     },
 
     _paramsChanged: function(value) {
@@ -282,13 +283,22 @@
       }
     },
 
-    _computeDiffURL: function(changeNum, patchRange, path) {
+    _getDiffURL: function(changeNum, patchRange, path) {
+      return '/c/' + changeNum + '/' + this._patchRangeStr(patchRange) + '/' +
+          path;
+    },
+
+    _computeDiffURL: function(changeNum, patchRangeRecord, path) {
+      return this._getDiffURL(changeNum, patchRangeRecord.base, path);
+    },
+
+    _patchRangeStr: function(patchRange) {
       var patchStr = patchRange.patchNum;
       if (patchRange.basePatchNum != null &&
           patchRange.basePatchNum != 'PARENT') {
         patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
       }
-      return '/c/' + changeNum + '/' + patchStr + '/' + path;
+      return patchStr;
     },
 
     _computeAvailablePatches: function(revisions) {
@@ -299,25 +309,30 @@
       return patchNums.sort(function(a, b) { return a - b; });
     },
 
-    _computeChangePath: function(changeNum, patchNum, revisions) {
+    _getChangePath: function(changeNum, patchRange, revisions) {
       var base = '/c/' + changeNum + '/';
 
       // The change may not have loaded yet, making revisions unavailable.
       if (!revisions) {
-        return base + patchNum;
+        return base + this._patchRangeStr(patchRange);
       }
 
       var latestPatchNum = -1;
       for (var rev in revisions) {
         latestPatchNum = Math.max(latestPatchNum, revisions[rev]._number);
       }
-      if (parseInt(patchNum, 10) != latestPatchNum) {
-        return base + patchNum;
+      if (patchRange.basePatchNum !== 'PARENT' ||
+          parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
+        return base + this._patchRangeStr(patchRange);
       }
 
       return base;
     },
 
+    _computeChangePath: function(changeNum, patchRangeRecord, revisions) {
+      return this._getChangePath(changeNum, patchRangeRecord.base, revisions);
+    },
+
     _computeFileDisplayName: function(path) {
       return path == COMMIT_MESSAGE_PATH ? 'Commit message' : path;
     },
@@ -347,8 +362,7 @@
 
     _handleMobileSelectChange: function(e) {
       var path = Polymer.dom(e).rootTarget.value;
-      page.show(
-          this._computeDiffURL(this._changeNum, this._patchRange, path));
+      page.show(this._getDiffURL(this._changeNum, this._patchRange, path));
     },
 
     _showDropdownTapHandler: function(e) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index 4167424..e0d0735 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -50,6 +50,7 @@
     test('keyboard shortcuts', function() {
       element._changeNum = '42';
       element._patchRange = {
+        basePatchNum: 'PARENT',
         patchNum: '10',
       };
       element._change = {
@@ -143,12 +144,12 @@
       MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
       assert.isTrue(element.changeViewState.showReplyDialog);
 
-      assert(showStub.lastCall.calledWithExactly('/c/42/'),
-          'Should navigate to /c/42/');
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
+          'Should navigate to /c/42/5..10');
 
       MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
-      assert(showStub.lastCall.calledWithExactly('/c/42/'),
-          'Should navigate to /c/42/');
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
+          'Should navigate to /c/42/5..10');
 
       MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10/wheatley.md'),
@@ -166,8 +167,8 @@
       element._path = 'chell.go';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
-      assert(showStub.lastCall.calledWithExactly('/c/42/'),
-          'Should navigate to /c/42/');
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
+          'Should navigate to /c/42/5..10');
 
       showStub.restore();
     });
@@ -175,6 +176,7 @@
     test('keyboard shortcuts with old patch number', function() {
       element._changeNum = '42';
       element._patchRange = {
+        basePatchNum: 'PARENT',
         patchNum: '1',
       };
       element._change = {
@@ -230,6 +232,7 @@
     test('go up to change via kb without change loaded', function() {
       element._changeNum = '42';
       element._patchRange = {
+        basePatchNum: 'PARENT',
         patchNum: '1',
       };
 
@@ -280,6 +283,7 @@
     test('jump to file dropdown', function() {
       element._changeNum = '42';
       element._patchRange = {
+        basePatchNum: 'PARENT',
         patchNum: '10',
       };
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index a2fa039..3cb37b6 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -315,18 +315,22 @@
           this.getChangeActionURL(changeNum, patchNum, '/commit?links'));
     },
 
-    getChangeFiles: function(changeNum, patchNum) {
+    getChangeFiles: function(changeNum, patchRange) {
+      var endpoint = '/files';
+      if (patchRange.basePatchNum !== 'PARENT') {
+        endpoint += '?base=' + encodeURIComponent(patchRange.basePatchNum);
+      }
       return this.fetchJSON(
-          this.getChangeActionURL(changeNum, patchNum, '/files'));
+          this.getChangeActionURL(changeNum, patchRange.patchNum, endpoint));
     },
 
-    getChangeFilesAsSpeciallySortedArray: function(changeNum, patchNum) {
-      return this.getChangeFiles(changeNum, patchNum).then(
+    getChangeFilesAsSpeciallySortedArray: function(changeNum, patchRange) {
+      return this.getChangeFiles(changeNum, patchRange).then(
           this._normalizeChangeFilesResponse.bind(this));
     },
 
-    getChangeFilePathsAsSpeciallySortedArray: function(changeNum, patchNum) {
-      return this.getChangeFiles(changeNum, patchNum).then(function(files) {
+    getChangeFilePathsAsSpeciallySortedArray: function(changeNum, patchRange) {
+      return this.getChangeFiles(changeNum, patchRange).then(function(files) {
         return Object.keys(files).sort(this._specialFilePathCompare.bind(this));
       }.bind(this));
     },