Merge "Separates diff processing from diff building"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index 1896c76..feb21e2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -28,9 +28,7 @@
       GrDiffBuilderSideBySide.prototype);
   GrDiffBuilderImage.prototype.constructor = GrDiffBuilderImage;
 
-  GrDiffBuilderImage.prototype.emitDiff = function() {
-    this.emitGroup(this._groups[0]);
-
+  GrDiffBuilderImage.prototype.renderDiffImages = function() {
     var section = this._createElement('tbody', 'image-diff');
 
     this._emitImagePair(section);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index 315692a..3f92e44 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -14,12 +14,14 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
 
 <dom-module id="gr-diff-builder">
   <template>
     <div class="contentWrapper">
       <content></content>
     </div>
+    <gr-diff-processor id="processor"></gr-diff-processor>
   </template>
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff/gr-diff-group.js"></script>
@@ -59,7 +61,11 @@
 
         render: function(diff, comments, prefs) {
           this._builder = this._getDiffBuilder(diff, comments, prefs);
-          this._renderDiff();
+
+          this.$.processor.context = prefs.context;
+          this.$.processor.keyLocations = this._getCommentLocations(comments);
+          this.$.processor.process(diff.content)
+              .then(this._renderDiff.bind(this));
         },
 
         getLineElByChild: function(node) {
@@ -176,7 +182,7 @@
         },
 
         showContext: function(newGroups, sectionEl) {
-          var groups = this._builder._groups;
+          var groups = this._builder.groups;
           // TODO(viktard): Polyfill findIndex for IE10.
           var contextIndex = groups.findIndex(function(group) {
             return group.element == sectionEl;
@@ -207,9 +213,14 @@
           throw Error('Unsupported diff view mode: ' + this.viewMode);
         },
 
-        _renderDiff: function() {
+        _renderDiff: function(groups) {
+          this._builder.groups = groups;
+
           this._clearDiffContent();
           this.emitDiff();
+          if (this.isImageDiff) {
+            this._builder.renderDiffImages();
+          }
           this.async(function() {
             this.fire('render');
           }, 1);
@@ -218,6 +229,23 @@
         _clearDiffContent: function() {
           this.diffElement.innerHTML = null;
         },
+
+        _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;
+        },
       });
     })();
   </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index b96423e..896b60e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -22,10 +22,7 @@
     this._comments = comments;
     this._prefs = prefs;
     this._outputEl = outputEl;
-    this._groups = [];
-
-    this._commentLocations = this._getCommentLocations(comments);
-    this._processContent(diff.content, this._groups, prefs.context);
+    this.groups = [];
   }
 
   GrDiffBuilder.LESS_THAN_CODE = '<'.charCodeAt(0);
@@ -63,8 +60,8 @@
   var PARTIAL_CONTEXT_AMOUNT = 10;
 
   GrDiffBuilder.prototype.emitDiff = function() {
-    for (var i = 0; i < this._groups.length; i++) {
-      this.emitGroup(this._groups[i]);
+    for (var i = 0; i < this.groups.length; i++) {
+      this.emitGroup(this.groups[i]);
     }
   };
 
@@ -79,8 +76,8 @@
   };
 
   GrDiffBuilder.prototype.renderSection = function(element) {
-    for (var i = 0; i < this._groups.length; i++) {
-      var group = this._groups[i];
+    for (var i = 0; i < this.groups.length; i++) {
+      var group = this.groups[i];
       if (group.element === element) {
         var newElement = this.buildSectionElement(group);
         group.element.parentElement.replaceChild(newElement, group.element);
@@ -93,8 +90,8 @@
   GrDiffBuilder.prototype.getGroupsByLineRange = function(
       startLine, endLine, opt_side) {
     var groups = [];
-    for (var i = 0; i < this._groups.length; i++) {
-      var group = this._groups[i];
+    for (var i = 0; i < this.groups.length; i++) {
+      var group = this.groups[i];
       if (group.lines.length === 0) {
         continue;
       }
@@ -139,196 +136,11 @@
         function(group) { return group.element; });
   };
 
-  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 = undefined;
-        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 = undefined;
-        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]);
-        }
-      }
-      // != instead of !== because we want to cover both undefined and null.
-      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;
-  };
-
+  // TODO (wyatta): Move this completely into the processor.
   GrDiffBuilder.prototype._insertContextGroups = function(groups, lines,
       hiddenRange) {
     var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
@@ -350,46 +162,6 @@
     }
   };
 
-  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.contextGroup || !line.contextGroup.lines.length) {
       return null;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index 879d8592..cda4dc8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -18,11 +18,23 @@
 <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/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../gr-diff/gr-diff-line.js"></script>
 <script src="../gr-diff/gr-diff-group.js"></script>
 <script src="gr-diff-builder.js"></script>
 
+<link rel="import" href="gr-diff-builder.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-builder>
+      <table id="diffTable"></table>
+    </gr-diff-builder>
+  </template>
+</test-fixture>
+
+
 <script>
   suite('gr-diff-builder tests', function() {
     var builder;
@@ -36,205 +48,6 @@
       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.instanceOf(groups[1].lines[0].contextGroup, GrDiffGroup);
-      assert.equal(groups[1].lines[0].contextGroup.lines.length, 90);
-      groups[1].lines[0].contextGroup.lines.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.instanceOf(groups[7].lines[0].contextGroup, GrDiffGroup);
-      assert.equal(groups[7].lines[0].contextGroup.lines.length, 90);
-      groups[7].lines[0].contextGroup.lines.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.instanceOf(groups[3].lines[0].contextGroup, GrDiffGroup);
-      assert.equal(groups[3].lines[0].contextGroup.lines.length, 30);
-      groups[3].lines[0].contextGroup.lines.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('context control buttons', function() {
       var section = {};
       var line = {contextGroup: {lines: []}};
@@ -412,153 +225,11 @@
           [{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,
-        }
-      ]);
-    });
-
     suite('rendering', function() {
       var content;
       var outputEl;
 
-      setup(function() {
+      setup(function(done) {
         var prefs = {
           line_length: 10,
           show_tabs: true,
@@ -577,32 +248,38 @@
             ]
           },
         ];
-        outputEl = document.createElement('out');
-        builder =
-            new GrDiffBuilder(
-                {content: content}, {left: [], right: []}, prefs, outputEl);
-        builder.buildSectionElement = function(group) {
-          var section = document.createElement('stub');
-          section.textContent = group.lines.reduce(function(acc, line) {
-            return acc + line.text;
-          }, '');
-          return section;
-        };
-        builder.emitDiff();
+        element = fixture('basic');
+        outputEl = element.queryEffectiveChildren('#diffTable');
+        element.addEventListener('render', function() {
+          done();
+        });
+        sinon.stub(element, '_getDiffBuilder', function() {
+          var builder = new GrDiffBuilder(
+              {content: content}, {left: [], right: []}, prefs, outputEl);
+          builder.buildSectionElement = function(group) {
+            var section = document.createElement('stub');
+            section.textContent = group.lines.reduce(function(acc, line) {
+              return acc + line.text;
+            }, '');
+            return section;
+          };
+          return builder;
+        });
+        element.render({ content: content }, {left: [], right: []}, prefs);
       });
 
       test('renderSection', function() {
         var section = outputEl.querySelector('stub:nth-of-type(2)');
         var prevInnerHTML = section.innerHTML;
         section.innerHTML = 'wiped';
-        builder.renderSection(section);
+        element._builder.renderSection(section);
         section = outputEl.querySelector('stub:nth-of-type(2)');
         assert.equal(section.innerHTML, prevInnerHTML);
       });
 
       test('getSectionsByLineRange one line', function() {
         var section = outputEl.querySelector('stub:nth-of-type(2)');
-        var sections = builder.getSectionsByLineRange(1, 1, 'left');
+        var sections = element._builder.getSectionsByLineRange(1, 1, 'left');
         assert.equal(sections.length, 1);
         assert.strictEqual(sections[0], section);
       });
@@ -612,7 +289,7 @@
           outputEl.querySelector('stub:nth-of-type(2)'),
           outputEl.querySelector('stub:nth-of-type(3)'),
         ];
-        var sections = builder.getSectionsByLineRange(1, 2, 'left');
+        var sections = element._builder.getSectionsByLineRange(1, 2, 'left');
         assert.equal(sections.length, 2);
         assert.strictEqual(sections[0], section[0]);
         assert.strictEqual(sections[1], section[1]);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
new file mode 100644
index 0000000..cae8bad
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
@@ -0,0 +1,23 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-diff-processor">
+  <script src="../gr-diff/gr-diff-line.js"></script>
+  <script src="../gr-diff/gr-diff-group.js"></script>
+  <script src="gr-diff-processor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
new file mode 100644
index 0000000..0d787c1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -0,0 +1,303 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  var WHOLE_FILE = -1;
+
+  var DiffSide = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  var DiffGroupType = {
+    ADDED: 'b',
+    BOTH: 'ab',
+    REMOVED: 'a',
+  };
+
+  var DiffHighlights = {
+    ADDED: 'edit_b',
+    REMOVED: 'edit_a',
+  };
+
+  Polymer({
+    is: 'gr-diff-processor',
+
+    properties: {
+
+      /**
+       * The amount of context around collapsed groups.
+       */
+      context: Number,
+
+      /**
+       * The array of groups output by the processor.
+       */
+      groups: {
+        type: Array,
+        notify: true,
+      },
+
+      /**
+       * Locations that should not be collapsed, including the locations of
+       * comments.
+       */
+      keyLocations: {
+        type: Object,
+        value: function() { return {left: {}, right: {}}; },
+      },
+
+      _content: Object,
+    },
+
+    process: function(content) {
+      return new Promise(function(resolve) {
+        var groups = [];
+        this._processContent(content, groups, this.context);
+        this.groups = groups;
+        resolve(groups);
+      }.bind(this));
+    },
+
+    _processContent: function(content, groups, context) {
+      this._appendFileComments(groups);
+
+      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[DiffGroupType.BOTH] !== undefined) {
+          var rows = group[DiffGroupType.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[DiffGroupType.REMOVED] !== undefined) {
+          var highlights = undefined;
+          if (group[DiffHighlights.REMOVED] !== undefined) {
+            highlights = this._normalizeIntralineHighlights(
+                group[DiffGroupType.REMOVED],
+                group[DiffHighlights.REMOVED]);
+          }
+          this._appendRemovedLines(group[DiffGroupType.REMOVED], lines,
+              lineNums, highlights);
+        }
+
+        if (group[DiffGroupType.ADDED] !== undefined) {
+          var highlights = undefined;
+          if (group[DiffHighlights.ADDED] !== undefined) {
+            highlights = this._normalizeIntralineHighlights(
+              group[DiffGroupType.ADDED],
+              group[DiffHighlights.ADDED]);
+          }
+          this._appendAddedLines(group[DiffGroupType.ADDED], lines,
+              lineNums, highlights);
+        }
+        groups.push(new GrDiffGroup(GrDiffGroup.Type.DELTA, lines));
+      }
+    },
+
+    _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]));
+    },
+
+    /**
+     * 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.
+     */
+    _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.keyLocations[DiffSide.LEFT][leftLineNum] ||
+              this.keyLocations[DiffSide.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]);
+          }
+        }
+        // != instead of !== because we want to cover both undefined and null.
+        if (currentChunk.ab != null && currentChunk.ab.length > 0) {
+          result.push(currentChunk);
+        }
+      }
+      return result;
+    },
+
+    _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);
+      }
+    },
+
+    _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.contextGroup =
+          new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
+      groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
+          [ctxLine]));
+
+      if (linesAfterCtx.length > 0) {
+        groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
+      }
+    },
+
+    /**
+     * 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;
+    },
+
+    _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);
+      }
+    },
+
+    _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);
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
new file mode 100644
index 0000000..6a24d6a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -0,0 +1,406 @@
+<!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-processor test</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-processor.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-processor></gr-diff-processor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-processor tests', function() {
+    var element;
+
+    suite('not logged in', function() {
+
+      setup(function() {
+        element = fixture('basic');
+
+        element.context = 4;
+      });
+
+      test('process loaded content', function(done) {
+        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.',
+            ]
+          },
+        ];
+
+        element.process(content).then(function() {
+          var groups = element.groups;
+
+          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.',
+          ]);
+
+          done();
+        });
+      });
+
+      test('insert context groups', function(done) {
+        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 context = 10;
+        element.context = context;
+
+        element.process(content).then(function() {
+          var groups = element.groups;
+
+          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.instanceOf(groups[1].lines[0].contextGroup, GrDiffGroup);
+          assert.equal(groups[1].lines[0].contextGroup.lines.length, 90);
+          groups[1].lines[0].contextGroup.lines.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.instanceOf(groups[7].lines[0].contextGroup, GrDiffGroup);
+          assert.equal(groups[7].lines[0].contextGroup.lines.length, 90);
+          groups[7].lines[0].contextGroup.lines.forEach(function(l) {
+            assert.equal(l.text, content[4].ab[0]);
+          });
+
+          done();
+        });
+      });
+
+      test('insert context groups', function(done) {
+        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');
+        }
+
+        var context = 10;
+        element.context = context;
+
+        element.process(content).then(function() {
+          var groups = element.groups;
+
+          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.instanceOf(groups[3].lines[0].contextGroup, GrDiffGroup);
+          assert.equal(groups[3].lines[0].contextGroup.lines.length, 30);
+          groups[3].lines[0].contextGroup.lines.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');
+
+          done();
+        });
+      });
+
+      test('break up common diff chunks', function() {
+        element.keyLocations = {
+          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 = element._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 = 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,
+          }
+        ]);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 96b97cc..98f7eef 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -49,6 +49,7 @@
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
     'diff/gr-diff-highlight/gr-diff-highlight_test.html',
     'diff/gr-diff-preferences/gr-diff-preferences_test.html',
+    'diff/gr-diff-processor/gr-diff-processor_test.html',
     'diff/gr-diff-selection/gr-diff-selection_test.html',
     'diff/gr-diff-view/gr-diff-view_test.html',
     'diff/gr-diff/gr-diff-group_test.html',