Show blame in diff

With this change a blame column is added to the left side of diff
tables. The column is empty and hidden until blame is loaded. A button
is added to the change view to trigger a load of the blame for that
diff, as well as a unload it if already loaded. In this stage, the blame
information is non-interactive and only displays the SHA, date and
commit author.

Feature: Issue 6075
Change-Id: Ifcb951265d0e6339094e6b7c9574ec9c69e60b51
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 7ef5c60..7b84ef5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -212,6 +212,32 @@
       #sizeWarning.warn {
         display: block;
       }
+      .target-row td.blame {
+        background: #eee;
+      }
+      td.blame {
+        display: none;
+        font-family: var(--font-family);
+        font-size: var(--font-size, 12px);
+        padding: 0 .5em;
+        white-space: pre;
+      }
+      :host(.showBlame) td.blame {
+        display: table-cell;
+      }
+      td.blame > span {
+        opacity: 0.6;
+      }
+      td.blame > span.startOfRange {
+        opacity: 1;
+      }
+      td.blame .sha {
+        font-family: var(--monospace-font-family);
+      }
+      .full-width td.blame {
+        overflow: hidden;
+        width: 200px;
+      }
     </style>
     <style include="gr-theme-default"></style>
     <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index d20d7bf..c3add28 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -16,6 +16,7 @@
 
   const ERR_COMMENT_ON_EDIT = 'You cannot comment on an edit.';
   const ERR_INVALID_LINE = 'Invalid line number: ';
+  const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
   const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
@@ -125,6 +126,17 @@
       },
 
       _showWarning: Boolean,
+
+      /** @type {?Object} */
+      _blame: {
+        type: Object,
+        value: null,
+      },
+      isBlameLoaded: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeIsBlameLoaded(_blame)',
+      },
     },
 
     behaviors: [
@@ -154,6 +166,7 @@
     /** @return {!Promise} */
     reload() {
       this.$.diffBuilder.cancel();
+      this.clearBlame();
       this._safetyBypass = null;
       this._showWarning = false;
       this._clearDiffContent();
@@ -191,6 +204,39 @@
       this.toggleClass('no-left');
     },
 
+    /**
+     * Load and display blame information for the base of the diff.
+     * @return {Promise} A promise that resolves when blame finishes rendering.
+     */
+    loadBlame() {
+      return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
+          this.path, true)
+          .then(blame => {
+            if (!blame.length) {
+              this.fire('show-alert', {message: MSG_EMPTY_BLAME});
+              return Promise.reject(MSG_EMPTY_BLAME);
+            }
+
+            this._blame = blame;
+
+            this.$.diffBuilder.setBlame(blame);
+            this.classList.add('showBlame');
+          });
+    },
+
+    _computeIsBlameLoaded(blame) {
+      return !!blame;
+    },
+
+    /**
+     * Unload blame information for the diff.
+     */
+    clearBlame() {
+      this._blame = null;
+      this.$.diffBuilder.setBlame(null);
+      this.classList.remove('showBlame');
+    },
+
     /** @return {boolean}} */
     _canRender() {
       return !!this.changeNum && !!this.patchRange && !!this.path &&
@@ -500,6 +546,8 @@
     _prefsChanged(prefs) {
       if (!prefs) { return; }
 
+      this.clearBlame();
+
       const stylesToUpdate = {};
 
       if (prefs.line_wrapping) {
@@ -589,7 +637,7 @@
       const isB = this._diff.meta_b &&
           this._diff.meta_b.content_type.startsWith('image/');
 
-      return this._diff.binary && (isA || isB);
+      return !!(this._diff.binary && (isA || isB));
     },
 
     /** @return {!Promise} */
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index f540c34..e422354 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -959,6 +959,60 @@
         });
       });
     });
+
+    suite('blame', () => {
+      setup(() => {
+        element = fixture('basic');
+      });
+
+      test('clearBlame', () => {
+        element._blame = [];
+        const setBlameSpy = sandbox.spy(element.$.diffBuilder, 'setBlame');
+        element.classList.add('showBlame');
+        element.clearBlame();
+        assert.isNull(element._blame);
+        assert.isTrue(setBlameSpy.calledWithExactly(null));
+        assert.isFalse(element.classList.contains('showBlame'));
+      });
+
+      test('loadBlame', () => {
+        const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+        const showAlertStub = sinon.stub();
+        element.addEventListener('show-alert', showAlertStub);
+        const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
+            .returns(Promise.resolve(mockBlame));
+        element.changeNum = 42;
+        element.patchRange = {patchNum: 5, basePatchNum: 4};
+        element.path = 'foo/bar.baz';
+        return element.loadBlame().then(() => {
+          assert.isTrue(getBlameStub.calledWithExactly(
+              42, 5, 'foo/bar.baz', true));
+          assert.isFalse(showAlertStub.called);
+          assert.equal(element._blame, mockBlame);
+          assert.isTrue(element.classList.contains('showBlame'));
+        });
+      });
+
+      test('loadBlame empty', () => {
+        const mockBlame = [];
+        const showAlertStub = sinon.stub();
+        element.addEventListener('show-alert', showAlertStub);
+        sandbox.stub(element.$.restAPI, 'getBlame')
+            .returns(Promise.resolve(mockBlame));
+        element.changeNum = 42;
+        element.patchRange = {patchNum: 5, basePatchNum: 4};
+        element.path = 'foo/bar.baz';
+        return element.loadBlame()
+            .then(() => {
+              assert.isTrue(false, 'Promise should not resolve');
+            })
+            .catch(() => {
+              assert.isTrue(showAlertStub.calledOnce);
+              assert.isNull(element._blame);
+              assert.isFalse(element.classList.contains('showBlame'));
+            });
+      });
+    });
   });
 
   a11ySuite('basic');