Show whether diff sides lack trailing newlines

Show a small warning at the end of a diff if either side does not have a
trailing newline. When a diff loads, find the last chunk for both sides
and examine the last line of each chunk. If the newline is missing from
either or both, show a corresponding message.

Keep track of whether the diff component is loading using a _loading
property so that the message can be hidden when loading a new diff.

Feature: Issue 9459
Change-Id: I59f59de5676af2f0352a42aa6d7dabaad5fc65b4
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 718fa17..0fb15c4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -245,6 +245,13 @@
         background-position: var(--line-limit) 0;
         background-repeat: repeat-y;
       }
+      .newlineWarning {
+        color: var(--deemphasized-text-color);
+        text-align: center;
+      }
+      .newlineWarning.hidden {
+        display: none;
+      }
     </style>
     <style include="gr-syntax-theme"></style>
     <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
@@ -284,6 +291,9 @@
         </gr-diff-highlight>
       </gr-diff-selection>
     </div>
+    <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
+      [[_newlineWarning]]
+    </div>
     <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
       <p>
         Prevented render because "Whole file" is enabled and this diff is very
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 4f9c8d7..407cbf9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -27,6 +27,9 @@
   const EVENT_ZERO_REBASE = 'rebase-percent-zero';
   const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
 
+  const NO_NEWLINE_BASE = 'No newline at end of base file.';
+  const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+
   const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
@@ -120,6 +123,11 @@
        */
       lineOfInterest: Object,
 
+      _loading: {
+        type: Boolean,
+        value: false,
+      },
+
       _loggedIn: {
         type: Boolean,
         value: false,
@@ -169,6 +177,11 @@
         type: Number,
         computed: '_computeParentIndex(patchRange.*)',
       },
+
+      _newlineWarning: {
+        type: String,
+        computed: '_computeNewlineWarning(_diff)',
+      },
     },
 
     behaviors: [
@@ -196,6 +209,7 @@
 
     /** @return {!Promise} */
     reload() {
+      this._loading = true;
       this.cancel();
       this.clearBlame();
       this._safetyBypass = null;
@@ -214,6 +228,8 @@
           return this._renderDiffTable();
         }
         return Promise.resolve();
+      }).then(() => {
+        this._loading = false;
       });
     },
 
@@ -857,5 +873,89 @@
     expandAllContext() {
       this._handleFullBypass();
     },
+
+    /**
+     * Find the last chunk for the given side.
+     * @param {!Object} diff
+     * @param {boolean} leftSide true if checking the base of the diff,
+     *     false if testing the revision.
+     * @return {Object|null} returns the chunk object or null if there was
+     *     no chunk for that side.
+     */
+    _lastChunkForSide(diff, leftSide) {
+      if (!diff.content.length) { return null; }
+
+      let chunkIndex = diff.content.length;
+      let chunk;
+
+      // Walk backwards until we find a chunk for the given side.
+      do {
+        chunkIndex--;
+        chunk = diff.content[chunkIndex];
+      } while (
+          // We haven't reached the beginning.
+          chunkIndex >= 0 &&
+
+          // The chunk doesn't have both sides.
+          !chunk.ab &&
+
+          // The chunk doesn't have the given side.
+          ((leftSide && !chunk.a) || (!leftSide && !chunk.b)));
+
+      // If we reached the beginning of the diff and failed to find a chunk
+      // with the given side, return null.
+      if (chunkIndex === -1) { return null; }
+
+      return chunk;
+    },
+
+    /**
+     * Check whether the specified side of the diff has a trailing newline.
+     * @param {!Object} diff
+     * @param {boolean} leftSide true if checking the base of the diff,
+     *     false if testing the revision.
+     * @return {boolean|null} Return true if the side has a trailing newline.
+     *     Return false if it doesn't. Return null if not applicable (for
+     *     example, if the diff has no content on the specified side).
+     */
+    _hasTrailingNewlines(diff, leftSide) {
+      const chunk = this._lastChunkForSide(diff, leftSide);
+      if (!chunk) { return null; }
+      let lines;
+      if (chunk.ab) {
+        lines = chunk.ab;
+      } else {
+        lines = leftSide ? chunk.a : chunk.b;
+      }
+      return lines[lines.length - 1] === '';
+    },
+
+    /**
+     * @param {!Object} diff
+     * @return {string|null}
+     */
+    _computeNewlineWarning(diff) {
+      const hasLeft = this._hasTrailingNewlines(diff, true);
+      const hasRight = this._hasTrailingNewlines(diff, false);
+      const messages = [];
+      if (hasLeft === false) {
+        messages.push(NO_NEWLINE_BASE);
+      }
+      if (hasRight === false) {
+        messages.push(NO_NEWLINE_REVISION);
+      }
+      if (!messages.length) { return null; }
+      return messages.join(' — ');
+    },
+
+    /**
+     * @param {string} warning
+     * @param {boolean} loading
+     * @return {string}
+     */
+    _computeNewlineWarningClass(warning, loading) {
+      if (loading || !warning) { return 'newlineWarning hidden'; }
+      return 'newlineWarning';
+    },
   });
 })();
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 33abab4..a3acf1f 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
@@ -1266,6 +1266,131 @@
         assert.isUndefined(reportStub.lastCall.args[1]);
       });
     });
+
+    suite('trailing newlines', () => {
+      setup(() => {
+        element = fixture('basic');
+      });
+
+      suite('_lastChunkForSide', () => {
+        test('deltas', () => {
+          const diff = {content: [
+            {a: ['foo', 'bar'], b: ['baz']},
+            {ab: ['foo', 'bar', 'baz']},
+            {b: ['foo']},
+          ]};
+          assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
+          assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);
+
+          diff.content.push({a: ['foo'], b: ['bar']});
+          assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
+          assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
+        });
+
+        test('addition', () => {
+          const diff = {content: [
+            {b: ['foo', 'bar', 'baz']},
+          ]};
+          assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
+          assert.isNull(element._lastChunkForSide(diff, true));
+        });
+
+        test('deletion', () => {
+          const diff = {content: [
+            {a: ['foo', 'bar', 'baz']},
+          ]};
+          assert.isNull(element._lastChunkForSide(diff, false));
+          assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
+        });
+
+        test('empty', () => {
+          const diff = {content: []};
+          assert.isNull(element._lastChunkForSide(diff, false));
+          assert.isNull(element._lastChunkForSide(diff, true));
+        });
+      });
+
+      suite('_hasTrailingNewlines', () => {
+        test('shared no trailing', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide')
+              .returns({ab: ['foo', 'bar']});
+          assert.isFalse(element._hasTrailingNewlines(diff, false));
+          assert.isFalse(element._hasTrailingNewlines(diff, true));
+        });
+
+        test('delta trailing in right', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide')
+              .returns({a: ['foo', 'bar'], b: ['baz', '']});
+          assert.isTrue(element._hasTrailingNewlines(diff, false));
+          assert.isFalse(element._hasTrailingNewlines(diff, true));
+        });
+
+        test('addition', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
+            if (leftSide) { return null; }
+            return {b: ['foo', '']};
+          });
+          assert.isTrue(element._hasTrailingNewlines(diff, false));
+          assert.isNull(element._hasTrailingNewlines(diff, true));
+        });
+
+        test('deletion', () => {
+          const diff = undefined;
+          sandbox.stub(element, '_lastChunkForSide', (diff, leftSide) => {
+            if (!leftSide) { return null; }
+            return {a: ['foo']};
+          });
+          assert.isNull(element._hasTrailingNewlines(diff, false));
+          assert.isFalse(element._hasTrailingNewlines(diff, true));
+        });
+      });
+
+      test('_computeNewlineWarning', () => {
+        const NO_NEWLINE_BASE = 'No newline at end of base file.';
+        const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+
+        let hasLeft;
+        let hasRight;
+        sandbox.stub(element, '_hasTrailingNewlines', (diff, left) => {
+          if (left) { return hasLeft; }
+          return hasRight;
+        });
+        const diff = undefined;
+
+        // The revision has a trailing newline, but the base doesn't.
+        hasLeft = true;
+        hasRight = false;
+        assert.equal(element._computeNewlineWarning(diff), NO_NEWLINE_REVISION);
+
+        // Trailing newlines were undetermined in the revision.
+        hasLeft = true;
+        hasRight = null;
+        assert.isNull(element._computeNewlineWarning(diff));
+
+        // Missing trailing newlines in the base.
+        hasLeft = false;
+        hasRight = null;
+        assert.equal(element._computeNewlineWarning(diff), NO_NEWLINE_BASE);
+
+        // Missing trailing newlines in both.
+        hasLeft = false;
+        hasRight = false;
+        assert.equal(element._computeNewlineWarning(diff),
+            NO_NEWLINE_BASE + ' — ' + NO_NEWLINE_REVISION);
+      });
+
+      test('_computeNewlineWarningClass', () => {
+        const hidden = 'newlineWarning hidden';
+        const shown = 'newlineWarning';
+        assert.equal(element._computeNewlineWarningClass(null, true), hidden);
+        assert.equal(element._computeNewlineWarningClass('foo', true), hidden);
+        assert.equal(element._computeNewlineWarningClass(null, false), hidden);
+        assert.equal(element._computeNewlineWarningClass('foo', false), shown);
+      });
+    });
   });
 
   a11ySuite('basic');