Added the diff cursor element

Adds gr-diff-cursor, which is a specialized cursor for navigating
through diff content. It tracks the diff line being targeted, and, in
side-by-side mode, it tracks the side of the diff being targeted.

Also includes special behavior that ships blank spaces and other
non-commentable content in diffs, as well as automatically switching
sides when appropriate.

Support for more than one diff.

Also updates the diff builders to emit some useful element classes.

Note that this only adds the diff cursor along with tests. This is
setting up the work to actually add the diff cursor to the diff views.

Bug: Issue 4033
Change-Id: I991fd514f56ddd19d43d8e1354ad0d4fc71930c4
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
new file mode 100644
index 0000000..f58916e
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
@@ -0,0 +1,29 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
+
+<dom-module id="gr-diff-cursor">
+  <template>
+    <gr-cursor-manager
+        id="cursorManager"
+        scroll
+        cursor-target-class="target-row"
+        target="{{diffRow}}"></gr-cursor-manager>
+  </template>
+  <script src="gr-diff-cursor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
new file mode 100644
index 0000000..f7ad7d2
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -0,0 +1,267 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var DiffSides = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  var DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
+  var LEFT_SIDE_CLASS = 'target-side-left';
+  var RIGHT_SIDE_CLASS = 'target-side-right';
+
+  Polymer({
+    is: 'gr-diff-cursor',
+
+    properties: {
+      /**
+       * Either DiffSides.LEFT or DiffSides.RIGHT.
+       */
+      side: {
+        type: String,
+        value: DiffSides.RIGHT,
+      },
+      diffRow: {
+        type: Object,
+        notify: true,
+        observer: '_rowChanged',
+      },
+
+      /**
+       * The diff views to cursor through and listen to.
+       */
+      diffs: {
+        type: Array,
+        value: function() {
+          return [];
+        },
+      },
+    },
+
+    observers: [
+      '_updateSideClass(side)',
+      '_diffsChanged(diffs.splices)',
+    ],
+
+    moveLeft: function() {
+      this.side = DiffSides.LEFT;
+      if (this._isTargetBlank()) {
+        this.moveUp()
+      }
+    },
+
+    moveRight: function() {
+      this.side = DiffSides.RIGHT;
+      if (this._isTargetBlank()) {
+        this.moveUp()
+      }
+    },
+
+    moveDown: function() {
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+        this.$.cursorManager.next(this._rowHasSide.bind(this));
+      } else {
+        this.$.cursorManager.next();
+      }
+    },
+
+    moveUp: function() {
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+        this.$.cursorManager.previous(this._rowHasSide.bind(this));
+      } else {
+        this.$.cursorManager.previous();
+      }
+    },
+
+    moveToNextChunk: function() {
+      this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this));
+      this._fixSide();
+    },
+
+    moveToPreviousChunk: function() {
+      this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
+      this._fixSide();
+    },
+
+    moveToNextCommentThread: function() {
+      this.$.cursorManager.next(this._rowHasThread.bind(this));
+      this._fixSide();
+    },
+
+    moveToPreviousCommentThread: function() {
+      this.$.cursorManager.previous(this._rowHasThread.bind(this));
+      this._fixSide();
+    },
+
+    /**
+     * Get the line number element targeted by the cursor row and side.
+     * @return {DOMElement}
+     */
+    getTargetLineElement: function() {
+      var lineElSelector = '.lineNum';
+
+      if (!this.diffRow) {
+        return;
+      }
+
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+        lineElSelector += this.side === DiffSides.LEFT ? '.left' : '.right';
+      }
+
+      return this.diffRow.querySelector(lineElSelector);
+    },
+
+    moveToFirstChunk: function() {
+      this.$.cursorManager.moveToStart();
+      this.moveToNextChunk();
+    },
+
+    reInitCursor: function() {
+      this._updateStops();
+      this.moveToFirstChunk();
+    },
+
+    _getViewMode: function() {
+      if (!this.diffRow) {
+        return null;
+      }
+
+      if (this.diffRow.classList.contains('side-by-side')) {
+        return DiffViewMode.SIDE_BY_SIDE;
+      } else {
+        return DiffViewMode.UNIFIED;
+      }
+    },
+
+    _rowHasSide: function(row) {
+      var selector = '.content';
+      selector += this.side === DiffSides.LEFT ? '.left' : '.right';
+      return row.querySelector(selector);
+    },
+
+    _isFirstRowOfChunk: function(row) {
+      var parentClassList = row.parentNode.classList;
+      return parentClassList.contains('section') &&
+          parentClassList.contains('delta') &&
+          !row.previousSibling;
+    },
+
+    _rowHasThread: function(row) {
+      return row.querySelector('gr-diff-comment-thread');
+    },
+
+    /**
+     * If we jumped to a row where there is no content on the current side then
+     * switch to the alternate side.
+     */
+    _fixSide: function() {
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
+          this._isTargetBlank()) {
+        this.side = this.side === DiffSides.LEFT ?
+            DiffSides.RIGHT : DiffSides.LEFT;
+      }
+    },
+
+    _isTargetBlank: function() {
+      if (!this.diffRow) {
+        return false;
+      }
+
+      var actions = this._getActionsForRow();
+      return (this.side === DiffSides.LEFT && !actions.left) ||
+          (this.side === DiffSides.RIGHT && !actions.right);
+    },
+
+    _rowChanged: function(newRow, oldRow) {
+      if (oldRow) {
+        oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+      }
+      this._updateSideClass();
+    },
+
+    _updateSideClass: function() {
+      if (!this.diffRow) {
+        return;
+      }
+      this.toggleClass(LEFT_SIDE_CLASS, this.side === DiffSides.LEFT,
+          this.diffRow);
+      this.toggleClass(RIGHT_SIDE_CLASS, this.side === DiffSides.RIGHT,
+          this.diffRow);
+    },
+
+    _isActionType: function(type) {
+      return type !== 'blank' && type !== 'contextControl';
+    },
+
+    _getActionsForRow: function() {
+      var actions = {left: false, right: false};
+      if (this.diffRow) {
+        actions.left = this._isActionType(
+            this.diffRow.getAttribute('left-type'));
+        actions.right = this._isActionType(
+            this.diffRow.getAttribute('right-type'));
+      }
+      return actions;
+    },
+
+    _getStops: function() {
+      return this.diffs.reduce(
+          function(stops, diff) {
+            return stops.concat(diff.getCursorStops());
+          }, []);
+    },
+
+    _updateStops: function() {
+      this.$.cursorManager.stops = this._getStops();
+    },
+
+    /**
+     * Setup and tear down on-render listeners for any diffs that are added or
+     * removed from the cursor.
+     * @private
+     */
+    _diffsChanged: function(changeRecord) {
+      if (!changeRecord) { return; }
+
+      this._updateStops();
+
+      var splice;
+      var i;
+      for (var spliceIdx = 0;
+        changeRecord.indexSplices &&
+            spliceIdx < changeRecord.indexSplices.length;
+        spliceIdx++) {
+        splice = changeRecord.indexSplices[spliceIdx];
+
+        for (i = splice.index;
+            i < splice.index + splice.addedCount;
+            i++) {
+          this.listen(this.diffs[i], 'render', '_updateStops');
+        }
+
+        for (i = 0;
+            i < splice.removed && splicee.removed.length;
+            i++) {
+          this.unlisten(splice.removed[i], 'render', '_updateStops');
+        }
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
new file mode 100644
index 0000000..f3c9f95
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -0,0 +1,185 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-cursor</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../gr-diff/gr-diff.html">
+<link rel="import" href="./gr-diff-cursor.html">
+<link rel="import" href="./mock-diff-response_test.html">
+
+<test-fixture id="basic">
+  <template>
+    <mock-diff-response></mock-diff-response>
+    <gr-diff></gr-diff>
+    <gr-diff-cursor></gr-diff-cursor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-cursor tests', function() {
+    var cursorElement;
+    var diffElement;
+    var mockDiffResponse;
+
+    setup(function(done) {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      });
+
+      var fixtureElems = fixture('basic');
+      mockDiffResponse = fixtureElems[0];
+      diffElement = fixtureElems[1];
+      cursorElement = fixtureElems[2];
+
+      // Register the diff with the cursor.
+      cursorElement.push('diffs', diffElement);
+
+      diffElement.$.restAPI.getDiffPreferences().then(function(prefs) {
+        diffElement.prefs = prefs;
+      });
+
+      sinon.stub(diffElement, '_getDiff', function() {
+        return Promise.resolve(mockDiffResponse.diffResponse);
+      });
+
+      sinon.stub(diffElement, '_getDiffComments', function() {
+        return Promise.resolve({baseComments: [], comments: []});
+      });
+
+      sinon.stub(diffElement, '_getDiffDrafts', function() {
+        return Promise.resolve({baseComments: [], comments: []});
+      });
+
+      var setupDone = function() {
+        cursorElement.moveToFirstChunk();
+        done();
+        diffElement.removeEventListener('render', setupDone);
+      };
+      diffElement.addEventListener('render', setupDone);
+
+      diffElement.reload();
+    });
+
+    test('diff cursor functionality (side-by-side)', function() {
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursorElement.diffRow);
+
+      var firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+      cursorElement.moveDown()
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+
+      cursorElement.moveUp();
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+    });
+
+    test('diff cursor functionality (unified)', function() {
+      diffElement.viewMode = 'UNIFIED_DIFF';
+      cursorElement.reInitCursor();
+
+      // The cursor has been initialized to the first delta.
+      assert.isOk(cursorElement.diffRow);
+
+      var firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+      firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+
+      cursorElement.moveDown();
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+
+      cursorElement.moveUp();
+
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+    });
+
+    test('cursor side functionality', function() {
+      // The side only applies to side-by-side mode, which should be the default
+      // mode.
+      assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
+
+      var firstDeltaSection = diffElement.$$('.section.delta');
+      var firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
+
+      // Because the first delta in this diff is on the right, it should be set
+      // to the right side.
+      assert.equal(cursorElement.side, 'right');
+      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      var firstIndex = cursorElement.$.cursorManager.index;
+
+      // Move the side to the left. Because this delta only has a right side, we
+      // should be moved up to the previous line where there is content on the
+      // right. The previous row is part of the previous section.
+      cursorElement.moveLeft();
+
+      assert.equal(cursorElement.side, 'left');
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursorElement.$.cursorManager.index, firstIndex - 1);
+      assert.equal(cursorElement.diffRow.parentElement,
+          firstDeltaSection.previousSibling);
+
+      // If we move down, we should skip everything in the first delta because
+      // we are on the left side and the first delta has no content on the left.
+      cursorElement.moveDown();
+
+      assert.equal(cursorElement.side, 'left');
+      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
+      assert.isTrue(cursorElement.$.cursorManager.index > firstIndex);
+      assert.equal(cursorElement.diffRow.parentElement,
+          firstDeltaSection.nextSibling);
+    });
+
+    test('chunk skip functionality', function() {
+      var chunks = Polymer.dom(diffElement.root).querySelectorAll(
+          '.section.delta');
+      var indexOfChunk = function(chunk) {
+        return Array.prototype.indexOf.call(chunks, chunk);
+      };
+
+      // We should be initialized to the first chunk. Since this chunk only has
+      // content on the right side, our side should be right.
+      var currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+      assert.equal(currentIndex, 0);
+      assert.equal(cursorElement.side, 'right');
+
+      // Move to the next chunk.
+      cursorElement.moveToNextChunk();
+
+      // Since this chunk only has content on the left side. we should have been
+      // automatically mvoed over.
+      var previousIndex = currentIndex;
+      currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+      assert.equal(currentIndex, previousIndex + 1);
+      assert.equal(cursorElement.side, 'left');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/mock-diff-response_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/mock-diff-response_test.html
new file mode 100644
index 0000000..ee4bd51
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/mock-diff-response_test.html
@@ -0,0 +1,163 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="mock-diff-response">
+  <template></template>
+  <script>
+    (function() {
+      'use strict';
+
+      var RESPONSE = {
+        "meta_a": {
+          "name": "lorem-ipsum.txt",
+          "content_type": "text/plain",
+          "lines": 45,
+        },
+        "meta_b": {
+          "name": "lorem-ipsum.txt",
+          "content_type": "text/plain",
+          "lines": 48,
+        },
+        "intraline_status": "OK",
+        "change_type": "MODIFIED",
+        "diff_header": [
+          "diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt",
+          "index b2adcf4..554ae49 100644",
+          "--- a/lorem-ipsum.txt",
+          "+++ b/lorem-ipsum.txt",
+        ],
+        "content": [
+          {
+            "ab": [
+              "Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, nulla phasellus.",
+              "Mattis lectus.",
+              "Sodales duis.",
+              "Orci a faucibus.",
+            ]
+          },
+          {
+            "b": [
+              "Nullam neque, ligula ac, id blandit.",
+              "Sagittis tincidunt torquent, tempor nunc amet.",
+              "At rhoncus id.",
+            ],
+          },
+          {
+            "ab": [
+              "Sem nascetur, erat ut, non in.",
+              "A donec, venenatis pellentesque dis.",
+              "Mauris mauris.",
+              "Quisque nisl duis, facilisis viverra.",
+              "Justo purus, semper eget et.",
+            ],
+          },
+          { "a": [
+              "Est amet, vestibulum pellentesque.",
+              "Erat ligula.",
+              "Justo eros.",
+              "Fringilla quisque.",
+            ],
+          },
+          {
+            "ab": [
+              "Arcu eget, rhoncus amet cursus, ipsum elementum.",
+              "Eros suspendisse.",
+            ],
+          },
+          {
+            "a": [
+              "Rhoncus tempor, ultricies aliquam ipsum.",
+            ],
+            "b": [
+              "Rhoncus tempor, ultricies praesent ipsum.",
+            ],
+            "edit_a": [
+              [
+                26,
+                7,
+              ],
+            ],
+            "edit_b": [
+              [
+                26,
+                8,
+              ],
+            ],
+          },
+          {
+            "ab": [
+              "Sollicitudin duis.",
+              "Blandit blandit, ante nisl fusce.",
+              "Felis ac at, tellus consectetuer.",
+              "Sociis ligula sapien, egestas leo.",
+              "Cum pulvinar, sed mauris, cursus neque velit.",
+              "Augue porta lobortis.",
+              "Nibh lorem, amet fermentum turpis, vel pulvinar diam.",
+              "Id quam ipsum, id urna et, massa suspendisse.",
+              "Ac nec, nibh praesent.",
+              "Rutrum vestibulum.",
+              "Est tellus, bibendum habitasse.",
+              "Justo facilisis, vel nulla.",
+              "Donec eu, vulputate neque aliquam, nulla dui.",
+              "Risus adipiscing in.",
+              "Lacus arcu arcu.",
+              "Urna velit.",
+              "Urna a dolor.",
+              "Lectus magna augue, convallis mattis tortor, sed tellus consequat.",
+              "Etiam dui, blandit wisi.",
+              "Mi nec.",
+              "Vitae eget vestibulum.",
+              "Ullamcorper nunc ante, nec imperdiet felis, consectetur in.",
+              "Ac eget.",
+              "Vel fringilla, interdum pellentesque placerat, proin ante.",
+            ],
+          },
+          {
+            "b": [
+              "Eu congue risus.",
+              "Enim ac, quis elementum.",
+              "Non et elit.",
+              "Etiam aliquam, diam vel nunc.",
+            ],
+          },
+          {
+            "ab": [
+              "Nec at.",
+              "Arcu mauris, venenatis lacus fermentum, praesent duis.",
+              "Pellentesque amet et, tellus duis.",
+              "Ipsum arcu vitae, justo elit, sed libero tellus.",
+              "Metus rutrum euismod, vivamus sodales, vel arcu nisl.",
+            ],
+          },
+        ],
+      };
+
+      Polymer({
+        is: 'mock-diff-response',
+        properties: {
+          diffResponse: {
+            type: Object,
+            value: function() {
+              return RESPONSE;
+            },
+          },
+        },
+      });
+    })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-side-by-side.js
index 061ed4f..bf93112 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-side-by-side.js
@@ -35,6 +35,10 @@
   GrDiffBuilderSideBySide.prototype._createRow = function(section, leftLine,
       rightLine) {
     var row = this._createElement('tr');
+    row.classList.add('diff-row', 'side-by-side');
+    row.setAttribute('left-type', leftLine.type);
+    row.setAttribute('right-type', rightLine.type);
+
     this._appendPair(section, row, leftLine, leftLine.beforeNumber,
         GrDiffBuilder.Side.LEFT);
     this._appendPair(section, row, rightLine, rightLine.afterNumber,
@@ -44,7 +48,7 @@
 
   GrDiffBuilderSideBySide.prototype._appendPair = function(section, row, line,
       lineNumber, side) {
-    row.appendChild(this._createLineEl(line, lineNumber, line.type));
+    row.appendChild(this._createLineEl(line, lineNumber, line.type, side));
     var action = this._createContextControl(section, line);
     if (action) {
       row.appendChild(action);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-unified.js
index d9517d3..86340bd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-unified.js
@@ -23,6 +23,7 @@
   GrDiffBuilderUnified.prototype.emitGroup = function(group,
       opt_beforeSection) {
     var sectionEl = this._createElement('tbody', 'section');
+    sectionEl.classList.add(group.type);
 
     for (var i = 0; i < group.lines.length; ++i) {
       sectionEl.appendChild(this._createRow(sectionEl, group.lines[i]));
@@ -36,6 +37,7 @@
         GrDiffLine.Type.REMOVE));
     row.appendChild(this._createLineEl(line, line.afterNumber,
         GrDiffLine.Type.ADD));
+    row.classList.add('diff-row', 'unified');
 
     var action = this._createContextControl(section, line);
     if (action) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder.js
index ce4515c..c2197eb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder.js
@@ -401,8 +401,12 @@
     return threadEl;
   };
 
-  GrDiffBuilder.prototype._createLineEl = function(line, number, type) {
+  GrDiffBuilder.prototype._createLineEl = function(line, number, type,
+      opt_class) {
     var td = this._createElement('td');
+    if (opt_class) {
+      td.classList.add(opt_class);
+    }
     if (line.type === GrDiffLine.Type.BLANK) {
       return td;
     } else if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
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 7b1ce00..7595bb2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -43,6 +43,14 @@
       .section {
         background-color: #eee;
       }
+      .diff-row.target-row {
+        outline: .2em solid #ddd;
+      }
+      .diff-row.target-row.target-side-left .lineNum.left,
+      .diff-row.target-row.target-side-right .lineNum.right,
+      .diff-row.target-row.unified .lineNum {
+        background-color: #ccc;
+      }
       .blank,
       .content {
         background-color: #fff;
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 447d307..04bc341 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -134,6 +134,10 @@
           this._getCommentThreads(), this._focusedThread, -1);
     },
 
+    getCursorStops: function() {
+      return Polymer.dom(this.root).querySelectorAll('.diff-row');
+    },
+
     _advanceElementWithinNodeList: function(els, curIndex, direction) {
       var idx = Math.max(0, Math.min(els.length - 1, curIndex + direction));
       if (curIndex !== idx) {
@@ -338,6 +342,10 @@
     _showContext: function(group, sectionEl) {
       this._builder.emitGroup(group, sectionEl);
       sectionEl.parentNode.removeChild(sectionEl);
+
+      this.async(function() {
+        this.fire('render', null, {bubbles: false});
+      }.bind(this), 1);
     },
 
     _prefsChanged: function(prefsChangeRecord) {