Move gr-diff-new to gr-diff

Change-Id: Ifaad016f806c31f3df43143b3238b757faa18b20
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
new file mode 100644
index 0000000..77c790c
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-side-by-side.js
@@ -0,0 +1,63 @@
+// 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(window, GrDiffBuilder) {
+  'use strict';
+
+  function GrDiffBuilderSideBySide(diff, comments, prefs, outputEl) {
+    GrDiffBuilder.call(this, diff, comments, prefs, outputEl);
+  }
+  GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
+  GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
+
+  GrDiffBuilderSideBySide.prototype.emitGroup = function(group,
+      opt_beforeSection) {
+    var sectionEl = this._createElement('tbody', 'section');
+    sectionEl.classList.add(group.type);
+    var pairs = group.getSideBySidePairs();
+    for (var i = 0; i < pairs.length; i++) {
+      sectionEl.appendChild(this._createRow(sectionEl, pairs[i].left,
+          pairs[i].right));
+    }
+    this._outputEl.insertBefore(sectionEl, opt_beforeSection);
+  },
+
+  GrDiffBuilderSideBySide.prototype._createRow = function(section, leftLine,
+      rightLine) {
+    var row = this._createElement('tr');
+    this._appendPair(section, row, leftLine, leftLine.beforeNumber,
+        GrDiffBuilder.Side.LEFT);
+    this._appendPair(section, row, rightLine, rightLine.afterNumber,
+        GrDiffBuilder.Side.RIGHT);
+    return row;
+  };
+
+  GrDiffBuilderSideBySide.prototype._appendPair = function(section, row, line,
+      lineNumber, side) {
+    row.appendChild(this._createLineEl(line, lineNumber, line.type));
+    var action = this._createContextControl(section, line);
+    if (action) {
+      row.appendChild(action);
+    } else {
+      var textEl = this._createTextEl(line);
+      textEl.classList.add(side);
+      var threadEl = this._commentThreadForLine(line, side);
+      if (threadEl) {
+        textEl.appendChild(threadEl);
+      }
+      row.appendChild(textEl);
+    }
+  };
+
+  window.GrDiffBuilderSideBySide = GrDiffBuilderSideBySide;
+})(window, GrDiffBuilder);
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
new file mode 100644
index 0000000..d9517d3
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder-unified.js
@@ -0,0 +1,55 @@
+// 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(window, GrDiffBuilder) {
+  'use strict';
+
+  function GrDiffBuilderUnified(diff, comments, prefs, outputEl) {
+    GrDiffBuilder.call(this, diff, comments, prefs, outputEl);
+  }
+  GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
+  GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
+
+  GrDiffBuilderUnified.prototype.emitGroup = function(group,
+      opt_beforeSection) {
+    var sectionEl = this._createElement('tbody', 'section');
+
+    for (var i = 0; i < group.lines.length; ++i) {
+      sectionEl.appendChild(this._createRow(sectionEl, group.lines[i]));
+    }
+    this._outputEl.insertBefore(sectionEl, opt_beforeSection);
+  };
+
+  GrDiffBuilderUnified.prototype._createRow = function(section, line) {
+    var row = this._createElement('tr', line.type);
+    row.appendChild(this._createLineEl(line, line.beforeNumber,
+        GrDiffLine.Type.REMOVE));
+    row.appendChild(this._createLineEl(line, line.afterNumber,
+        GrDiffLine.Type.ADD));
+
+    var action = this._createContextControl(section, line);
+    if (action) {
+      row.appendChild(action);
+    } else {
+      var textEl = this._createTextEl(line);
+      var threadEl = this._commentThreadForLine(line);
+      if (threadEl) {
+        textEl.appendChild(threadEl);
+      }
+      row.appendChild(textEl);
+    }
+    return row;
+  };
+
+  window.GrDiffBuilderUnified = GrDiffBuilderUnified;
+})(window, GrDiffBuilder);
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
new file mode 100644
index 0000000..338845e
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder.js
@@ -0,0 +1,580 @@
+// 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(window, GrDiffGroup, GrDiffLine) {
+  'use strict';
+
+  function GrDiffBuilder(diff, comments, prefs, outputEl) {
+    this._comments = comments;
+    this._prefs = prefs;
+    this._outputEl = outputEl;
+    this._groups = [];
+
+    this._commentLocations = this._getCommentLocations(comments);
+    this._processContent(diff.content, this._groups, prefs.context);
+  }
+
+  GrDiffBuilder.LESS_THAN_CODE = '<'.charCodeAt(0);
+  GrDiffBuilder.GREATER_THAN_CODE = '>'.charCodeAt(0);
+  GrDiffBuilder.AMPERSAND_CODE = '&'.charCodeAt(0);
+  GrDiffBuilder.SEMICOLON_CODE = ';'.charCodeAt(0);
+
+  GrDiffBuilder.TAB_REGEX = /\t/g;
+
+  GrDiffBuilder.LINE_FEED_HTML =
+      '<span class="style-scope gr-diff br"></span>';
+
+  GrDiffBuilder.GroupType = {
+    ADDED: 'b',
+    BOTH: 'ab',
+    REMOVED: 'a',
+  };
+
+  GrDiffBuilder.Highlights = {
+    ADDED: 'edit_b',
+    REMOVED: 'edit_a',
+  };
+
+  GrDiffBuilder.Side = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  GrDiffBuilder.prototype.emitDiff = function() {
+    for (var i = 0; i < this._groups.length; i++) {
+      this.emitGroup(this._groups[i]);
+    }
+  };
+
+  GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) {
+    throw Error('Subclasses must implement emitGroup');
+  },
+
+  GrDiffBuilder.prototype._processContent = function(content, groups, context) {
+    this._appendFileComments(groups);
+
+    var WHOLE_FILE = -1;
+    context = content.length > 1 ? context : WHOLE_FILE;
+
+    var lineNums = {
+      left: 0,
+      right: 0,
+    };
+    content = this._splitCommonGroupsWithComments(content, lineNums);
+    for (var i = 0; i < content.length; i++) {
+      var group = content[i];
+      var lines = [];
+
+      if (group[GrDiffBuilder.GroupType.BOTH] !== undefined) {
+        var rows = group[GrDiffBuilder.GroupType.BOTH];
+        this._appendCommonLines(rows, lines, lineNums);
+
+        var hiddenRange = [context, rows.length - context];
+        if (i === 0) {
+          hiddenRange[0] = 0;
+        } else if (i === content.length - 1) {
+          hiddenRange[1] = rows.length;
+        }
+
+        if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 0) {
+          this._insertContextGroups(groups, lines, hiddenRange);
+        } else {
+          groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, lines));
+        }
+        continue;
+      }
+
+      if (group[GrDiffBuilder.GroupType.REMOVED] !== undefined) {
+        var highlights;
+        if (group[GrDiffBuilder.Highlights.REMOVED] !== undefined) {
+          highlights = this._normalizeIntralineHighlights(
+              group[GrDiffBuilder.GroupType.REMOVED],
+              group[GrDiffBuilder.Highlights.REMOVED]);
+        }
+        this._appendRemovedLines(group[GrDiffBuilder.GroupType.REMOVED], lines,
+            lineNums, highlights);
+      }
+
+      if (group[GrDiffBuilder.GroupType.ADDED] !== undefined) {
+        var highlights;
+        if (group[GrDiffBuilder.Highlights.ADDED] !== undefined) {
+          highlights = this._normalizeIntralineHighlights(
+            group[GrDiffBuilder.GroupType.ADDED],
+            group[GrDiffBuilder.Highlights.ADDED]);
+        }
+        this._appendAddedLines(group[GrDiffBuilder.GroupType.ADDED], lines,
+            lineNums, highlights);
+      }
+      groups.push(new GrDiffGroup(GrDiffGroup.Type.DELTA, lines));
+    }
+  };
+
+  GrDiffBuilder.prototype._appendFileComments = function(groups) {
+    var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+    line.beforeNumber = GrDiffLine.FILE;
+    line.afterNumber = GrDiffLine.FILE;
+    groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]));
+  };
+
+  GrDiffBuilder.prototype._getCommentLocations = function(comments) {
+    var result = {
+      left: {},
+      right: {},
+    };
+    for (var side in comments) {
+      if (side !== GrDiffBuilder.Side.LEFT &&
+          side !== GrDiffBuilder.Side.RIGHT) {
+        continue;
+      }
+      comments[side].forEach(function(c) {
+        result[side][c.line || GrDiffLine.FILE] = true;
+      });
+    }
+    return result;
+  };
+
+  GrDiffBuilder.prototype._commentIsAtLineNum = function(side, lineNum) {
+    return this._commentLocations[side][lineNum] === true;
+  };
+
+  // In order to show comments out of the bounds of the selected context,
+  // treat them as separate chunks within the model so that the content (and
+  // context surrounding it) renders correctly.
+  GrDiffBuilder.prototype._splitCommonGroupsWithComments = function(content,
+      lineNums) {
+    var result = [];
+    var leftLineNum = lineNums.left;
+    var rightLineNum = lineNums.right;
+    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._commentIsAtLineNum(GrDiffBuilder.Side.LEFT, leftLineNum) ||
+            this._commentIsAtLineNum(GrDiffBuilder.Side.RIGHT, rightLineNum)) {
+          if (currentChunk.ab && currentChunk.ab.length > 0) {
+            result.push(currentChunk);
+            currentChunk = {ab: []};
+          }
+          result.push({ab: [chunk[j]]});
+        } else {
+          currentChunk.ab.push(chunk[j]);
+        }
+      }
+      if (currentChunk.ab != null && currentChunk.ab.length > 0) {
+        result.push(currentChunk);
+      }
+    }
+    return result;
+  };
+
+  // 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.
+  GrDiffBuilder.prototype._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;
+  };
+
+  GrDiffBuilder.prototype._insertContextGroups = function(groups, lines,
+      hiddenRange) {
+    var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
+    var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
+    var linesAfterCtx = lines.slice(hiddenRange[1]);
+
+    if (linesBeforeCtx.length > 0) {
+      groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
+    }
+
+    var ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+    ctxLine.contextLines = hiddenLines;
+    groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
+        [ctxLine]));
+
+    if (linesAfterCtx.length > 0) {
+      groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
+    }
+  };
+
+  GrDiffBuilder.prototype._appendCommonLines = function(rows, lines, lineNums) {
+    for (var i = 0; i < rows.length; i++) {
+      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.text = rows[i];
+      line.beforeNumber = ++lineNums.left;
+      line.afterNumber = ++lineNums.right;
+      lines.push(line);
+    }
+  };
+
+  GrDiffBuilder.prototype._appendRemovedLines = function(rows, lines, lineNums,
+      opt_highlights) {
+    for (var i = 0; i < rows.length; i++) {
+      var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      line.text = rows[i];
+      line.beforeNumber = ++lineNums.left;
+      if (opt_highlights) {
+        line.highlights = opt_highlights.filter(function(hl) {
+          return hl.contentIndex === i;
+        });
+      }
+      lines.push(line);
+    }
+  };
+
+  GrDiffBuilder.prototype._appendAddedLines = function(rows, lines, lineNums,
+      opt_highlights) {
+    for (var i = 0; i < rows.length; i++) {
+      var line = new GrDiffLine(GrDiffLine.Type.ADD);
+      line.text = rows[i];
+      line.afterNumber = ++lineNums.right;
+      if (opt_highlights) {
+        line.highlights = opt_highlights.filter(function(hl) {
+          return hl.contentIndex === i;
+        });
+      }
+      lines.push(line);
+    }
+  };
+
+  GrDiffBuilder.prototype._createContextControl = function(section, line) {
+    if (!line.contextLines.length) {
+      return null;
+    }
+    var td = this._createElement('td');
+    var button = this._createElement('gr-button', 'showContext');
+    button.setAttribute('link', true);
+    var commonLines = line.contextLines.length;
+    var text = 'Show ' + commonLines + ' common line';
+    if (commonLines > 1) {
+      text += 's';
+    }
+    text += '...';
+    button.textContent = text;
+    button.addEventListener('tap', function(e) {
+      e.detail = {
+        group: new GrDiffGroup(GrDiffGroup.Type.BOTH, line.contextLines),
+        section: section,
+      };
+      // Let it bubble up the DOM tree.
+    });
+    td.appendChild(button);
+    return td;
+  };
+
+  GrDiffBuilder.prototype._getCommentsForLine = function(comments, line,
+      opt_side) {
+    function byLineNum(lineNum) {
+      return function(c) {
+        return (c.line === lineNum) ||
+               (c.line === undefined && lineNum === GrDiffLine.FILE)
+      }
+    }
+    var leftComments =
+        comments[GrDiffBuilder.Side.LEFT].filter(byLineNum(line.beforeNumber));
+    var rightComments =
+        comments[GrDiffBuilder.Side.RIGHT].filter(byLineNum(line.afterNumber));
+
+    var result;
+
+    switch (opt_side) {
+      case GrDiffBuilder.Side.LEFT:
+        result = leftComments;
+        break;
+      case GrDiffBuilder.Side.RIGHT:
+        result = rightComments;
+        break;
+      default:
+        result = leftComments.concat(rightComments);
+        break;
+    }
+
+    return result;
+  };
+
+  GrDiffBuilder.prototype.createCommentThread = function(changeNum, patchNum,
+      path, side, projectConfig) {
+    var threadEl = document.createElement('gr-diff-comment-thread');
+    threadEl.changeNum = changeNum;
+    threadEl.patchNum = patchNum;
+    threadEl.path = path;
+    threadEl.side = side;
+    threadEl.projectConfig = projectConfig;
+    return threadEl;
+  },
+
+  GrDiffBuilder.prototype._commentThreadForLine = function(line, opt_side) {
+    var comments = this._getCommentsForLine(this._comments, line, opt_side);
+    if (!comments || comments.length === 0) {
+      return null;
+    }
+
+    var patchNum = this._comments.meta.patchRange.patchNum;
+    var side = 'REVISION';
+    if (line.type === GrDiffLine.Type.REMOVE ||
+        opt_side === GrDiffBuilder.Side.LEFT) {
+      if (this._comments.meta.patchRange.basePatchNum === 'PARENT') {
+        side = 'PARENT';
+      } else {
+        patchNum = this._comments.meta.patchRange.basePatchNum;
+      }
+    }
+    var threadEl = this.createCommentThread(
+        this._comments.meta.changeNum,
+        patchNum,
+        this._comments.meta.path,
+        side,
+        this._comments.meta.projectConfig);
+    threadEl.comments = comments;
+    return threadEl;
+  };
+
+  GrDiffBuilder.prototype._createLineEl = function(line, number, type) {
+    var td = this._createElement('td');
+    if (line.type === GrDiffLine.Type.BLANK) {
+      return td;
+    } else if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
+      td.classList.add('contextLineNum');
+      td.setAttribute('data-value', '@@');
+    } else if (line.type === GrDiffLine.Type.BOTH || line.type == type) {
+      td.classList.add('lineNum');
+      td.setAttribute('data-value', number);
+    }
+    return td;
+  };
+
+  GrDiffBuilder.prototype._createTextEl = function(line) {
+    var td = this._createElement('td');
+    if (line.type !== GrDiffLine.Type.BLANK) {
+      td.classList.add('content');
+    }
+    td.classList.add(line.type);
+    var text = line.text;
+    var html = util.escapeHTML(text);
+
+    td.classList.add(line.highlights.length > 0 ?
+        'lightHighlight' : 'darkHighlight');
+
+    if (line.highlights.length > 0) {
+      html = this._addIntralineHighlights(text, html, line.highlights);
+    }
+
+    if (text.length > this._prefs.line_length) {
+      html = this._addNewlines(text, html);
+    }
+    html = this._addTabWrappers(html);
+
+    // If the html is equivalent to the text then it didn't get highlighted
+    // or escaped. Use textContent which is faster than innerHTML.
+    if (html == text) {
+      td.textContent = text;
+    } else {
+      td.innerHTML = html;
+    }
+    return td;
+  };
+
+  // Advance `index` by the appropriate number of characters that would
+  // represent one source code character and return that index. For
+  // example, for source code '<span>' the escaped html string is
+  // '&lt;span&gt;'. Advancing from index 0 on the prior html string would
+  // return 4, since &lt; maps to one source code character ('<').
+  GrDiffBuilder.prototype._advanceChar = function(html, index) {
+    // TODO(andybons): Unicode is all kinds of messed up in JS. Account for it.
+    // https://mathiasbynens.be/notes/javascript-unicode
+
+    // Tags don't count as characters
+    while (index < html.length &&
+           html.charCodeAt(index) == GrDiffBuilder.LESS_THAN_CODE) {
+      while (index < html.length &&
+             html.charCodeAt(index) != GrDiffBuilder.GREATER_THAN_CODE) {
+        index++;
+      }
+      index++;  // skip the ">" itself
+    }
+    // An HTML entity (e.g., &lt;) counts as one character.
+    if (index < html.length &&
+        html.charCodeAt(index) == GrDiffBuilder.AMPERSAND_CODE) {
+      while (index < html.length &&
+             html.charCodeAt(index) != GrDiffBuilder.SEMICOLON_CODE) {
+        index++;
+      }
+    }
+    return index + 1;
+  };
+
+  GrDiffBuilder.prototype._addNewlines = function(text, html) {
+    var htmlIndex = 0;
+    var indices = [];
+    var numChars = 0;
+    for (var i = 0; i < text.length; i++) {
+      if (numChars > 0 && numChars % this._prefs.line_length === 0) {
+        indices.push(htmlIndex);
+      }
+      htmlIndex = this._advanceChar(html, htmlIndex);
+      if (text[i] === '\t') {
+        numChars += this._prefs.tab_size;
+      } else {
+        numChars++;
+      }
+    }
+    var result = html;
+    // Since the result string is being altered in place, start from the end
+    // of the string so that the insertion indices are not affected as the
+    // result string changes.
+    for (var i = indices.length - 1; i >= 0; i--) {
+      result = result.slice(0, indices[i]) + GrDiffBuilder.LINE_FEED_HTML +
+          result.slice(indices[i]);
+    }
+    return result;
+  };
+
+  GrDiffBuilder.prototype._addTabWrappers = function(html) {
+    var htmlStr = this._getTabWrapper(this._prefs.tab_size,
+        this._prefs.show_tabs);
+    return html.replace(GrDiffBuilder.TAB_REGEX, htmlStr);
+  };
+
+  GrDiffBuilder.prototype._addIntralineHighlights = function(content, html,
+      highlights) {
+    var START_TAG = '<hl class="style-scope gr-diff">';
+    var END_TAG = '</hl>';
+
+    for (var i = 0; i < highlights.length; i++) {
+      var hl = highlights[i];
+
+      var htmlStartIndex = 0;
+      // Find the index of the HTML string to insert the start tag.
+      for (var j = 0; j < hl.startIndex; j++) {
+        htmlStartIndex = this._advanceChar(html, htmlStartIndex);
+      }
+
+      var htmlEndIndex = 0;
+      if (hl.endIndex !== undefined) {
+        for (var j = 0; j < hl.endIndex; j++) {
+          htmlEndIndex = this._advanceChar(html, htmlEndIndex);
+        }
+      } else {
+        // If endIndex isn't present, continue to the end of the line.
+        htmlEndIndex = html.length;
+      }
+      // The start and end indices could be the same if a highlight is meant
+      // to start at the end of a line and continue onto the next one.
+      // Ignore it.
+      if (htmlStartIndex !== htmlEndIndex) {
+        html = html.slice(0, htmlStartIndex) + START_TAG +
+              html.slice(htmlStartIndex, htmlEndIndex) + END_TAG +
+              html.slice(htmlEndIndex);
+      }
+    }
+    return html;
+  };
+
+  GrDiffBuilder.prototype._getTabWrapper = function(tabSize, showTabs) {
+    // Force this to be a number to prevent arbitrary injection.
+    tabSize = +tabSize;
+    if (isNaN(tabSize)) {
+      throw Error('Invalid tab size from preferences.');
+    }
+
+    var str = '<span class="style-scope gr-diff tab ';
+    if (showTabs) {
+      str += 'withIndicator';
+    }
+    str += '" ';
+    // TODO(andybons): CSS tab-size is not supported in IE.
+    str += 'style="tab-size:' + tabSize + ';';
+    str += 'style="-moz-tab-size:' + tabSize + ';';
+    str += '">\t</span>';
+    return str;
+  };
+
+  GrDiffBuilder.prototype._createElement = function(tagName, className) {
+    var el = document.createElement(tagName);
+    // When Shady DOM is being used, these classes are added to account for
+    // Polymer's polyfill behavior. In order to guarantee sufficient
+    // specificity within the CSS rules, these are added to every element.
+    // Since the Polymer DOM utility functions (which would do this
+    // automatically) are not being used for performance reasons, this is
+    // done manually.
+    el.classList.add('style-scope', 'gr-diff');
+    if (!!className) {
+      el.classList.add(className);
+    }
+    return el;
+  };
+
+  window.GrDiffBuilder = GrDiffBuilder;
+})(window, GrDiffGroup, GrDiffLine);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder_test.html
new file mode 100644
index 0000000..2e30999
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-builder_test.html
@@ -0,0 +1,516 @@
+<!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-builder</title>
+
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="gr-diff-line.js"></script>
+<script src="gr-diff-group.js"></script>
+<script src="gr-diff-builder.js"></script>
+
+<script>
+  suite('gr-diff-builder tests', function() {
+    var builder;
+
+    setup(function() {
+      var prefs = {
+        line_length: 10,
+        show_tabs: true,
+        tab_size: 4,
+      };
+      builder = new GrDiffBuilder({content: []}, {left: [], right: []}, prefs);
+    });
+
+    test('process loaded content', function() {
+      var content = [
+        {
+          ab: [
+            '<!DOCTYPE html>',
+            '<meta charset="utf-8">',
+          ]
+        },
+        {
+          a: [
+            '  Welcome ',
+            '  to the wooorld of tomorrow!',
+          ],
+          b: [
+            '  Hello, world!',
+          ],
+        },
+        {
+          ab: [
+            'Leela: This is the only place the ship can’t hear us, so ',
+            'everyone pretend to shower.',
+            'Fry: Same as every day. Got it.',
+          ]
+        },
+      ];
+      var groups = [];
+
+      builder._processContent(content, groups, -1);
+
+      assert.equal(groups.length, 4);
+
+      var group = groups[0];
+      assert.equal(group.type, GrDiffGroup.Type.BOTH);
+      assert.equal(group.lines.length, 1);
+      assert.equal(group.lines[0].text, '');
+      assert.equal(group.lines[0].beforeNumber, GrDiffLine.FILE);
+      assert.equal(group.lines[0].afterNumber, GrDiffLine.FILE);
+
+      group = groups[1];
+      assert.equal(group.type, GrDiffGroup.Type.BOTH);
+      assert.equal(group.lines.length, 2);
+      assert.equal(group.lines.length, 2);
+
+      function beforeNumberFn(l) { return l.beforeNumber; }
+      function afterNumberFn(l) { return l.afterNumber; }
+      function textFn(l) { return l.text; }
+
+      assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
+      assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
+      assert.deepEqual(group.lines.map(textFn), [
+        '<!DOCTYPE html>',
+        '<meta charset="utf-8">',
+      ]);
+
+      group = groups[2];
+      assert.equal(group.type, GrDiffGroup.Type.DELTA);
+      assert.equal(group.lines.length, 3);
+      assert.equal(group.adds.length, 1);
+      assert.equal(group.removes.length, 2);
+      assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
+      assert.deepEqual(group.adds.map(afterNumberFn), [3]);
+      assert.deepEqual(group.removes.map(textFn), [
+        '  Welcome ',
+        '  to the wooorld of tomorrow!',
+      ]);
+      assert.deepEqual(group.adds.map(textFn), [
+        '  Hello, world!',
+      ]);
+
+      group = groups[3];
+      assert.equal(group.type, GrDiffGroup.Type.BOTH);
+      assert.equal(group.lines.length, 3);
+      assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
+      assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
+      assert.deepEqual(group.lines.map(textFn), [
+        'Leela: This is the only place the ship can’t hear us, so ',
+        'everyone pretend to shower.',
+        'Fry: Same as every day. Got it.',
+      ]);
+    });
+
+    test('insert context groups', function() {
+      var content = [
+        {ab: []},
+        {a: ['all work and no play make andybons a dull boy']},
+        {ab: []},
+        {b: ['elgoog elgoog elgoog']},
+        {ab: []},
+      ];
+      for (var i = 0; i < 100; i++) {
+        content[0].ab.push('all work and no play make jack a dull boy');
+        content[4].ab.push('all work and no play make jill a dull girl');
+      }
+      for (var i = 0; i < 5; i++) {
+        content[2].ab.push('no tv and no beer make homer go crazy');
+      }
+      var groups = [];
+      var context = 10;
+
+      builder._processContent(content, groups, context);
+
+      assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
+      assert.equal(groups[0].lines.length, 1);
+      assert.equal(groups[0].lines[0].text, '');
+      assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
+      assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+
+      assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+      assert.equal(groups[1].lines[0].contextLines.length, 90);
+      groups[1].lines[0].contextLines.forEach(function(l) {
+        assert.equal(l.text, content[0].ab[0]);
+      });
+
+      assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+      assert.equal(groups[2].lines.length, context);
+      groups[2].lines.forEach(function(l) {
+        assert.equal(l.text, content[0].ab[0]);
+      });
+
+      assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
+      assert.equal(groups[3].lines.length, 1);
+      assert.equal(groups[3].removes.length, 1);
+      assert.equal(groups[3].removes[0].text,
+          'all work and no play make andybons a dull boy');
+
+      assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+      assert.equal(groups[4].lines.length, 5);
+      groups[4].lines.forEach(function(l) {
+        assert.equal(l.text, content[2].ab[0]);
+      });
+
+      assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+      assert.equal(groups[5].lines.length, 1);
+      assert.equal(groups[5].adds.length, 1);
+      assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
+
+      assert.equal(groups[6].type, GrDiffGroup.Type.BOTH);
+      assert.equal(groups[6].lines.length, context);
+      groups[6].lines.forEach(function(l) {
+        assert.equal(l.text, content[4].ab[0]);
+      });
+
+      assert.equal(groups[7].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+      assert.equal(groups[7].lines[0].contextLines.length, 90);
+      groups[7].lines[0].contextLines.forEach(function(l) {
+        assert.equal(l.text, content[4].ab[0]);
+      });
+
+      content = [
+        {a: ['all work and no play make andybons a dull boy']},
+        {ab: []},
+        {b: ['elgoog elgoog elgoog']},
+      ];
+      for (var i = 0; i < 50; i++) {
+        content[1].ab.push('no tv and no beer make homer go crazy');
+      }
+      groups = [];
+
+      builder._processContent(content, groups, 10);
+
+      assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
+      assert.equal(groups[0].lines.length, 1);
+      assert.equal(groups[0].lines[0].text, '');
+      assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
+      assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+
+      assert.equal(groups[1].type, GrDiffGroup.Type.DELTA);
+      assert.equal(groups[1].lines.length, 1);
+      assert.equal(groups[1].removes.length, 1);
+      assert.equal(groups[1].removes[0].text,
+          'all work and no play make andybons a dull boy');
+
+      assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+      assert.equal(groups[2].lines.length, context);
+      groups[2].lines.forEach(function(l) {
+        assert.equal(l.text, content[1].ab[0]);
+      });
+
+      assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+      assert.equal(groups[3].lines[0].contextLines.length, 30);
+      groups[3].lines[0].contextLines.forEach(function(l) {
+        assert.equal(l.text, content[1].ab[0]);
+      });
+
+      assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+      assert.equal(groups[4].lines.length, context);
+      groups[4].lines.forEach(function(l) {
+        assert.equal(l.text, content[1].ab[0]);
+      });
+
+      assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+      assert.equal(groups[5].lines.length, 1);
+      assert.equal(groups[5].adds.length, 1);
+      assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
+    });
+
+    test('newlines', function() {
+      var text = 'abcdef';
+      assert.equal(builder._addNewlines(text, text), text);
+      text = 'a'.repeat(20);
+      assert.equal(builder._addNewlines(text, text),
+          'a'.repeat(10) +
+          GrDiffBuilder.LINE_FEED_HTML +
+          'a'.repeat(10));
+
+      text = '<span class="thumbsup">👍</span>';
+      var html = '&lt;span class=&quot;thumbsup&quot;&gt;👍&lt;&#x2F;span&gt;';
+      assert.equal(builder._addNewlines(text, html),
+          '&lt;span clas' +
+          GrDiffBuilder.LINE_FEED_HTML +
+          's=&quot;thumbsu' +
+          GrDiffBuilder.LINE_FEED_HTML +
+          'p&quot;&gt;👍&lt;&#x2F;spa' +
+          GrDiffBuilder.LINE_FEED_HTML +
+          'n&gt;');
+
+      text = '01234\t56789';
+      assert.equal(builder._addNewlines(text, text),
+          '01234\t5' +
+          GrDiffBuilder.LINE_FEED_HTML +
+          '6789');
+    });
+
+    test('tab wrapper insertion', function() {
+      var html = 'abc\tdef';
+      var wrapper = builder._getTabWrapper(
+          builder._prefs.tab_size,
+          builder._prefs.show_tabs);
+      assert.ok(wrapper);
+      assert.isAbove(wrapper.length, 0);
+      assert.equal(builder._addTabWrappers(html), 'abc' + wrapper + 'def');
+      assert.throws(builder._getTabWrapper.bind(
+          builder,
+          '"><img src="/" onerror="alert(1);"><span class="',
+          true));
+    });
+
+    test('comments', function() {
+      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+
+      var comments = {left: [], right:[]};
+      assert.deepEqual(builder._getCommentsForLine(comments, line), []);
+      assert.deepEqual(builder._getCommentsForLine(comments, line,
+          GrDiffBuilder.Side.LEFT), []);
+      assert.deepEqual(builder._getCommentsForLine(comments, line,
+          GrDiffBuilder.Side.RIGHT), []);
+
+      comments = {
+        left: [
+          {id: 'l3', line: 3},
+          {id: 'l5', line: 5},
+        ],
+        right: [
+          {id: 'r3', line: 3},
+          {id: 'r5', line: 5},
+        ],
+      };
+      assert.deepEqual(builder._getCommentsForLine(comments, line),
+          [{id: 'l3', line: 3}, {id: 'r5', line: 5}]);
+      assert.deepEqual(builder._getCommentsForLine(comments, line,
+          GrDiffBuilder.Side.LEFT), [{id: 'l3', line: 3}]);
+      assert.deepEqual(builder._getCommentsForLine(comments, line,
+          GrDiffBuilder.Side.RIGHT), [{id: 'r5', line: 5}]);
+    });
+
+    test('comment thread creation', function() {
+      builder._comments = {
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: '3',
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [
+          {id: 'l3', line: 3},
+          {id: 'l5', line: 5},
+        ],
+        right: [
+          {id: 'r5', line: 5},
+        ],
+      };
+
+      function checkThreadProps(patchNum, side, comments) {
+        assert.equal(threadEl.changeNum, '42');
+        assert.equal(threadEl.patchNum, patchNum);
+        assert.equal(threadEl.path, '/path/to/foo');
+        assert.equal(threadEl.side, side);
+        assert.deepEqual(threadEl.projectConfig, {foo: 'bar'});
+        assert.deepEqual(threadEl.comments, comments);
+      }
+
+      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = 5;
+      line.afterNumber = 5;
+      threadEl = builder._commentThreadForLine(line);
+      checkThreadProps('3', 'REVISION',
+          [{id: 'l5', line: 5}, {id: 'r5', line: 5}]);
+
+      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.RIGHT);
+      checkThreadProps('3', 'REVISION', [{id: 'r5', line: 5}]);
+
+      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.LEFT);
+      checkThreadProps('3', 'PARENT', [{id: 'l5', line: 5}]);
+
+      builder._comments.meta.patchRange.basePatchNum = '1';
+
+      threadEl = builder._commentThreadForLine(line);
+      checkThreadProps('3', 'REVISION',
+          [{id: 'l5', line: 5}, {id: 'r5', line: 5}]);
+
+      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.LEFT);
+      checkThreadProps('1', 'REVISION', [{id: 'l5', line: 5}]);
+
+      threadEl = builder._commentThreadForLine(line, GrDiffBuilder.Side.RIGHT);
+      checkThreadProps('3', 'REVISION', [{id: 'r5', line: 5}]);
+
+      builder._comments.meta.patchRange.basePatchNum = 'PARENT';
+
+      line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      line.beforeNumber = 5;
+      line.afterNumber = 5;
+      threadEl = builder._commentThreadForLine(line);
+      checkThreadProps('3', 'PARENT',
+          [{id: 'l5', line: 5}, {id: 'r5', line: 5}]);
+
+      line = new GrDiffLine(GrDiffLine.Type.ADD);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
+      threadEl = builder._commentThreadForLine(line);
+      checkThreadProps('3', 'REVISION',
+          [{id: 'l3', line: 3}, {id: 'r5', line: 5}]);
+    });
+
+    test('break up common diff chunks', function() {
+      builder._commentLocations = {
+        left: {1: true},
+        right: {10: true},
+      };
+      var lineNums = {
+        left: 0,
+        right: 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 = builder._splitCommonGroupsWithComments(content, lineNums);
+      assert.deepEqual(result, [
+        {
+          ab: ['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, ',
+          ]
+        },
+        {
+          ab: ['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.',
+          ]
+        }
+      ]);
+    });
+
+    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 = GrDiffBuilder.prototype._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 = GrDiffBuilder.prototype._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,
+        }
+      ]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
new file mode 100644
index 0000000..750f7da
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
@@ -0,0 +1,77 @@
+// 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(window, GrDiffLine) {
+  'use strict';
+
+  function GrDiffGroup(type, opt_lines) {
+    this.type = type;
+    this.lines = [];
+    this.adds = [];
+    this.removes = [];
+
+    if (opt_lines) {
+      opt_lines.forEach(this.addLine, this);
+    }
+  }
+
+  GrDiffGroup.Type = {
+    BOTH: 'both',
+    CONTEXT_CONTROL: 'contextControl',
+    DELTA: 'delta',
+  };
+
+  GrDiffGroup.prototype.addLine = function(line) {
+    this.lines.push(line);
+
+    var notDelta = (this.type === GrDiffGroup.Type.BOTH ||
+        this.type === GrDiffGroup.Type.CONTEXT_CONTROL);
+    if (notDelta && (line.type === GrDiffLine.Type.ADD ||
+        line.type === GrDiffLine.Type.REMOVE)) {
+      throw Error('Cannot add delta line to a non-delta group.');
+    }
+
+    if (line.type === GrDiffLine.Type.ADD) {
+      this.adds.push(line);
+    } else if (line.type === GrDiffLine.Type.REMOVE) {
+      this.removes.push(line);
+    }
+  };
+
+  GrDiffGroup.prototype.getSideBySidePairs = function() {
+    if (this.type === GrDiffGroup.Type.BOTH ||
+        this.type === GrDiffGroup.Type.CONTEXT_CONTROL) {
+      return this.lines.map(function(line) {
+        return {
+          left: line,
+          right: line,
+        };
+      });
+    }
+
+    var pairs = [];
+    var i = 0;
+    var j = 0;
+    while (i < this.removes.length || j < this.adds.length) {
+      pairs.push({
+        left: this.removes[i] || GrDiffLine.BLANK_LINE,
+        right: this.adds[j] || GrDiffLine.BLANK_LINE,
+      });
+      i++;
+      j++;
+    }
+    return pairs;
+  };
+
+  window.GrDiffGroup = GrDiffGroup;
+})(window, GrDiffLine);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
new file mode 100644
index 0000000..d3063e7
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
@@ -0,0 +1,105 @@
+<!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-group</title>
+
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="gr-diff-line.js"></script>
+<script src="gr-diff-group.js"></script>
+
+<script>
+  suite('gr-diff-group tests', function() {
+
+    test('delta line pairs', function() {
+      var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      var l1 = new GrDiffLine(GrDiffLine.Type.ADD);
+      var l2 = new GrDiffLine(GrDiffLine.Type.ADD);
+      var l3 = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      group.addLine(l1);
+      group.addLine(l2);
+      group.addLine(l3);
+      assert.deepEqual(group.lines, [l1, l2, l3]);
+      assert.deepEqual(group.adds, [l1, l2]);
+      assert.deepEqual(group.removes, [l3]);
+
+      var pairs = group.getSideBySidePairs();
+      assert.deepEqual(pairs, [
+        {left: l3, right: l1},
+        {left: GrDiffLine.BLANK_LINE, right: l2},
+      ]);
+
+      group = new GrDiffGroup(GrDiffGroup.Type.DELTA, [l1, l2, l3]);
+      assert.deepEqual(group.lines, [l1, l2, l3]);
+      assert.deepEqual(group.adds, [l1, l2]);
+      assert.deepEqual(group.removes, [l3]);
+
+      pairs = group.getSideBySidePairs();
+      assert.deepEqual(pairs, [
+        {left: l3, right: l1},
+        {left: GrDiffLine.BLANK_LINE, right: l2},
+      ]);
+    });
+
+    test('group/header line pairs', function() {
+      var l1 = new GrDiffLine(GrDiffLine.Type.BOTH);
+      var l2 = new GrDiffLine(GrDiffLine.Type.BOTH);
+      var l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+      var group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
+
+      assert.deepEqual(group.lines, [l1, l2, l3]);
+      assert.deepEqual(group.adds, []);
+      assert.deepEqual(group.removes, []);
+
+      var pairs = group.getSideBySidePairs();
+      assert.deepEqual(pairs, [
+        {left: l1, right: l1},
+        {left: l2, right: l2},
+        {left: l3, right: l3},
+      ]);
+
+      group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL, [l1, l2, l3]);
+      assert.deepEqual(group.lines, [l1, l2, l3]);
+      assert.deepEqual(group.adds, []);
+      assert.deepEqual(group.removes, []);
+
+      pairs = group.getSideBySidePairs();
+      assert.deepEqual(pairs, [
+        {left: l1, right: l1},
+        {left: l2, right: l2},
+        {left: l3, right: l3},
+      ]);
+    });
+
+    test('adding delta lines to non-delta group', function() {
+      var l1 = new GrDiffLine(GrDiffLine.Type.ADD);
+      var l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      var l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+
+      var group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+      assert.throws(group.addLine.bind(group, l1));
+      assert.throws(group.addLine.bind(group, l2));
+      assert.doesNotThrow(group.addLine.bind(group, l3));
+
+      group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL);
+      assert.throws(group.addLine.bind(group, l1));
+      assert.throws(group.addLine.bind(group, l2));
+      assert.doesNotThrow(group.addLine.bind(group, l3));
+    });
+  });
+
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
new file mode 100644
index 0000000..ea00a3d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
@@ -0,0 +1,43 @@
+// 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(window) {
+  'use strict';
+
+  function GrDiffLine(type) {
+    this.type = type;
+    this.contextLines = [];
+    this.highlights = [];
+  }
+
+  GrDiffLine.prototype.beforeNumber = 0;
+
+  GrDiffLine.prototype.afterNumber = 0;
+
+  GrDiffLine.prototype.text = '';
+
+  GrDiffLine.Type = {
+    ADD: 'add',
+    BOTH: 'both',
+    BLANK: 'blank',
+    CONTEXT_CONTROL: 'contextControl',
+    REMOVE: 'remove',
+  };
+
+  GrDiffLine.FILE = 'FILE';
+
+  GrDiffLine.BLANK_LINE = new GrDiffLine(GrDiffLine.Type.BLANK);
+
+  window.GrDiffLine = GrDiffLine;
+
+})(window);
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 a6d92a2..e0306ad 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -20,13 +20,19 @@
 <link rel="import" href="../../shared/gr-request/gr-request.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
+<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.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>
+      :host {
+        --light-remove-highlight-color: #fee;
+        --dark-remove-highlight-color: #ffd4d4;
+        --light-add-highlight-color: #efe;
+        --dark-add-highlight-color: #d4ffd4;
+      }
       .loading {
         padding: 0 var(--default-horizontal-margin) 1em;
         color: #666;
@@ -45,16 +51,99 @@
         display: flex;
         font: 12px var(--monospace-font-family);
         overflow-x: auto;
+        will-change: transform;
       }
-      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;
+      table {
+        border-collapse: collapse;
         border-right: 1px solid #ddd;
       }
+      .section {
+        background-color: #eee;
+      }
+      .blank,
+      .content {
+        background-color: #fff;
+      }
+      .lineNum,
+      .content {
+        vertical-align: top;
+        white-space: pre;
+      }
+      .contextLineNum:before,
+      .lineNum:before {
+        display: inline-block;
+        color: #666;
+        content: attr(data-value);
+        padding: 0 .75em;
+        text-align: right;
+        width: 100%;
+      }
+      .canComment .lineNum[data-value] {
+        cursor: pointer;
+      }
+      .canComment .lineNum[data-value]:before {
+        text-decoration: underline;
+      }
+      .canComment .lineNum[data-value]:hover:before {
+        background-color: #ccc;
+      }
+      .canComment .lineNum[data-value="FILE"]:before {
+        content: 'File';
+      }
+      .content {
+        overflow: hidden;
+        min-width: var(--content-width, 80ch);
+      }
+      .content.left {
+        -webkit-user-select: var(--left-user-select, text);
+        -moz-user-select: var(--left-user-select, text);
+        -ms-user-select: var(--left-user-select, text);
+        user-select: var(--left-user-select, text);
+      }
+      .content.right {
+        -webkit-user-select: var(--right-user-select, text);
+        -moz-user-select: var(--right-user-select, text);
+        -ms-user-select: var(--right-user-select, text);
+        user-select: var(--right-user-select, text);
+      }
+      .content.add hl,
+      .content.add.darkHighlight {
+        background-color: var(--dark-add-highlight-color);
+      }
+      .content.add.lightHighlight {
+        background-color: var(--light-add-highlight-color);
+      }
+      .content.remove hl,
+      .content.remove.darkHighlight {
+        background-color: var(--dark-remove-highlight-color);
+      }
+      .content.remove.lightHighlight {
+        background-color: var(--light-remove-highlight-color);
+      }
+      .contextControl {
+        color: #849;
+        background-color: #fef;
+      }
+      .contextControl gr-button {
+        display: block;
+        font-family: var(--monospace-font-family);
+        text-decoration: none;
+      }
+      .contextControl td:not(.lineNum) {
+        text-align: center;
+      }
+      .br:after {
+        /* Line feed */
+        content: '\A';
+      }
+      .tab {
+        display: inline-block;
+      }
+      .tab.withIndicator:before {
+        color: #C62828;
+        /* >> character */
+        content: '\00BB';
+      }
     </style>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
     <div hidden$="[[_loading]]" hidden>
@@ -67,44 +156,29 @@
         <gr-button link
            class="prefsButton"
            on-tap="_handlePrefsTap"
-           hidden$="[[!prefs]]"
+           hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]"
            hidden>Diff View Preferences</gr-button>
       </div>
       <gr-overlay id="prefsOverlay" with-backdrop>
         <gr-diff-preferences
-            prefs="{{prefs}}"
+            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 class$="[[_computeContainerClass(_loggedIn, _viewMode)]]"
+          on-tap="_handleTap"
+          on-mousedown="_handleMouseDown"
+          on-copy="_handleCopy">
+        <table id="diffTable"></table>
       </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
+  <script src="gr-diff-line.js"></script>
+  <script src="gr-diff-group.js"></script>
+  <script src="gr-diff-builder.js"></script>
+  <script src="gr-diff-builder-side-by-side.js"></script>
+  <script src="gr-diff-builder-unified.js"></script>
   <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
index cac1f3e..12ceacb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -14,6 +14,16 @@
 (function() {
   'use strict';
 
+  var DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
+  var DiffSide = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
   Polymer({
     is: 'gr-diff',
 
@@ -26,148 +36,379 @@
     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: {
+      projectConfig: {
         type: Object,
-        readOnly: true,
-        value: function() {
-          return new Promise(function(resolve) {
-            this._resolvePrefsReady = resolve;
-          }.bind(this));
-        },
+        observer: '_projectConfigChanged',
       },
-      _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,
-
-      _diffPreferencesPromise: Object,  // Used for testing.
+      _viewMode: {
+        type: String,
+        value: DiffViewMode.SIDE_BY_SIDE,
+      },
+      _diff: Object,
+      _diffBuilder: Object,
+      _prefs: Object,
+      _selectionSide: {
+        type: String,
+        observer: '_selectionSideChanged',
+      },
+      _comments: Object,
+      _focusedSection: {
+        type: Number,
+        value: -1,
+      },
+      _focusedThread: {
+        type: Number,
+        value: -1,
+      },
     },
 
     observers: [
-      '_prefsChanged(prefs.*)',
+      '_prefsChanged(_prefs.*)',
     ],
 
-    ready: function() {
-      app.accountReady.then(function() {
-        this._loggedIn = app.loggedIn;
+    attached: function() {
+      this._getLoggedIn().then(function(loggedIn) {
+        this._loggedIn = 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();
+      this.addEventListener('thread-discard',
+          this._handleThreadDiscard.bind(this));
+      this.addEventListener('comment-discard',
+          this._handleCommentDiscard.bind(this));
     },
 
     reload: function() {
+      this._clearDiffContent();
       this._loading = true;
-      // 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 diffLoaded = this._getDiff().then(function(diff) {
-        this._diffResponse = diff;
+      var promises = [];
+
+      promises.push(this._getDiff().then(function(diff) {
+        this._diff = diff;
+        this._loading = false;
+      }.bind(this)));
+
+      promises.push(this._getDiffCommentsAndDrafts().then(function(comments) {
+        this._comments = comments;
+      }.bind(this)));
+
+      promises.push(this._getDiffPreferences().then(function(prefs) {
+        this._prefs = prefs;
+      }.bind(this)));
+
+      return Promise.all(promises).then(function() {
+        this._render();
       }.bind(this));
+    },
 
-      var promises = [
-        this._prefsReady,
-        diffLoaded,
-      ];
+    showDiffPreferences: function() {
+      this.$.prefsOverlay.open();
+    },
 
-      return app.accountReady.then(function() {
-        promises.push(this._getDiffComments().then(function(res) {
-          this._baseComments = res.baseComments;
-          this._comments = res.comments;
-        }.bind(this)));
+    scrollToLine: function(lineNum) {
+      if (isNaN(lineNum) || lineNum < 1) { return; }
 
-        if (!app.loggedIn) {
-          this._baseDrafts = [];
-          this._drafts = [];
-        } else {
-          promises.push(this._getDiffDrafts().then(function(res) {
-            this._baseDrafts = res.baseComments;
-            this._drafts = res.comments;
-          }.bind(this)));
-        }
+      var lineEls = Polymer.dom(this.root).querySelectorAll(
+          '.lineNum[data-value="' + lineNum + '"]');
 
-        return Promise.all(promises).then(function() {
-          this._render();
-          this._loading = false;
-        }.bind(this)).catch(function(err) {
-          this._loading = false;
-          alert('Oops. Something went wrong. Check the console and bug the ' +
+      // Always choose the right side.
+      var el = lineEls.length === 2 ? lineEls[1] : lineEls[0];
+      this._scrollToElement(el);
+    },
+
+    scrollToNextDiffChunk: function() {
+      this._focusedSection = this._advanceElementWithinNodeList(
+          this._getDeltaSections(), this._focusedSection, 1);
+    },
+
+    scrollToPreviousDiffChunk: function() {
+      this._focusedSection = this._advanceElementWithinNodeList(
+          this._getDeltaSections(), this._focusedSection, -1);
+    },
+
+    scrollToNextCommentThread: function() {
+      this._focusedThread = this._advanceElementWithinNodeList(
+          this._getCommentThreads(), this._focusedThread, 1);
+    },
+
+    scrollToPreviousCommentThread: function() {
+      this._focusedThread = this._advanceElementWithinNodeList(
+          this._getCommentThreads(), this._focusedThread, -1);
+    },
+
+    _advanceElementWithinNodeList: function(els, curIndex, direction) {
+      var idx = Math.max(0, Math.min(els.length - 1, curIndex + direction));
+      if (curIndex !== idx) {
+        this._scrollToElement(els[idx]);
+        return idx;
+      }
+      return curIndex;
+    },
+
+    _getCommentThreads: function() {
+      return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
+    },
+
+    _getDeltaSections: function() {
+      return Polymer.dom(this.root).querySelectorAll('.section.delta');
+    },
+
+    _scrollToElement: function(el) {
+      if (!el) { return; }
+
+      // Calculate where the element is relative to the window.
+      var top = el.offsetTop;
+      for (var offsetParent = el.offsetParent;
+           offsetParent;
+           offsetParent = offsetParent.offsetParent) {
+        top += offsetParent.offsetTop;
+      }
+
+      // Scroll the element to the middle of the window. Dividing by a third
+      // instead of half the inner height feels a bit better otherwise the
+      // element appears to be below the center of the window even when it
+      // isn't.
+      window.scrollTo(0, top - (window.innerHeight / 3) +
+          (el.offsetHeight / 2));
+    },
+
+    _computeContainerClass: function(loggedIn, viewMode) {
+      var classes = ['diffContainer'];
+      switch (viewMode) {
+        case DiffViewMode.UNIFIED:
+          classes.push('unified');
+          break;
+        case DiffViewMode.SIDE_BY_SIDE:
+          classes.push('sideBySide');
+          break
+        default:
+          throw Error('Invalid view mode: ', viewMode);
+      }
+      if (loggedIn) {
+        classes.push('canComment');
+      }
+      return classes.join(' ');
+    },
+
+    _computePrefsButtonHidden: function(prefs, loggedIn) {
+      return !loggedIn || !prefs;
+    },
+
+    _handlePrefsTap: function(e) {
+      e.preventDefault();
+      this.$.prefsOverlay.open();
+    },
+
+    _handlePrefsSave: function(e) {
+      e.stopPropagation();
+      var el = Polymer.dom(e).rootTarget;
+      el.disabled = true;
+      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));
+        throw err;
+      });
+    },
+
+    _saveDiffPreferences: function() {
+      return this.$.restAPI.saveDiffPreferences(this._prefs);
+    },
+
+    _handlePrefsCancel: function(e) {
+      e.stopPropagation();
+      this.$.prefsOverlay.close();
+    },
+
+    _handleTap: function(e) {
+      var el = Polymer.dom(e).rootTarget;
+
+      if (el.classList.contains('showContext')) {
+        this._showContext(e.detail.group, e.detail.section);
+      } else if (el.classList.contains('lineNum')) {
+        this._handleLineTap(el);
+      }
+    },
+
+    _handleLineTap: function(el) {
+      this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn) { return; }
+
+        var value = el.getAttribute('data-value');
+        if (value === GrDiffLine.FILE) {
+          this._addDraft(el);
+          return;
+        }
+        var lineNum = parseInt(value, 10);
+        if (isNaN(lineNum)) {
+          throw Error('Invalid line number: ' + value);
+        }
+        this._addDraft(el, lineNum);
       }.bind(this));
     },
 
+    _addDraft: function(lineEl, opt_lineNum) {
+      var threadEl;
+
+      // Does a thread already exist at this line?
+      var contentEl = lineEl.nextSibling;
+      while (contentEl && !contentEl.classList.contains('content')) {
+        contentEl = contentEl.nextSibling;
+      }
+      if (contentEl.childNodes.length > 0 &&
+          contentEl.lastChild.nodeName === 'GR-DIFF-COMMENT-THREAD') {
+        threadEl = contentEl.lastChild;
+      } else {
+        var patchNum = this.patchRange.patchNum;
+        var side = 'REVISION';
+        if (contentEl.classList.contains(DiffSide.LEFT) ||
+            contentEl.classList.contains('remove')) {
+          if (this.patchRange.basePatchNum === 'PARENT') {
+            side = 'PARENT';
+          } else {
+            patchNum = this.patchRange.basePatchNum;
+          }
+        }
+        threadEl = this._builder.createCommentThread(this.changeNum, patchNum,
+            this.path, side, this.projectConfig);
+        contentEl.appendChild(threadEl);
+      }
+      threadEl.addDraft(opt_lineNum);
+    },
+
+    _handleThreadDiscard: function(e) {
+      var el = Polymer.dom(e).rootTarget;
+      el.parentNode.removeChild(el);
+    },
+
+    _handleCommentDiscard: function(e) {
+      var comment = Polymer.dom(e).rootTarget.comment;
+      this._removeComment(comment);
+    },
+
+    _removeComment: function(comment) {
+      if (!comment.id) { return; }
+      this._removeCommentFromSide(comment, DiffSide.LEFT) ||
+          this._removeCommentFromSide(comment, DiffSide.RIGHT);
+    },
+
+    _removeCommentFromSide: function(comment, side) {
+      var idx = -1;
+      for (var i = 0; i < this._comments[side].length; i++) {
+        if (this._comments[side][i].id === comment.id) {
+          idx = i;
+          break;
+        }
+      }
+      if (idx !== -1) {
+        this.splice('_comments.' + side, idx, 1);
+        return true;
+      }
+      return false;
+    },
+
+    _handleMouseDown: function(e) {
+      var el = Polymer.dom(e).rootTarget;
+      var side;
+      for (var node = el; node != null; node = node.parentNode) {
+        if (!node.classList) { continue; }
+
+        if (node.classList.contains(DiffSide.LEFT)) {
+          side = DiffSide.LEFT;
+          break;
+        } else if (node.classList.contains(DiffSide.RIGHT)) {
+          side = DiffSide.RIGHT;
+          break;
+        }
+      }
+      this._selectionSide = side;
+    },
+
+    _selectionSideChanged: function(side) {
+      if (side) {
+        var oppositeSide = side === DiffSide.RIGHT ?
+            DiffSide.LEFT : DiffSide.RIGHT;
+        this.customStyle['--' + side + '-user-select'] = 'text';
+        this.customStyle['--' + oppositeSide + '-user-select'] = 'none';
+      } else {
+        this.customStyle['--left-user-select'] = 'text';
+        this.customStyle['--right-user-select'] = 'text';
+      }
+      this.updateStyles();
+    },
+
+    _handleCopy: function(e) {
+      var text = this._getSelectedText(this._selectionSide);
+      e.clipboardData.setData('Text', text);
+      e.preventDefault();
+    },
+
+    _getSelectedText: function(opt_side) {
+      var sel = window.getSelection();
+      var range = sel.getRangeAt(0);
+      var doc = range.cloneContents();
+      var selector = '.content';
+      if (opt_side) {
+        selector += '.' + opt_side;
+      }
+      var contentEls = Polymer.dom(doc).querySelectorAll(selector);
+
+      if (contentEls.length === 0) {
+        return doc.textContent;
+      }
+
+      var text = '';
+      for (var i = 0; i < contentEls.length; i++) {
+        text += contentEls[i].textContent + '\n';
+      }
+      return text;
+    },
+
+    _showContext: function(group, sectionEl) {
+      this._builder.emitGroup(group, sectionEl);
+      sectionEl.parentNode.removeChild(sectionEl);
+    },
+
+    _prefsChanged: function(prefsChangeRecord) {
+      var prefs = prefsChangeRecord.base;
+      this.customStyle['--content-width'] = prefs.line_length + 'ch';
+      this.updateStyles();
+
+      if (this._diff && this._comments) {
+        this._render();
+      }
+    },
+
+    _render: function() {
+      this._clearDiffContent();
+      this._builder = this._getDiffBuilder(this._diff, this._comments,
+          this._prefs);
+      this._builder.emitDiff(this._diff.content);
+
+      this.async(function() {
+        this.fire('render', null, {bubbles: false});
+      }.bind(this), 1);
+    },
+
+    _clearDiffContent: function() {
+      this.$.diffTable.innerHTML = null;
+    },
+
     _getDiff: function() {
       return this.$.restAPI.getDiff(
           this.changeNum,
@@ -185,528 +426,95 @@
     },
 
     _getDiffDrafts: function() {
-      return this.$.restAPI.getDiffDrafts(
-          this.changeNum,
-          this.patchRange.basePatchNum,
-          this.patchRange.patchNum,
-          this.path);
-    },
-
-    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;
-    },
-
-    _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;
+      return this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn) {
+          return Promise.resolve({baseComments: [], comments: []});
         }
-        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;
-        });
+        return this.$.restAPI.getDiffDrafts(
+            this.changeNum,
+            this.patchRange.basePatchNum,
+            this.patchRange.patchNum,
+            this.path);
       }.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;
+    _getDiffCommentsAndDrafts: function() {
+      var promises = [];
+      promises.push(this._getDiffComments());
+      promises.push(this._getDiffDrafts());
+      return Promise.all(promises).then(function(results) {
+        return Promise.resolve({
+          comments: results[0],
+          drafts: results[1],
+        });
+      }).then(this._normalizeDiffCommentsAndDrafts.bind(this));
     },
 
-    _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;
+    _getDiffPreferences: function() {
+      return this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn) {
+          // These defaults should match the defaults in
+          // gerrit-extension-api/src/main/jcg/gerrit/extensions/client/DiffPreferencesInfo.java
+          // NOTE: There are some settings that don't apply to PolyGerrit
+          // (Render mode being at least one of them).
+          return Promise.resolve({
+            auto_hide_diff_table_header: true,
+            context: 10,
+            cursor_blink_rate: 0,
+            ignore_whitespace: 'IGNORE_NONE',
+            intraline_difference: true,
+            line_length: 100,
+            show_line_endings: true,
+            show_tabs: true,
+            show_whitespace_errors: true,
+            syntax_highlighting: true,
+            tab_size: 8,
+            theme: 'DEFAULT',
+          });
         }
-      }
-      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);
+        return this.$.restAPI.getDiffPreferences();
+      }.bind(this));
     },
 
-    _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);
+    _normalizeDiffCommentsAndDrafts: function(results) {
+      function markAsDraft(d) {
+        d.__draft = true;
+        return d;
       }
-      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,
+      var baseDrafts = results.drafts.baseComments.map(markAsDraft);
+      var drafts = results.drafts.comments.map(markAsDraft);
+      return Promise.resolve({
+        meta: {
           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,
+          changeNum: this.changeNum,
+          patchRange: this.patchRange,
+          projectConfig: this.projectConfig,
         },
-        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,
-      };
+        left: results.comments.baseComments.concat(baseDrafts),
+        right: results.comments.comments.concat(drafts),
+      });
     },
 
-    // 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;
+    _getLoggedIn: function() {
+      return this.$.restAPI.getLoggedIn();
     },
 
-    _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;
+    _getDiffBuilder: function(diff, comments, prefs) {
+      if (this._viewMode === DiffViewMode.SIDE_BY_SIDE) {
+        return new GrDiffBuilderSideBySide(diff, comments, prefs,
+            this.$.diffTable);
+      } else if (this._viewMode === DiffViewMode.UNIFIED) {
+        return new GrDiffBuilderUnified(diff, comments, prefs,
+            this.$.diffTable);
+      }
+      throw Error('Unsupported diff view mode: ' + this._viewMode);
     },
 
-    _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 + ']');
+    _projectConfigChanged: function(projectConfig) {
+      var threadEls = this._getCommentThreads();
+      for (var i = 0; i < threadEls.length; i++) {
+        threadEls[i].projectConfig = projectConfig;
       }
-      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
index e46bbb8..579957e 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
@@ -20,8 +20,6 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-<script src="../../../test/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">
@@ -35,458 +33,214 @@
 <script>
   suite('gr-diff tests', function() {
     var element;
-    var server;
-    var getDiffStub;
-    var getCommentsStub;
 
     setup(function() {
+      stub('gr-rest-api-interface', {
+        getLoggedIn: function() { return Promise.resolve(false); },
+      })
       element = fixture('basic');
-      element.changeNum = 42;
-      element.path = 'sieve.go';
-      element.prefs = {
-        context: 10,
-        tab_size: 8,
-      };
-
-      getDiffStub = sinon.stub(element.$.restAPI, 'getDiff', function() {
-        return Promise.resolve({
-          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.',
-              ]
-            },
-          ]
-        });
-      });
-
-      getCommentsStub = sinon.stub(element.$.restAPI, 'getDiffComments',
-        function() {
-          return Promise.resolve({
-            baseComments: [
-              {
-                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',
-              }
-            ],
-            comments: [
-              {
-                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: 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 = sinon.fakeServer.create();
-      server.respondWith(
-        'PUT',
-        '/accounts/self/preferences.diff',
-        [
-          200,
-          {'Content-Type': 'application/json'},
-          ')]}\'\n' +
-          JSON.stringify({context: 25}),
-        ]
-      );
-
     });
 
-    teardown(function() {
-      getDiffStub.restore();
-      getCommentsStub.restore();
-      server.restore();
-    });
+    test('get drafts logged out', function(done) {
+      element.patchRange = {basePatchNum: 0, patchNum: 0};
 
-    test('comment rendering', function(done) {
-      element.prefs.context = -1;
-      element._loggedIn = true;
-      element.patchRange = {
-        basePatchNum: 1,
-        patchNum: 2,
-      };
-
-      element.reload().then(function() {
-        flush(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);
-          flush(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('thread-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('comment-discard');
-          });
-        });
-      });
-      server.respond();
-    });
-
-    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));
+      var getDraftsStub = sinon.stub(element.$.restAPI, 'getDiffDrafts');
+      var loggedInStub = sinon.stub(element, '_getLoggedIn',
+          function() { return Promise.resolve(false); });
+      element._getDiffDrafts().then(function(result) {
+        assert.deepEqual(result, {baseComments: [], comments: []});
+        sinon.assert.notCalled(getDraftsStub);
+        loggedInStub.restore();
+        getDraftsStub.restore();
         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('get drafts logged in', function(done) {
+      element.patchRange = {basePatchNum: 0, patchNum: 0};
+      var draftsResponse = {
+        baseComments: [{id: 'foo'}],
+        comments: [{id: 'bar'}],
+      };
+      var getDraftsStub = sinon.stub(element.$.restAPI, 'getDiffDrafts',
+          function() { return Promise.resolve(draftsResponse); });
+      var loggedInStub = sinon.stub(element, '_getLoggedIn',
+          function() { return Promise.resolve(true); });
+      element._getDiffDrafts().then(function(result) {
+        assert.deepEqual(result, draftsResponse);
+        loggedInStub.restore();
+        getDraftsStub.restore();
+        done();
+      });
     });
 
-    test('break up common diff chunks', function() {
-      element._groupedBaseComments = {
-        1: {},
+    test('get comments and drafts', function(done) {
+      var loggedInStub = sinon.stub(element, '_getLoggedIn',
+          function() { return Promise.resolve(true); });
+      var comments = {
+        baseComments: [
+          {id: 'bc1'},
+          {id: 'bc2'},
+        ],
+        comments: [
+          {id: 'c1'},
+          {id: 'c2'},
+        ],
       };
-      element._groupedComments = {
-        10: {},
+      var diffCommentsStub = sinon.stub(element, '_getDiffComments',
+          function() { return Promise.resolve(comments); });
+
+      var drafts = {
+        baseComments: [
+          {id: 'bd1'},
+          {id: 'bd2'},
+        ],
+        comments: [
+          {id: 'd1'},
+          {id: 'd2'},
+        ],
       };
-      var ctx = {
-        left: {lineNum: 0},
-        right: {lineNum: 0},
+      var diffDraftsStub = sinon.stub(element, '_getDiffDrafts',
+          function() { return Promise.resolve(drafts); });
+
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 3,
       };
-      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'],
+      element.path = '/path/to/foo';
+      element.projectConfig = {foo: 'bar'};
+
+      element._getDiffCommentsAndDrafts().then(function(result) {
+        assert.deepEqual(result, {
+          meta: {
+            changeNum: '42',
+            patchRange: {
+              basePatchNum: 'PARENT',
+              patchNum: 3,
+            },
+            path: '/path/to/foo',
+            projectConfig: {foo: 'bar'},
+          },
+          left: [
+            {id: 'bc1'},
+            {id: 'bc2'},
+            {id: 'bd1', __draft: true},
+            {id: 'bd2', __draft: true},
+          ],
+          right: [
+            {id: 'c1'},
+            {id: 'c2'},
+            {id: 'd1', __draft: true},
+            {id: 'd2', __draft: true},
+          ],
+        });
+
+        diffCommentsStub.restore();
+        diffDraftsStub.restore();
+        loggedInStub.restore();
+        done();
+      });
+    });
+
+    test('remove comment', function() {
+      element._comments = {
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
         },
-        {
-          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, ',
-          ]
+        left: [
+          {id: 'bc1'},
+          {id: 'bc2'},
+          {id: 'bd1', __draft: true},
+          {id: 'bd2', __draft: true},
+        ],
+        right: [
+          {id: 'c1'},
+          {id: 'c2'},
+          {id: 'd1', __draft: true},
+          {id: 'd2', __draft: true},
+        ],
+      };
+
+      element._removeComment({});
+      // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem to
+      // believe that one object deepEquals another even when they do :-/.
+      assert.equal(JSON.stringify(element._comments), JSON.stringify({
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
         },
-        {
-          __noHighlight: true,
-          a: ['software distributed under the License is distributed on an '],
-          b: ['software distributed under the License is distributed on an ']
+        left: [
+          {id: 'bc1'},
+          {id: 'bc2'},
+          {id: 'bd1', __draft: true},
+          {id: 'bd2', __draft: true},
+        ],
+        right: [
+          {id: 'c1'},
+          {id: 'c2'},
+          {id: 'd1', __draft: true},
+          {id: 'd2', __draft: true},
+        ],
+      }));
+
+      element._removeComment({id: 'bc2'});
+      assert.equal(JSON.stringify(element._comments), JSON.stringify({
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
         },
-        {
-          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.',
-          ]
-        }
-      ]);
+        left: [
+          {id: 'bc1'},
+          {id: 'bd1', __draft: true},
+          {id: 'bd2', __draft: true},
+        ],
+        right: [
+          {id: 'c1'},
+          {id: 'c2'},
+          {id: 'd1', __draft: true},
+          {id: 'd2', __draft: true},
+        ],
+      }));
+
+      element._removeComment({id: 'd2'});
+      assert.deepEqual(JSON.stringify(element._comments), JSON.stringify({
+        meta: {
+          changeNum: '42',
+          patchRange: {
+            basePatchNum: 'PARENT',
+            patchNum: 3,
+          },
+          path: '/path/to/foo',
+          projectConfig: {foo: 'bar'},
+        },
+        left: [
+          {id: 'bc1'},
+          {id: 'bd1', __draft: true},
+          {id: 'bd2', __draft: true},
+        ],
+        right: [
+          {id: 'c1'},
+          {id: 'c2'},
+          {id: 'd1', __draft: true},
+        ],
+      }));
     });
   });
-
 </script>