Refactor directory structure of components

There is no change in functionality. Only moving things around.

+ Separate html from the js.
+ Place the unit test for a component within the same folder.
+ Organize the components in subfolders.

Change-Id: I51fdc510db75fc1b33f040ca63decbbdfd4d5513
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
new file mode 100644
index 0000000..21ee076
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -0,0 +1,123 @@
+<!--
+Copyright (C) 2015 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/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-ajax/gr-ajax.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-request/gr-request.html">
+
+<link rel="import" href="../gr-diff-preferences/gr-diff-preferences.html">
+<link rel="import" href="../gr-diff-side/gr-diff-side.html">
+<link rel="import" href="../gr-patch-range-select/gr-patch-range-select.html">
+
+<dom-module id="gr-diff">
+  <template>
+    <style>
+      .loading {
+        padding: 0 var(--default-horizontal-margin) 1em;
+        color: #666;
+      }
+      .header {
+        display: flex;
+        justify-content: space-between;
+        margin: 0 var(--default-horizontal-margin) .75em;
+      }
+      .prefsButton {
+        text-align: right;
+      }
+      .diffContainer {
+        border-bottom: 1px solid #eee;
+        border-top: 1px solid #eee;
+        display: flex;
+        font: 12px var(--monospace-font-family);
+        overflow-x: auto;
+      }
+      gr-diff-side:first-of-type {
+        --light-highlight-color: #fee;
+        --dark-highlight-color: #ffd4d4;
+      }
+      gr-diff-side:last-of-type {
+        --light-highlight-color: #efe;
+        --dark-highlight-color: #d4ffd4;
+        border-right: 1px solid #ddd;
+      }
+    </style>
+    <gr-ajax id="diffXHR"
+        url="[[_computeDiffPath(changeNum, patchRange.patchNum, path)]]"
+        params="[[_computeDiffQueryParams(patchRange.basePatchNum)]]"
+        last-response="{{_diffResponse}}"
+        loading="{{_loading}}"></gr-ajax>
+    <gr-ajax id="baseCommentsXHR"
+        url="[[_computeCommentsPath(changeNum, patchRange.basePatchNum)]]"></gr-ajax>
+    <gr-ajax id="commentsXHR"
+        url="[[_computeCommentsPath(changeNum, patchRange.patchNum)]]"></gr-ajax>
+    <gr-ajax id="baseDraftsXHR"
+        url="[[_computeDraftsPath(changeNum, patchRange.basePatchNum)]]"></gr-ajax>
+    <gr-ajax id="draftsXHR"
+        url="[[_computeDraftsPath(changeNum, patchRange.patchNum)]]"></gr-ajax>
+    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+    <div hidden$="[[_loading]]" hidden>
+      <div class="header">
+        <gr-patch-range-select
+            path="[[path]]"
+            change-num="[[changeNum]]"
+            patch-range="[[patchRange]]"
+            available-patches="[[availablePatches]]"></gr-patch-range-select>
+        <gr-button link
+           class="prefsButton"
+           on-tap="_handlePrefsTap"
+           hidden$="[[!prefs]]"
+           hidden>Diff View Preferences</gr-button>
+      </div>
+      <gr-overlay id="prefsOverlay" with-backdrop>
+        <gr-diff-preferences
+            prefs="{{prefs}}"
+            on-save="_handlePrefsSave"
+            on-cancel="_handlePrefsCancel"></gr-diff-preferences>
+      </gr-overlay>
+
+      <div class="diffContainer">
+        <gr-diff-side id="leftDiff"
+            change-num="[[changeNum]]"
+            patch-num="[[patchRange.basePatchNum]]"
+            path="[[path]]"
+            content="{{_diff.leftSide}}"
+            prefs="[[prefs]]"
+            can-comment="[[_loggedIn]]"
+            project-config="[[projectConfig]]"
+            on-expand-context="_handleExpandContext"
+            on-thread-height-change="_handleThreadHeightChange"
+            on-add-draft="_handleAddDraft"
+            on-remove-thread="_handleRemoveThread"></gr-diff-side>
+        <gr-diff-side id="rightDiff"
+            change-num="[[changeNum]]"
+            patch-num="[[patchRange.patchNum]]"
+            path="[[path]]"
+            content="{{_diff.rightSide}}"
+            prefs="[[prefs]]"
+            can-comment="[[_loggedIn]]"
+            project-config="[[projectConfig]]"
+            on-expand-context="_handleExpandContext"
+            on-thread-height-change="_handleThreadHeightChange"
+            on-add-draft="_handleAddDraft"
+            on-remove-thread="_handleRemoveThread"></gr-diff-side>
+      </div>
+    </div>
+  </template>
+  <script src="gr-diff.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
new file mode 100644
index 0000000..485e2cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -0,0 +1,746 @@
+// 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';
+
+  Polymer({
+    is: 'gr-diff',
+
+    /**
+     * Fired when the diff is rendered.
+     *
+     * @event render
+     */
+
+    properties: {
+      availablePatches: Array,
+      changeNum: String,
+      /*
+       * A single object to encompass basePatchNum and patchNum is used
+       * so that both can be set at once without incremental observers
+       * firing after each property changes.
+       */
+      patchRange: Object,
+      path: String,
+      prefs: {
+        type: Object,
+        notify: true,
+      },
+      projectConfig: Object,
+
+      _prefsReady: {
+        type: Object,
+        readOnly: true,
+        value: function() {
+          return new Promise(function(resolve) {
+            this._resolvePrefsReady = resolve;
+          }.bind(this));
+        },
+      },
+      _baseComments: Array,
+      _comments: Array,
+      _drafts: Array,
+      _baseDrafts: Array,
+      /**
+       * Base (left side) comments and drafts grouped by line number.
+       * Only used for initial rendering.
+       */
+      _groupedBaseComments: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      /**
+       * Comments and drafts (right side) grouped by line number.
+       * Only used for initial rendering.
+       */
+      _groupedComments: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      _diffResponse: Object,
+      _diff: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _initialRenderComplete: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _savedPrefs: Object,
+
+      _diffRequestsPromise: Object,  // Used for testing.
+      _diffPreferencesPromise: Object,  // Used for testing.
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    observers: [
+      '_prefsChanged(prefs.*)',
+    ],
+
+    ready: function() {
+      app.accountReady.then(function() {
+        this._loggedIn = app.loggedIn;
+      }.bind(this));
+    },
+
+    scrollToLine: function(lineNum) {
+      // TODO(andybons): Should this always be the right side?
+      this.$.rightDiff.scrollToLine(lineNum);
+    },
+
+    scrollToNextDiffChunk: function() {
+      this.$.rightDiff.scrollToNextDiffChunk();
+    },
+
+    scrollToPreviousDiffChunk: function() {
+      this.$.rightDiff.scrollToPreviousDiffChunk();
+    },
+
+    scrollToNextCommentThread: function() {
+      this.$.rightDiff.scrollToNextCommentThread();
+    },
+
+    scrollToPreviousCommentThread: function() {
+      this.$.rightDiff.scrollToPreviousCommentThread();
+    },
+
+    reload: function(changeNum, patchRange, path) {
+      // If a diff takes a considerable amount of time to render, the previous
+      // diff can end up showing up while the DOM is constructed. Clear the
+      // content on a reload to prevent this.
+      this._diff = {
+        leftSide: [],
+        rightSide: [],
+      };
+
+      var promises = [
+        this._prefsReady,
+        this.$.diffXHR.generateRequest().completes
+      ];
+
+      var basePatchNum = this.patchRange.basePatchNum;
+
+      return app.accountReady.then(function() {
+        promises.push(this._getCommentsAndDrafts(basePatchNum, app.loggedIn));
+        this._diffRequestsPromise = Promise.all(promises).then(function() {
+          this._render();
+        }.bind(this)).catch(function(err) {
+          alert('Oops. Something went wrong. Check the console and bug the ' +
+              'PolyGerrit team for assistance.');
+          throw err;
+        });
+      }.bind(this));
+    },
+
+    showDiffPreferences: function() {
+      this.$.prefsOverlay.open();
+    },
+
+    _prefsChanged: function(changeRecord) {
+      if (this._initialRenderComplete) {
+        this._render();
+      }
+      this._resolvePrefsReady(changeRecord.base);
+    },
+
+    _render: function() {
+      this._groupCommentsAndDrafts();
+      this._processContent();
+
+      // Allow for the initial rendering to complete before firing the event.
+      this.async(function() {
+        this.fire('render', null, {bubbles: false});
+      }.bind(this), 1);
+
+      this._initialRenderComplete = true;
+    },
+
+    _getCommentsAndDrafts: function(basePatchNum, loggedIn) {
+      function onlyParent(c) { return c.side == 'PARENT'; }
+      function withoutParent(c) { return c.side != 'PARENT'; }
+
+      var promises = [];
+      var commentsPromise = this.$.commentsXHR.generateRequest().completes;
+      promises.push(commentsPromise.then(function(req) {
+        var comments = req.response[this.path] || [];
+        if (basePatchNum == 'PARENT') {
+          this._baseComments = comments.filter(onlyParent);
+        }
+        this._comments = comments.filter(withoutParent);
+      }.bind(this)));
+
+      if (basePatchNum != 'PARENT') {
+        commentsPromise = this.$.baseCommentsXHR.generateRequest().completes;
+        promises.push(commentsPromise.then(function(req) {
+          this._baseComments =
+            (req.response[this.path] || []).filter(withoutParent);
+        }.bind(this)));
+      }
+
+      if (!loggedIn) {
+        this._baseDrafts = [];
+        this._drafts = [];
+        return Promise.all(promises);
+      }
+
+      var draftsPromise = this.$.draftsXHR.generateRequest().completes;
+      promises.push(draftsPromise.then(function(req) {
+        var drafts = req.response[this.path] || [];
+        if (basePatchNum == 'PARENT') {
+          this._baseDrafts = drafts.filter(onlyParent);
+        }
+        this._drafts = drafts.filter(withoutParent);
+      }.bind(this)));
+
+      if (basePatchNum != 'PARENT') {
+        draftsPromise = this.$.baseDraftsXHR.generateRequest().completes;
+        promises.push(draftsPromise.then(function(req) {
+          this._baseDrafts =
+              (req.response[this.path] || []).filter(withoutParent);
+        }.bind(this)));
+      }
+
+      return Promise.all(promises);
+    },
+
+    _computeDiffPath: function(changeNum, patchNum, path) {
+      return this.changeBaseURL(changeNum, patchNum) + '/files/' +
+          encodeURIComponent(path) + '/diff';
+    },
+
+    _computeCommentsPath: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/comments';
+    },
+
+    _computeDraftsPath: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/drafts';
+    },
+
+    _computeDiffQueryParams: function(basePatchNum) {
+      var params =  {
+        context: 'ALL',
+        intraline: null
+      };
+      if (basePatchNum != 'PARENT') {
+        params.base = basePatchNum;
+      }
+      return params;
+    },
+
+    _handlePrefsTap: function(e) {
+      e.preventDefault();
+
+      // TODO(andybons): This is not supported in IE. Implement a polyfill.
+      // NOTE: Object.assign is NOT automatically a deep copy. If prefs adds
+      // an object as a value, it must be marked enumerable.
+      this._savedPrefs = Object.assign({}, this.prefs);
+      this.$.prefsOverlay.open();
+    },
+
+    _handlePrefsSave: function(e) {
+      e.stopPropagation();
+      var el = Polymer.dom(e).rootTarget;
+      el.disabled = true;
+      app.accountReady.then(function() {
+        if (!this._loggedIn) {
+          el.disabled = false;
+          this.$.prefsOverlay.close();
+          return;
+        }
+        this._saveDiffPreferences().then(function() {
+          this.$.prefsOverlay.close();
+          el.disabled = false;
+        }.bind(this)).catch(function(err) {
+          el.disabled = false;
+          alert('Oops. Something went wrong. Check the console and bug the ' +
+                'PolyGerrit team for assistance.');
+          throw err;
+        });
+      }.bind(this));
+    },
+
+    _saveDiffPreferences: function() {
+      var xhr = document.createElement('gr-request');
+      this._diffPreferencesPromise = xhr.send({
+        method: 'PUT',
+        url: '/accounts/self/preferences.diff',
+        body: this.prefs,
+      });
+      return this._diffPreferencesPromise;
+    },
+
+    _handlePrefsCancel: function(e) {
+      e.stopPropagation();
+      this.prefs = this._savedPrefs;
+      this.$.prefsOverlay.close();
+    },
+
+    _handleExpandContext: function(e) {
+      var ctx = e.detail.context;
+      var contextControlIndex = -1;
+      for (var i = ctx.start; i <= ctx.end; i++) {
+        this._diff.leftSide[i].hidden = false;
+        this._diff.rightSide[i].hidden = false;
+        if (this._diff.leftSide[i].type == 'CONTEXT_CONTROL' &&
+            this._diff.rightSide[i].type == 'CONTEXT_CONTROL') {
+          contextControlIndex = i;
+        }
+      }
+      this._diff.leftSide[contextControlIndex].hidden = true;
+      this._diff.rightSide[contextControlIndex].hidden = true;
+
+      this.$.leftDiff.hideElementsWithIndex(contextControlIndex);
+      this.$.rightDiff.hideElementsWithIndex(contextControlIndex);
+
+      this.$.leftDiff.renderLineIndexRange(ctx.start, ctx.end);
+      this.$.rightDiff.renderLineIndexRange(ctx.start, ctx.end);
+    },
+
+    _handleThreadHeightChange: function(e) {
+      var index = e.detail.index;
+      var diffEl = Polymer.dom(e).rootTarget;
+      var otherSide = diffEl == this.$.leftDiff ?
+          this.$.rightDiff : this.$.leftDiff;
+
+      var threadHeight = e.detail.height;
+      var otherSideHeight;
+      if (otherSide.content[index].type == 'COMMENT_THREAD') {
+        otherSideHeight = otherSide.getRowNaturalHeight(index);
+      } else {
+        otherSideHeight = otherSide.getRowHeight(index);
+      }
+      var maxHeight = Math.max(threadHeight, otherSideHeight);
+      this.$.leftDiff.setRowHeight(index, maxHeight);
+      this.$.rightDiff.setRowHeight(index, maxHeight);
+    },
+
+    _handleAddDraft: function(e) {
+      var insertIndex = e.detail.index + 1;
+      var diffEl = Polymer.dom(e).rootTarget;
+      var content = diffEl.content;
+      if (content[insertIndex] &&
+          content[insertIndex].type == 'COMMENT_THREAD') {
+        // A thread is already here. Do nothing.
+        return;
+      }
+      var comment = {
+        type: 'COMMENT_THREAD',
+        comments: [{
+          __draft: true,
+          __draftID: Math.random().toString(36),
+          line: e.detail.line,
+          path: this.path,
+        }]
+      };
+      if (diffEl == this.$.leftDiff &&
+          this.patchRange.basePatchNum == 'PARENT') {
+        comment.comments[0].side = 'PARENT';
+        comment.patchNum = this.patchRange.patchNum;
+      }
+
+      if (content[insertIndex] &&
+          content[insertIndex].type == 'FILLER') {
+        content[insertIndex] = comment;
+        diffEl.rowUpdated(insertIndex);
+      } else {
+        content.splice(insertIndex, 0, comment);
+        diffEl.rowInserted(insertIndex);
+      }
+
+      var otherSide = diffEl == this.$.leftDiff ?
+          this.$.rightDiff : this.$.leftDiff;
+      if (otherSide.content[insertIndex] == null ||
+          otherSide.content[insertIndex].type != 'COMMENT_THREAD') {
+        otherSide.content.splice(insertIndex, 0, {
+          type: 'FILLER',
+        });
+        otherSide.rowInserted(insertIndex);
+      }
+    },
+
+    _handleRemoveThread: function(e) {
+      var diffEl = Polymer.dom(e).rootTarget;
+      var otherSide = diffEl == this.$.leftDiff ?
+          this.$.rightDiff : this.$.leftDiff;
+      var index = e.detail.index;
+
+      if (otherSide.content[index].type == 'FILLER') {
+        otherSide.content.splice(index, 1);
+        otherSide.rowRemoved(index);
+        diffEl.content.splice(index, 1);
+        diffEl.rowRemoved(index);
+      } else if (otherSide.content[index].type == 'COMMENT_THREAD') {
+        diffEl.content[index] = {type: 'FILLER'};
+        diffEl.rowUpdated(index);
+        var height = otherSide.setRowNaturalHeight(index);
+        diffEl.setRowHeight(index, height);
+      } else {
+        throw Error('A thread cannot be opposite anything but filler or ' +
+            'another thread');
+      }
+    },
+
+    _processContent: function() {
+      var leftSide = [];
+      var rightSide = [];
+      var initialLineNum = 0 + (this._diffResponse.content.skip || 0);
+      var ctx = {
+        hidingLines: false,
+        lastNumLinesHidden: 0,
+        left: {
+          lineNum: initialLineNum,
+        },
+        right: {
+          lineNum: initialLineNum,
+        }
+      };
+      var content = this._breakUpCommonChunksWithComments(ctx,
+          this._diffResponse.content);
+      var context = this.prefs.context;
+      if (context == -1) {
+        // Show the entire file.
+        context = Infinity;
+      }
+      for (var i = 0; i < content.length; i++) {
+        if (i == 0) {
+          ctx.skipRange = [0, context];
+        } else if (i == content.length - 1) {
+          ctx.skipRange = [context, 0];
+        } else {
+          ctx.skipRange = [context, context];
+        }
+        ctx.diffChunkIndex = i;
+        this._addDiffChunk(ctx, content[i], leftSide, rightSide);
+      }
+
+      this._diff = {
+        leftSide: leftSide,
+        rightSide: rightSide,
+      };
+    },
+
+    // In order to show comments out of the bounds of the selected context,
+    // treat them as diffs within the model so that the content (and context
+    // surrounding it) renders correctly.
+    _breakUpCommonChunksWithComments: function(ctx, content) {
+      var result = [];
+      var leftLineNum = ctx.left.lineNum;
+      var rightLineNum = ctx.right.lineNum;
+      for (var i = 0; i < content.length; i++) {
+        if (!content[i].ab) {
+          result.push(content[i]);
+          if (content[i].a) {
+            leftLineNum += content[i].a.length;
+          }
+          if (content[i].b) {
+            rightLineNum += content[i].b.length;
+          }
+          continue;
+        }
+        var chunk = content[i].ab;
+        var currentChunk = {ab: []};
+        for (var j = 0; j < chunk.length; j++) {
+          leftLineNum++;
+          rightLineNum++;
+          if (this._groupedBaseComments[leftLineNum] == null &&
+              this._groupedComments[rightLineNum] == null) {
+            currentChunk.ab.push(chunk[j]);
+          } else {
+            if (currentChunk.ab && currentChunk.ab.length > 0) {
+              result.push(currentChunk);
+              currentChunk = {ab: []};
+            }
+            // Append an annotation to indicate that this line should not be
+            // highlighted even though it's implied with both `a` and `b`
+            // defined. This is needed since there may be two lines that
+            // should be highlighted but are equal (blank lines, for example).
+            result.push({
+              __noHighlight: true,
+              a: [chunk[j]],
+              b: [chunk[j]],
+            });
+          }
+        }
+        if (currentChunk.ab != null && currentChunk.ab.length > 0) {
+          result.push(currentChunk);
+        }
+      }
+      return result;
+    },
+
+    _groupCommentsAndDrafts: function() {
+      this._baseDrafts.forEach(function(d) { d.__draft = true; });
+      this._drafts.forEach(function(d) { d.__draft = true; });
+      var allLeft = this._baseComments.concat(this._baseDrafts);
+      var allRight = this._comments.concat(this._drafts);
+
+      var leftByLine = {};
+      var rightByLine = {};
+      var mapFunc = function(byLine) {
+        return function(c) {
+          // File comments/drafts are grouped with line 1 for now.
+          var line = c.line || 1;
+          if (byLine[line] == null) {
+            byLine[line] = [];
+          }
+          byLine[line].push(c);
+        };
+      };
+      allLeft.forEach(mapFunc(leftByLine));
+      allRight.forEach(mapFunc(rightByLine));
+
+      this._groupedBaseComments = leftByLine;
+      this._groupedComments = rightByLine;
+    },
+
+    _addContextControl: function(ctx, leftSide, rightSide) {
+      var numLinesHidden = ctx.lastNumLinesHidden;
+      var leftStart = leftSide.length - numLinesHidden;
+      var leftEnd = leftSide.length;
+      var rightStart = rightSide.length - numLinesHidden;
+      var rightEnd = rightSide.length;
+      if (leftStart != rightStart || leftEnd != rightEnd) {
+        throw Error(
+            'Left and right ranges for context control should be equal:' +
+            'Left: [' + leftStart + ', ' + leftEnd + '] ' +
+            'Right: [' + rightStart + ', ' + rightEnd + ']');
+      }
+      var obj = {
+        type: 'CONTEXT_CONTROL',
+        numLines: numLinesHidden,
+        start: leftStart,
+        end: leftEnd,
+      };
+      // NOTE: Be careful, here. This object is meant to be immutable. If the
+      // object is altered within one side's array it will reflect the
+      // alterations in another.
+      leftSide.push(obj);
+      rightSide.push(obj);
+    },
+
+    _addCommonDiffChunk: function(ctx, chunk, leftSide, rightSide) {
+      for (var i = 0; i < chunk.ab.length; i++) {
+        var numLines = Math.ceil(
+            this._visibleLineLength(chunk.ab[i]) / this.prefs.line_length);
+        var hidden = i >= ctx.skipRange[0] &&
+            i < chunk.ab.length - ctx.skipRange[1];
+        if (ctx.hidingLines && hidden == false) {
+          // No longer hiding lines. Add a context control.
+          this._addContextControl(ctx, leftSide, rightSide);
+          ctx.lastNumLinesHidden = 0;
+        }
+        ctx.hidingLines = hidden;
+        if (hidden) {
+          ctx.lastNumLinesHidden++;
+        }
+
+        // Blank lines within a diff content array indicate a newline.
+        leftSide.push({
+          type: 'CODE',
+          hidden: hidden,
+          content: chunk.ab[i] || '\n',
+          numLines: numLines,
+          lineNum: ++ctx.left.lineNum,
+        });
+        rightSide.push({
+          type: 'CODE',
+          hidden: hidden,
+          content: chunk.ab[i] || '\n',
+          numLines: numLines,
+          lineNum: ++ctx.right.lineNum,
+        });
+
+        this._addCommentsIfPresent(ctx, leftSide, rightSide);
+      }
+      if (ctx.lastNumLinesHidden > 0) {
+        this._addContextControl(ctx, leftSide, rightSide);
+      }
+    },
+
+    _addDiffChunk: function(ctx, chunk, leftSide, rightSide) {
+      if (chunk.ab) {
+        this._addCommonDiffChunk(ctx, chunk, leftSide, rightSide);
+        return;
+      }
+
+      var leftHighlights = [];
+      if (chunk.edit_a) {
+        leftHighlights =
+            this._normalizeIntralineHighlights(chunk.a, chunk.edit_a);
+      }
+      var rightHighlights = [];
+      if (chunk.edit_b) {
+        rightHighlights =
+            this._normalizeIntralineHighlights(chunk.b, chunk.edit_b);
+      }
+
+      var aLen = (chunk.a && chunk.a.length) || 0;
+      var bLen = (chunk.b && chunk.b.length) || 0;
+      var maxLen = Math.max(aLen, bLen);
+      for (var i = 0; i < maxLen; i++) {
+        var hasLeftContent = chunk.a && i < chunk.a.length;
+        var hasRightContent = chunk.b && i < chunk.b.length;
+        var leftContent = hasLeftContent ? chunk.a[i] : '';
+        var rightContent = hasRightContent ? chunk.b[i] : '';
+        var highlight = !chunk.__noHighlight;
+        var maxNumLines = this._maxLinesSpanned(leftContent, rightContent);
+        if (hasLeftContent) {
+          leftSide.push({
+            type: 'CODE',
+            content: leftContent || '\n',
+            numLines: maxNumLines,
+            lineNum: ++ctx.left.lineNum,
+            highlight: highlight,
+            intraline: highlight && leftHighlights.filter(function(hl) {
+              return hl.contentIndex == i;
+            }),
+          });
+        } else {
+          leftSide.push({
+            type: 'FILLER',
+            numLines: maxNumLines,
+          });
+        }
+        if (hasRightContent) {
+          rightSide.push({
+            type: 'CODE',
+            content: rightContent || '\n',
+            numLines: maxNumLines,
+            lineNum: ++ctx.right.lineNum,
+            highlight: highlight,
+            intraline: highlight && rightHighlights.filter(function(hl) {
+              return hl.contentIndex == i;
+            }),
+          });
+        } else {
+          rightSide.push({
+            type: 'FILLER',
+            numLines: maxNumLines,
+          });
+        }
+        this._addCommentsIfPresent(ctx, leftSide, rightSide);
+      }
+    },
+
+    _addCommentsIfPresent: function(ctx, leftSide, rightSide) {
+      var leftComments = this._groupedBaseComments[ctx.left.lineNum];
+      var rightComments = this._groupedComments[ctx.right.lineNum];
+      if (leftComments) {
+        var thread = {
+          type: 'COMMENT_THREAD',
+          comments: leftComments,
+        };
+        if (this.patchRange.basePatchNum == 'PARENT') {
+          thread.patchNum = this.patchRange.patchNum;
+        }
+        leftSide.push(thread);
+      }
+      if (rightComments) {
+        rightSide.push({
+          type: 'COMMENT_THREAD',
+          comments: rightComments,
+        });
+      }
+      if (leftComments && !rightComments) {
+        rightSide.push({type: 'FILLER'});
+      } else if (!leftComments && rightComments) {
+        leftSide.push({type: 'FILLER'});
+      }
+      this._groupedBaseComments[ctx.left.lineNum] = null;
+      this._groupedComments[ctx.right.lineNum] = null;
+    },
+
+    // The `highlights` array consists of a list of <skip length, mark length>
+    // pairs, where the skip length is the number of characters between the
+    // end of the previous edit and the start of this edit, and the mark
+    // length is the number of edited characters following the skip. The start
+    // of the edits is from the beginning of the related diff content lines.
+    //
+    // Note that the implied newline character at the end of each line is
+    // included in the length calculation, and thus it is possible for the
+    // edits to span newlines.
+    //
+    // A line highlight object consists of three fields:
+    // - contentIndex: The index of the diffChunk `content` field (the line
+    //   being referred to).
+    // - startIndex: Where the highlight should begin.
+    // - endIndex: (optional) Where the highlight should end. If omitted, the
+    //   highlight is meant to be a continuation onto the next line.
+    _normalizeIntralineHighlights: function(content, highlights) {
+      var contentIndex = 0;
+      var idx = 0;
+      var normalized = [];
+      for (var i = 0; i < highlights.length; i++) {
+        var line = content[contentIndex] + '\n';
+        var hl = highlights[i];
+        var j = 0;
+        while (j < hl[0]) {
+          if (idx == line.length) {
+            idx = 0;
+            line = content[++contentIndex] + '\n';
+            continue;
+          }
+          idx++;
+          j++;
+        }
+        var lineHighlight = {
+          contentIndex: contentIndex,
+          startIndex: idx,
+        };
+
+        j = 0;
+        while (line && j < hl[1]) {
+          if (idx == line.length) {
+            idx = 0;
+            line = content[++contentIndex] + '\n';
+            normalized.push(lineHighlight);
+            lineHighlight = {
+              contentIndex: contentIndex,
+              startIndex: idx,
+            };
+            continue;
+          }
+          idx++;
+          j++;
+        }
+        lineHighlight.endIndex = idx;
+        normalized.push(lineHighlight);
+      }
+      return normalized;
+    },
+
+    _visibleLineLength: function(contents) {
+      // http://jsperf.com/performance-of-match-vs-split
+      var numTabs = contents.split('\t').length - 1;
+      return contents.length - numTabs + (this.prefs.tab_size * numTabs);
+    },
+
+    _maxLinesSpanned: function(left, right) {
+      return Math.max(
+          Math.ceil(this._visibleLineLength(left) / this.prefs.line_length),
+          Math.ceil(this._visibleLineLength(right) / this.prefs.line_length));
+    },
+  });
+})();
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
new file mode 100644
index 0000000..9a8cb81
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -0,0 +1,574 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 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</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/fake-app.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.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff></gr-diff>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+      element.changeNum = 42;
+      element.path = 'sieve.go';
+      element.prefs = {
+        context: 10,
+        tab_size: 8,
+      };
+
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'GET',
+        /\/changes\/42\/revisions\/(1|2)\/files\/sieve\.go\/diff(.*)/,
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({
+            change_type: 'MODIFIED',
+            content: [
+              {
+                ab: [
+                  '<!DOCTYPE html>',
+                  '<meta charset="utf-8">',
+                  '<title>My great page</title>',
+                  '<style>',
+                  '  *,',
+                  '  *:before,',
+                  '  *:after {',
+                  '    box-sizing: border-box;',
+                  '  }',
+                  '</style>',
+                  '<header>',
+                ]
+              },
+              {
+                a: [
+                  '  Welcome ',
+                  '  to the wooorld of tomorrow!',
+                ],
+                b: [
+                  '  Hello, world!',
+                ],
+              },
+              {
+                ab: [
+                  '</header>',
+                  '<body>',
+                  'Leela: This is the only place the ship can’t hear us, so ',
+                  'everyone pretend to shower.',
+                  'Fry: Same as every day. Got it.',
+                ]
+              },
+            ]
+          }),
+        ]
+      );
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/1/comments',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({
+            '/COMMIT_MSG': [],
+            'sieve.go': [
+              {
+                author: {
+                  _account_id: 1000000,
+                  name: 'Andrew Bonventre',
+                  email: 'andybons@gmail.com',
+                },
+                id: '9af53d3f_5f2b8b82',
+                line: 1,
+                message: 'this isn’t quite right',
+                updated: '2015-12-10 02:50:21.627000000',
+              },
+              {
+                author: {
+                  _account_id: 1000000,
+                  name: 'Andrew Bonventre',
+                  email: 'andybons@gmail.com',
+                },
+                id: '9af53d3f_bf1cd76b',
+                line: 1,
+                side: 'PARENT',
+                message: 'how did this work in the first place?',
+                updated: '2015-12-10 00:08:42.255000000',
+              },
+            ],
+          }),
+        ]
+      );
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/2/comments',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({
+            '/COMMIT_MSG': [],
+            'sieve.go': [
+              {
+                author: {
+                  _account_id: 1010008,
+                  name: 'Dave Borowitz',
+                  email: 'dborowitz@google.com',
+                },
+                id: '001a2067_f30f3048',
+                line: 12,
+                message: 'What on earth are you thinking, here?',
+                updated: '2015-12-12 02:51:37.973000000',
+              },
+              {
+                author: {
+                  _account_id: 1010008,
+                  name: 'Dave Borowitz',
+                  email: 'dborowitz@google.com',
+                },
+                id: '001a2067_f6b1b1c8',
+                in_reply_to: '9af53d3f_bf1cd76b',
+                line: 1,
+                side: 'PARENT',
+                message: 'Yeah not sure how this worked either?',
+                updated: '2015-12-12 02:51:37.973000000',
+              },
+              {
+                author: {
+                  _account_id: 1000000,
+                  name: 'Andrew Bonventre',
+                  email: 'andybons@gmail.com',
+                },
+                id: 'a0407443_30dfe8fb',
+                in_reply_to: '001a2067_f30f3048',
+                line: 12,
+                message: '¯\\_(ツ)_/¯',
+                updated: '2015-12-12 18:50:21.627000000',
+              },
+            ],
+          }),
+        ]
+      );
+
+      server.respondWith(
+        'PUT',
+        '/accounts/self/preferences.diff',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({context: 25}),
+        ]
+      );
+
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    test('comments with parent', function(done) {
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+
+      element.reload();
+      server.respond();
+
+      element._diffRequestsPromise.then(function() {
+        assert.equal(element._baseComments.length, 1);
+        assert.equal(element._comments.length, 1);
+        assert.equal(element._baseDrafts.length, 0);
+        assert.equal(element._drafts.length, 0);
+        done();
+      });
+    });
+
+    test('comments between two patches', function(done) {
+      element.patchRange = {
+        basePatchNum: 1,
+        patchNum: 2,
+      };
+
+      element.reload();
+      server.respond();
+
+      element._diffRequestsPromise.then(function() {
+        assert.equal(element._baseComments.length, 1);
+        assert.equal(element._comments.length, 2);
+        assert.equal(element._baseDrafts.length, 0);
+        assert.equal(element._drafts.length, 0);
+        done();
+      });
+    });
+
+    test('comment rendering', function(done) {
+      element.prefs.context = -1;
+      element._loggedIn = true;
+      element.patchRange = {
+        basePatchNum: 1,
+        patchNum: 2,
+      };
+
+      element.reload();
+      server.respond();
+
+      // Allow events to fire and the threads to render.
+      element.async(function() {
+        var leftThreadEls =
+            Polymer.dom(element.$.leftDiff.root).querySelectorAll(
+                'gr-diff-comment-thread');
+        assert.equal(leftThreadEls.length, 1);
+        assert.equal(leftThreadEls[0].comments.length, 1);
+
+        var rightThreadEls =
+            Polymer.dom(element.$.rightDiff.root).querySelectorAll(
+                'gr-diff-comment-thread');
+        assert.equal(rightThreadEls.length, 1);
+        assert.equal(rightThreadEls[0].comments.length, 2);
+
+        var index = leftThreadEls[0].getAttribute('data-index');
+        var leftFillerEls =
+            Polymer.dom(element.$.leftDiff.root).querySelectorAll(
+                '.commentThread.filler[data-index="' + index + '"]');
+        assert.equal(leftFillerEls.length, 1);
+        var rightFillerEls =
+            Polymer.dom(element.$.rightDiff.root).querySelectorAll(
+                '[data-index="' + index + '"]');
+        assert.equal(rightFillerEls.length, 2);
+
+        for (var i = 0; i < rightFillerEls.length; i++) {
+          assert.isTrue(rightFillerEls[i].classList.contains('filler'));
+        }
+        var originalHeight = rightFillerEls[0].offsetHeight;
+        assert.equal(rightFillerEls[1].offsetHeight, originalHeight);
+        assert.equal(leftThreadEls[0].offsetHeight, originalHeight);
+        assert.equal(leftFillerEls[0].offsetHeight, originalHeight);
+
+        // Create a comment on the opposite side of the first comment.
+        var rightLineEL = element.$.rightDiff.$$(
+              '.lineNum[data-index="' + (index - 1) + '"]');
+        assert.ok(rightLineEL);
+        MockInteractions.tap(rightLineEL);
+        element.async(function() {
+          var newThreadEls =
+            Polymer.dom(element.$.rightDiff.root).querySelectorAll(
+                '[data-index="' + index + '"]');
+          assert.equal(newThreadEls.length, 2);
+          for (var i = 0; i < newThreadEls.length; i++) {
+            assert.isTrue(
+                newThreadEls[i].classList.contains('commentThread') ||
+                newThreadEls[i].tagName == 'GR-DIFF-COMMENT-THREAD');
+          }
+          var newHeight = newThreadEls[0].offsetHeight;
+          assert.equal(newThreadEls[1].offsetHeight, newHeight);
+          assert.equal(leftFillerEls[0].offsetHeight, newHeight);
+          assert.equal(leftThreadEls[0].offsetHeight, newHeight);
+
+          // The editing mode height of the right comment will be greater than
+          // the non-editing mode height of the left comment.
+          assert.isAbove(newHeight, originalHeight);
+
+          // Discard the right thread and ensure the left comment heights are
+          // back to their original values.
+          newThreadEls[1].addEventListener('discard', function() {
+            rightFillerEls =
+                Polymer.dom(element.$.rightDiff.root).querySelectorAll(
+                    '[data-index="' + index + '"]');
+            assert.equal(rightFillerEls.length, 2);
+
+            for (var i = 0; i < rightFillerEls.length; i++) {
+              assert.isTrue(rightFillerEls[i].classList.contains('filler'));
+            }
+            var originalHeight = rightFillerEls[0].offsetHeight;
+            assert.equal(rightFillerEls[1].offsetHeight, originalHeight);
+            assert.equal(leftThreadEls[0].offsetHeight, originalHeight);
+            assert.equal(leftFillerEls[0].offsetHeight, originalHeight);
+            done();
+          });
+          var commentEl = newThreadEls[1].$$('gr-diff-comment');
+          commentEl.fire('discard', null, {bubbles: false});
+        }, 1);
+      }, 1);
+    });
+
+    test('intraline normalization', function() {
+      // The content and highlights are in the format returned by the Gerrit
+      // REST API.
+      var content = [
+        '      <section class="summary">',
+        '        <gr-linked-text content="' +
+            '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+        '      </section>',
+      ];
+      var highlights = [
+        [31, 34], [42, 26]
+      ];
+      var results = element._normalizeIntralineHighlights(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 31,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+          endIndex: 33,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 75,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 0,
+          endIndex: 6,
+        }
+      ]);
+
+      content = [
+        '        this._path = value.path;',
+        '',
+        '        // When navigating away from the page, there is a possibility that the',
+        '        // patch number is no longer a part of the URL (say when navigating to',
+        '        // the top-level change info view) and therefore undefined in `params`.',
+        '        if (!this._patchRange.patchNum) {',
+      ];
+      highlights = [
+        [14, 17],
+        [11, 70],
+        [12, 67],
+        [12, 67],
+        [14, 29],
+      ];
+      results = element._normalizeIntralineHighlights(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 14,
+          endIndex: 31,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 8,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 3,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 4,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 5,
+          startIndex: 12,
+          endIndex: 41,
+        }
+      ]);
+    });
+
+    test('context', function() {
+      element.prefs.context = 3;
+      element._diffResponse = {
+        content: [
+          {
+            ab: [
+              '<!DOCTYPE html>',
+              '<meta charset="utf-8">',
+              '<title>My great page</title>',
+              '<style>',
+              '  *,',
+              '  *:before,',
+              '  *:after {',
+              '    box-sizing: border-box;',
+              '  }',
+              '</style>',
+              '<header>',
+            ]
+          },
+          {
+            a: [
+              '  Welcome ',
+              '  to the wooorld of tomorrow!',
+            ],
+            b: [
+              '  Hello, world!',
+            ],
+          },
+          {
+            ab: [
+              '</header>',
+              '<body>',
+              'Leela: This is the only place the ship can’t hear us, so ',
+              'everyone pretend to shower.',
+              'Fry: Same as every day. Got it.',
+            ]
+          },
+        ]
+      };
+      element._processContent();
+
+      // First eight lines should be hidden on both sides.
+      for (var i = 0; i < 8; i++) {
+        assert.isTrue(element._diff.leftSide[i].hidden);
+        assert.isTrue(element._diff.rightSide[i].hidden);
+      }
+      // A context control should be at index 8 on both sides.
+      var leftContext = element._diff.leftSide[8];
+      var rightContext = element._diff.rightSide[8];
+      assert.deepEqual(leftContext, rightContext);
+      assert.equal(leftContext.numLines, 8);
+      assert.equal(leftContext.start, 0);
+      assert.equal(leftContext.end, 8);
+
+      // Line indices 9-16 should be shown.
+      for (var i = 9; i <= 16; i++) {
+        // notOk (falsy) because the `hidden` attribute may not be present.
+        assert.notOk(element._diff.leftSide[i].hidden);
+        assert.notOk(element._diff.rightSide[i].hidden);
+      }
+
+      // Lines at indices 17 and 18 should be hidden.
+      assert.isTrue(element._diff.leftSide[17].hidden);
+      assert.isTrue(element._diff.rightSide[17].hidden);
+      assert.isTrue(element._diff.leftSide[18].hidden);
+      assert.isTrue(element._diff.rightSide[18].hidden);
+
+      // Context control at index 19.
+      leftContext = element._diff.leftSide[19];
+      rightContext = element._diff.rightSide[19];
+      assert.deepEqual(leftContext, rightContext);
+      assert.equal(leftContext.numLines, 2);
+      assert.equal(leftContext.start, 17);
+      assert.equal(leftContext.end, 19);
+    });
+
+    test('save prefs', function(done) {
+      element._loggedIn = false;
+
+      element.prefs = {
+        tab_size: 4,
+        context: 50,
+      };
+      element.fire('save', {}, {node: element.$$('gr-diff-preferences')});
+      assert.isTrue(element._diffPreferencesPromise == null);
+
+      element._loggedIn = true;
+      element.fire('save', {}, {node: element.$$('gr-diff-preferences')});
+      server.respond();
+
+      element._diffPreferencesPromise.then(function(req) {
+        assert.equal(req.xhr.requestBody, JSON.stringify(element.prefs));
+        done();
+      });
+    });
+
+    test('visible line length', function() {
+      assert.equal(element._visibleLineLength('A'.repeat(5)), 5);
+      assert.equal(
+          element._visibleLineLength('A'.repeat(5) + '\t' + 'A'.repeat(5)), 18);
+    });
+
+    test('break up common diff chunks', function() {
+      element._groupedBaseComments = {
+        1: {},
+      };
+      element._groupedComments = {
+        10: {},
+      };
+      var ctx = {
+        left: {lineNum: 0},
+        right: {lineNum: 0},
+      };
+      var content = [
+        {
+          ab: [
+            'Copyright (C) 2015 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.',
+          ]
+        }
+      ];
+      var result = element._breakUpCommonChunksWithComments(ctx, content);
+      assert.deepEqual(result, [
+        {
+          __noHighlight: true,
+          a: ['Copyright (C) 2015 The Android Open Source Project'],
+          b: ['Copyright (C) 2015 The Android Open Source Project'],
+        },
+        {
+          ab: [
+            '',
+            '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, ',
+          ]
+        },
+        {
+          __noHighlight: true,
+          a: ['software distributed under the License is distributed on an '],
+          b: ['software distributed under the License is distributed on an ']
+        },
+        {
+          ab: [
+            '"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.',
+          ]
+        }
+      ]);
+    });
+  });
+
+</script>