Refactor directory structure of components

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

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

Change-Id: I51fdc510db75fc1b33f040ca63decbbdfd4d5513
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
new file mode 100644
index 0000000..ce7faae
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
@@ -0,0 +1,54 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-diff-comment/gr-diff-comment.html">
+
+<dom-module id="gr-diff-comment-thread">
+  <template>
+    <style>
+      :host {
+        display: block;
+        white-space: normal;
+      }
+      gr-diff-comment {
+        border-left: 1px solid #ddd;
+      }
+      gr-diff-comment:first-of-type {
+        border-top: 1px solid #ddd;
+      }
+      gr-diff-comment:last-of-type {
+        border-bottom: 1px solid #ddd;
+      }
+    </style>
+    <div id="container">
+      <template id="commentList" is="dom-repeat" items="{{_orderedComments}}" as="comment">
+        <gr-diff-comment
+            comment="{{comment}}"
+            change-num="[[changeNum]]"
+            patch-num="[[patchNum]]"
+            draft="[[comment.__draft]]"
+            show-actions="[[showActions]]"
+            project-config="[[projectConfig]]"
+            on-height-change="_handleCommentHeightChange"
+            on-reply="_handleCommentReply"
+            on-discard="_handleCommentDiscard"
+            on-done="_handleCommentDone"></gr-diff-comment>
+      </template>
+    </div>
+  </template>
+  <script src="gr-diff-comment-thread.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
new file mode 100644
index 0000000..32c8313
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -0,0 +1,214 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-diff-comment-thread',
+
+    /**
+     * Fired when the height of the thread changes.
+     *
+     * @event height-change
+     */
+
+    /**
+     * Fired when the thread should be discarded.
+     *
+     * @event discard
+     */
+
+    properties: {
+      changeNum: String,
+      comments: {
+        type: Array,
+        value: function() { return []; },
+      },
+      patchNum: String,
+      path: String,
+      showActions: Boolean,
+      projectConfig: Object,
+
+      _boundWindowResizeHandler: {
+        type: Function,
+        value: function() { return this._handleWindowResize.bind(this); }
+      },
+      _lastHeight: Number,
+      _orderedComments: Array,
+    },
+
+    get naturalHeight() {
+      return this.$.container.offsetHeight;
+    },
+
+    observers: [
+      '_commentsChanged(comments.splices)',
+    ],
+
+    attached: function() {
+      window.addEventListener('resize', this._boundWindowResizeHandler);
+    },
+
+    detached: function() {
+      window.removeEventListener('resize', this._boundWindowResizeHandler);
+    },
+
+    _handleWindowResize: function(e) {
+      this._heightChanged();
+    },
+
+    _commentsChanged: function(changeRecord) {
+      this._orderedComments = this._sortedComments(this.comments);
+    },
+
+    _sortedComments: function(comments) {
+      comments.sort(function(c1, c2) {
+        var c1Date = c1.__date || util.parseDate(c1.updated);
+        var c2Date = c2.__date || util.parseDate(c2.updated);
+        return c1Date - c2Date;
+      });
+
+      var commentIDToReplies = {};
+      var topLevelComments = [];
+      for (var i = 0; i < comments.length; i++) {
+        var c = comments[i];
+        if (c.in_reply_to) {
+          if (commentIDToReplies[c.in_reply_to] == null) {
+            commentIDToReplies[c.in_reply_to] = [];
+          }
+          commentIDToReplies[c.in_reply_to].push(c);
+        } else {
+          topLevelComments.push(c);
+        }
+      }
+      var results = [];
+      for (var i = 0; i < topLevelComments.length; i++) {
+        this._visitComment(topLevelComments[i], commentIDToReplies, results);
+      }
+      return results;
+    },
+
+    _visitComment: function(parent, commentIDToReplies, results) {
+      results.push(parent);
+
+      var replies = commentIDToReplies[parent.id];
+      if (!replies) { return; }
+      for (var i = 0; i < replies.length; i++) {
+        this._visitComment(replies[i], commentIDToReplies, results);
+      }
+    },
+
+    _handleCommentHeightChange: function(e) {
+      e.stopPropagation();
+      this._heightChanged();
+    },
+
+    _handleCommentReply: function(e) {
+      var comment = e.detail.comment;
+      var quoteStr;
+      if (e.detail.quote) {
+        var msg = comment.message;
+        var quoteStr = msg.split('\n').map(
+            function(line) { return ' > ' + line; }).join('\n') + '\n\n';
+      }
+      var reply =
+          this._newReply(comment.id, comment.line, this.path, quoteStr);
+      this.push('comments', reply);
+
+      // Allow the reply to render in the dom-repeat.
+      this.async(function() {
+        var commentEl = this._commentElWithDraftID(reply.__draftID);
+        commentEl.editing = true;
+        this.async(this._heightChanged.bind(this), 1);
+      }.bind(this), 1);
+    },
+
+    _handleCommentDone: function(e) {
+      var comment = e.detail.comment;
+      var reply = this._newReply(comment.id, comment.line, this.path, 'Done');
+      this.push('comments', reply);
+
+      // Allow the reply to render in the dom-repeat.
+      this.async(function() {
+        var commentEl = this._commentElWithDraftID(reply.__draftID);
+        commentEl.save();
+        this.async(this._heightChanged.bind(this), 1);
+      }.bind(this), 1);
+    },
+
+    _commentElWithDraftID: function(draftID) {
+      var commentEls =
+          Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
+      for (var i = 0; i < commentEls.length; i++) {
+        if (commentEls[i].comment.__draftID == draftID) {
+          return commentEls[i];
+        }
+      }
+      return null;
+    },
+
+    _newReply: function(inReplyTo, line, path, opt_message) {
+      var c = {
+        __draft: true,
+        __draftID: Math.random().toString(36),
+        __date: new Date(),
+        line: line,
+        path: path,
+        in_reply_to: inReplyTo,
+      };
+      if (opt_message != null) {
+        c.message = opt_message;
+      }
+      return c;
+    },
+
+    _handleCommentDiscard: function(e) {
+      // TODO(andybons): In Shadow DOM, the event bubbles up, while in Shady
+      // DOM, it respects the bubbles property.
+      // https://github.com/Polymer/polymer/issues/3226
+      e.stopPropagation();
+      var diffCommentEl = Polymer.dom(e).rootTarget;
+      var idx = this._indexOf(diffCommentEl.comment, this.comments);
+      if (idx == -1) {
+        throw Error('Cannot find comment ' +
+            JSON.stringify(diffCommentEl.comment));
+      }
+      this.splice('comments', idx, 1);
+      if (this.comments.length == 0) {
+        this.fire('discard', null, {bubbles: false});
+        return;
+      }
+      this.async(this._heightChanged.bind(this), 1);
+    },
+
+    _heightChanged: function() {
+      var height = this.$.container.offsetHeight;
+      if (height == this._lastHeight) { return; }
+
+      this.fire('height-change', {height: height}, {bubbles: false});
+      this._lastHeight = height;
+    },
+
+    _indexOf: function(comment, arr) {
+      for (var i = 0; i < arr.length; i++) {
+        var c = arr[i];
+        if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
+            (c.id != null && c.id == comment.id)) {
+          return i;
+        }
+      }
+      return -1;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
new file mode 100644
index 0000000..52ad066
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -0,0 +1,243 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-comment-thread</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-comment-thread.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-comment-thread></gr-diff-comment-thread>
+  </template>
+</test-fixture>
+
+<test-fixture id="withComment">
+  <template>
+    <gr-diff-comment-thread></gr-diff-comment-thread>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-comment-thread tests', function() {
+    var element;
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('comments are sorted correctly', function() {
+      var comments = [
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+        },
+        {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        },
+        {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        },
+        {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        },
+        {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000'
+        },
+        {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 21:00:20.396000000'
+        }
+      ];
+      var results = element._sortedComments(comments);
+      assert.deepEqual(results, [
+        {
+          id: 'sally_to_dr_finklestein',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        },
+        {
+          id: 'dr_finklesteins_response',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'no i will pull a thread and your arm will fall off',
+          updated: '2015-10-31 11:00:20.396000000'
+        },
+        {
+          id: 'sallys_defiance',
+          in_reply_to: 'sally_to_dr_finklestein',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        },
+        {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        },
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+        },
+        {
+          id: 'sallys_mission',
+          message: 'i have to find santa',
+          updated: '2015-12-24 21:00:20.396000000'
+        }
+      ]);
+    });
+  });
+
+  suite('comment action tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('withComment');
+      element.comments = [{
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+      }];
+      flushAsynchronousOperations();
+
+      server = sinon.fakeServer.create();
+      // Eat any requests made by elements in this suite.
+      server.respondWith(
+        'PUT',
+        '/changes/41/1/drafts',
+        [
+          201,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' + JSON.stringify({
+            id: '7afa4931_de3d65bd',
+            path: '/path/to/file.txt',
+            line: 5,
+            in_reply_to: 'baf0414d_60047215',
+            updated: '2015-12-21 02:01:10.850000000',
+            message: 'Done'
+          }),
+        ]
+      );
+
+      server.respondWith(
+        'DELETE',
+        '/changes/41/1/drafts/baf0414d_60047215',
+        [
+          204,
+          {},
+          '',
+        ]
+      );
+    });
+
+    test('reply', function(done) {
+      var commentEl = element.$$('gr-diff-comment');
+      assert.ok(commentEl);
+      commentEl.addEventListener('reply', function() {
+        var drafts = element._orderedComments.filter(function(c) {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 1);
+        assert.notOk(drafts[0].message, 'message should be empty');
+        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        done();
+      });
+      commentEl.fire('reply', {comment: commentEl.comment}, {bubbles: false});
+    });
+
+    test('quote reply', function(done) {
+      var commentEl = element.$$('gr-diff-comment');
+      assert.ok(commentEl);
+      commentEl.addEventListener('reply', function() {
+        var drafts = element._orderedComments.filter(function(c) {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 1);
+        assert.equal(drafts[0].message, ' > is this a crossover episode!?\n\n');
+        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        done();
+      });
+      commentEl.fire('reply', {comment: commentEl.comment, quote: true},
+          {bubbles: false});
+    });
+
+    test('done', function(done) {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      var commentEl = element.$$('gr-diff-comment');
+      assert.ok(commentEl);
+      commentEl.addEventListener('done', function() {
+        server.respond();
+        var drafts = element._orderedComments.filter(function(c) {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 1);
+        assert.equal(drafts[0].message, 'Done');
+        assert.equal(drafts[0].in_reply_to, 'baf0414d_60047215');
+        done();
+      });
+      commentEl.fire('done', {comment: commentEl.comment}, {bubbles: false});
+    });
+
+    test('discard', function(done) {
+      element.changeNum = '42';
+      element.patchNum = '1';
+      element.push('comments', element._newReply(
+        element.comments[0].id,
+        element.comments[0].line,
+        element.comments[0].path,
+        'it’s pronouced jiff, not giff'));
+      flushAsynchronousOperations();
+
+      var draftEl =
+          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
+      assert.ok(draftEl);
+      draftEl.addEventListener('discard', function() {
+        server.respond();
+        var drafts = element.comments.filter(function(c) {
+          return c.__draft == true;
+        });
+        assert.equal(drafts.length, 0);
+        done();
+      });
+      draftEl.fire('discard', null, {bubbles: false});
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
new file mode 100644
index 0000000..ca6815b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -0,0 +1,153 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
+<link rel="import" href="../../shared/gr-request/gr-request.html">
+
+<dom-module id="gr-diff-comment">
+  <template>
+    <style>
+      :host {
+        background-color: #ffd;
+        display: block;
+        --iron-autogrow-textarea: {
+          padding: 2px;
+        };
+      }
+      :host([disabled]) {
+        pointer-events: none;
+      }
+      :host([disabled]) .container {
+        opacity: .5;
+      }
+      .header,
+      .message,
+      .actions {
+        padding: .5em .7em;
+      }
+      .header {
+        display: flex;
+        padding-bottom: 0;
+        font-family: 'Open Sans', sans-serif;
+      }
+      .headerLeft {
+        flex: 1;
+      }
+      .authorName,
+      .draftLabel {
+        font-weight: bold;
+      }
+      .draftLabel {
+        color: #999;
+        display: none;
+      }
+      .date {
+        justify-content: flex-end;
+        margin-left: 5px;
+      }
+      a.date:link,
+      a.date:visited {
+        color: #666;
+      }
+      .actions {
+        display: flex;
+        padding-top: 0;
+      }
+      .action {
+        margin-right: .5em;
+      }
+      .danger {
+        display: flex;
+        flex: 1;
+        justify-content: flex-end;
+      }
+      .editMessage {
+        display: none;
+        margin: .5em .7em;
+        width: calc(100% - 1.4em - 2px);
+      }
+      .danger .action {
+        margin-right: 0;
+      }
+      .container:not(.draft) .actions :not(.reply):not(.quote):not(.done) {
+        display: none;
+      }
+      .draft .reply,
+      .draft .quote,
+      .draft .done {
+        display: none;
+      }
+      .draft .draftLabel {
+        display: inline;
+      }
+      .draft:not(.editing) .save,
+      .draft:not(.editing) .cancel {
+        display: none;
+      }
+      .editing .message,
+      .editing .reply,
+      .editing .quote,
+      .editing .done,
+      .editing .edit {
+        display: none;
+      }
+      .editing .editMessage {
+        background-color: #fff;
+        display: block;
+      }
+    </style>
+    <div class="container" id="container">
+      <div class="header" id="header">
+        <div class="headerLeft">
+          <span class="authorName">[[comment.author.name]]</span>
+          <span class="draftLabel">DRAFT</span>
+        </div>
+        <a class="date" href$="[[_computeLinkToComment(comment)]]" on-tap="_handleLinkTap">
+          <gr-date-formatter date-str="[[comment.updated]]"></gr-date-formatter>
+        </a>
+      </div>
+      <iron-autogrow-textarea
+          id="editTextarea"
+          class="editMessage"
+          disabled="{{disabled}}"
+          rows="4"
+          bind-value="{{_editDraft}}"
+          on-keyup="_handleTextareaKeyup"
+          on-keydown="_handleTextareaKeydown"></iron-autogrow-textarea>
+      <gr-linked-text class="message"
+          pre
+          content="[[comment.message]]"
+          config="[[projectConfig.commentlinks]]"></gr-linked-text>
+      <div class="actions" hidden$="[[!showActions]]">
+        <gr-button class="action reply" on-tap="_handleReply">Reply</gr-button>
+        <gr-button class="action quote" on-tap="_handleQuote">Quote</gr-button>
+        <gr-button class="action done" on-tap="_handleDone">Done</gr-button>
+        <gr-button class="action edit" on-tap="_handleEdit">Edit</gr-button>
+        <gr-button class="action save" on-tap="_handleSave"
+            disabled$="[[_computeSaveDisabled(_editDraft)]]">Save</gr-button>
+        <gr-button class="action cancel" on-tap="_handleCancel" hidden>Cancel</gr-button>
+        <div class="danger">
+          <gr-button class="action discard" on-tap="_handleDiscard">Discard</gr-button>
+        </div>
+      </div>
+    </div>
+  </template>
+  <script src="gr-diff-comment.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
new file mode 100644
index 0000000..ca0bedb
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -0,0 +1,247 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-diff-comment',
+
+    /**
+     * Fired when the height of the comment changes.
+     *
+     * @event height-change
+     */
+
+    /**
+     * Fired when the Reply action is triggered.
+     *
+     * @event reply
+     */
+
+    /**
+     * Fired when the Done action is triggered.
+     *
+     * @event done
+     */
+
+    /**
+     * Fired when this comment is discarded.
+     *
+     * @event discard
+     */
+
+    properties: {
+      changeNum: String,
+      comment: {
+        type: Object,
+        notify: true,
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      draft: {
+        type: Boolean,
+        value: false,
+        observer: '_draftChanged',
+      },
+      editing: {
+        type: Boolean,
+        value: false,
+        observer: '_editingChanged',
+      },
+      patchNum: String,
+      showActions: Boolean,
+      projectConfig: Object,
+
+      _xhrPromise: Object,  // Used for testing.
+      _editDraft: String,
+    },
+
+    ready: function() {
+      this._editDraft = (this.comment && this.comment.message) || '';
+      this.editing = this._editDraft.length == 0;
+    },
+
+    attached: function() {
+      this._heightChanged();
+    },
+
+    save: function() {
+      this.comment.message = this._editDraft;
+      this.disabled = true;
+      var endpoint = this._restEndpoint(this.comment.id);
+      this._send('PUT', endpoint).then(function(req) {
+        this.disabled = false;
+        var comment = req.response;
+        comment.__draft = true;
+        // Maintain the ephemeral draft ID for identification by other
+        // elements.
+        if (this.comment.__draftID) {
+          comment.__draftID = this.comment.__draftID;
+        }
+        this.comment = comment;
+        this.editing = false;
+      }.bind(this)).catch(function(err) {
+        alert('Your draft couldn’t be saved. Check the console and contact ' +
+            'the PolyGerrit team for assistance.');
+        this.disabled = false;
+      }.bind(this));
+    },
+
+    _heightChanged: function() {
+      this.async(function() {
+        this.fire('height-change', {height: this.offsetHeight},
+            {bubbles: false});
+      }.bind(this));
+    },
+
+    _draftChanged: function(draft) {
+      this.$.container.classList.toggle('draft', draft);
+    },
+
+    _editingChanged: function(editing) {
+      this.$.container.classList.toggle('editing', editing);
+      if (editing) {
+        var textarea = this.$.editTextarea.textarea;
+        // Put the cursor at the end always.
+        textarea.selectionStart = textarea.value.length;
+        textarea.selectionEnd = textarea.selectionStart;
+        this.async(function() {
+          textarea.focus();
+        }.bind(this));
+      }
+      if (this.comment && this.comment.id) {
+        this.$$('.cancel').hidden = !editing;
+      }
+      this._heightChanged();
+    },
+
+    _computeLinkToComment: function(comment) {
+      return '#' + comment.line;
+    },
+
+    _computeSaveDisabled: function(draft) {
+      return draft == null || draft.trim() == '';
+    },
+
+    _handleTextareaKeyup: function(e) {
+      // TODO(andybons): This isn't always true, but I can't currently think
+      // of a better metric.
+      this._heightChanged();
+    },
+
+    _handleTextareaKeydown: function(e) {
+      if (e.keyCode == 27) {  // 'esc'
+        this._handleCancel(e);
+      }
+    },
+
+    _handleLinkTap: function(e) {
+      e.preventDefault();
+      var hash = this._computeLinkToComment(this.comment);
+      // Don't add the hash to the window history if it's already there.
+      // Otherwise you mess up expected back button behavior.
+      if (window.location.hash == hash) { return; }
+      // Change the URL but don’t trigger a nav event. Otherwise it will
+      // reload the page.
+      page.show(window.location.pathname + hash, null, false);
+    },
+
+    _handleReply: function(e) {
+      this._preventDefaultAndBlur(e);
+      this.fire('reply', {comment: this.comment}, {bubbles: false});
+    },
+
+    _handleQuote: function(e) {
+      this._preventDefaultAndBlur(e);
+      this.fire('reply', {comment: this.comment, quote: true},
+          {bubbles: false});
+    },
+
+    _handleDone: function(e) {
+      this._preventDefaultAndBlur(e);
+      this.fire('done', {comment: this.comment}, {bubbles: false});
+    },
+
+    _handleEdit: function(e) {
+      this._preventDefaultAndBlur(e);
+      this._editDraft = this.comment.message;
+      this.editing = true;
+    },
+
+    _handleSave: function(e) {
+      this._preventDefaultAndBlur(e);
+      this.save();
+    },
+
+    _handleCancel: function(e) {
+      this._preventDefaultAndBlur(e);
+      if (this.comment.message == null || this.comment.message.length == 0) {
+        this.fire('discard', null, {bubbles: false});
+        return;
+      }
+      this._editDraft = this.comment.message;
+      this.editing = false;
+    },
+
+    _handleDiscard: function(e) {
+      this._preventDefaultAndBlur(e);
+      if (!this.comment.__draft) {
+        throw Error('Cannot discard a non-draft comment.');
+      }
+      this.disabled = true;
+      var commentID = this.comment.id;
+      if (!commentID) {
+        this.fire('discard', null, {bubbles: false});
+        return;
+      }
+      this._send('DELETE', this._restEndpoint(commentID)).then(function(req) {
+        this.fire('discard', null, {bubbles: false});
+      }.bind(this)).catch(function(err) {
+        alert('Your draft couldn’t be deleted. Check the console and ' +
+            'contact the PolyGerrit team for assistance.');
+        this.disabled = false;
+      }.bind(this));
+    },
+
+    _preventDefaultAndBlur: function(e) {
+      e.preventDefault();
+      Polymer.dom(e).rootTarget.blur();
+    },
+
+    _send: function(method, url) {
+      var xhr = document.createElement('gr-request');
+      var opts = {
+        method: method,
+        url: url,
+      };
+      if (method == 'PUT' || method == 'POST') {
+        opts.body = this.comment;
+      }
+      this._xhrPromise = xhr.send(opts);
+      return this._xhrPromise;
+    },
+
+    _restEndpoint: function(id) {
+      var path = '/changes/' + this.changeNum + '/revisions/' +
+          this.patchNum + '/drafts';
+      if (id) {
+        path += '/' + id;
+      }
+      return path;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
new file mode 100644
index 0000000..799dbf2
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -0,0 +1,269 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-comment</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.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-comment.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-comment></gr-diff-comment>
+  </template>
+</test-fixture>
+
+<test-fixture id="draft">
+  <template>
+    <gr-diff-comment draft="true"></gr-diff-comment>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-comment tests', function() {
+    var element;
+    setup(function() {
+      element = fixture('basic');
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com',
+        },
+        id: 'baf0414d_60047215',
+        line: 5,
+        message: 'is this a crossover episode!?',
+        updated: '2015-12-08 19:48:33.843000000',
+      };
+    });
+
+    test('proper event fires on reply', function(done) {
+      element.addEventListener('reply', function(e) {
+        assert.ok(e.detail.comment);
+        done();
+      });
+      MockInteractions.tap(element.$$('.reply'));
+    });
+
+    test('proper event fires on quote', function(done) {
+      element.addEventListener('reply', function(e) {
+        assert.ok(e.detail.comment);
+        assert.isTrue(e.detail.quote);
+        done();
+      });
+      MockInteractions.tap(element.$$('.quote'));
+    });
+
+    test('proper event fires on done', function(done) {
+      element.addEventListener('done', function(e) {
+        done();
+      });
+      MockInteractions.tap(element.$$('.done'));
+    });
+
+    test('clicking on date link does not trigger nav', function() {
+      var showStub = sinon.stub(page, 'show');
+      var dateEl = element.$$('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+      var dest = window.location.pathname + '#5';
+      assert(showStub.lastCall.calledWithExactly(dest, null, false),
+          'Should navigate to ' + dest + ' without triggering nav');
+      showStub.restore();
+    });
+  });
+
+  suite('gr-diff-comment draft tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('draft');
+      element.changeNum = 42;
+      element.patchNum = 1;
+      element.editing = false;
+      element.comment = {
+        __draft: true,
+        __draftID: 'temp_draft_id',
+        path: '/path/to/file',
+        line: 5,
+      };
+
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'PUT',
+        '/changes/42/revisions/1/drafts',
+        [
+          201,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n{' +
+            '"id": "baf0414d_40572e03",' +
+            '"path": "/path/to/file",' +
+            '"line": 5,' +
+            '"updated": "2015-12-08 21:52:36.177000000",' +
+            '"message": "created!"' +
+          '}'
+        ]
+      );
+
+      server.respondWith(
+        'PUT',
+        /\/changes\/42\/revisions\/1\/drafts\/.+/,
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n{' +
+            '"id": "baf0414d_40572e03",' +
+            '"path": "/path/to/file",' +
+            '"line": 5,' +
+            '"updated": "2015-12-08 21:52:36.177000000",' +
+            '"message": "saved!"' +
+          '}'
+        ]
+      );
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    function isVisible(el) {
+      assert.ok(el);
+      return getComputedStyle(el).getPropertyValue('display') != 'none';
+    }
+
+    test('button visibility states', function() {
+      element.showActions = false;
+      assert.isTrue(element.$$('.actions').hasAttribute('hidden'));
+      element.showActions = true;
+      assert.isFalse(element.$$('.actions').hasAttribute('hidden'));
+
+      element.draft = true;
+      assert.isTrue(isVisible(element.$$('.edit')), 'edit is visible');
+      assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
+      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
+      assert.isFalse(isVisible(element.$$('.reply')), 'reply is not visible');
+      assert.isFalse(isVisible(element.$$('.quote')), 'quote is not visible');
+      assert.isFalse(isVisible(element.$$('.done')), 'done is not visible');
+
+      element.editing = true;
+      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
+      assert.isTrue(isVisible(element.$$('.discard')), 'discard is visible');
+      assert.isTrue(isVisible(element.$$('.save')), 'save is visible');
+      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is visible');
+      assert.isFalse(isVisible(element.$$('.reply')), 'reply is not visible');
+      assert.isFalse(isVisible(element.$$('.quote')), 'quote is not visible');
+      assert.isFalse(isVisible(element.$$('.done')), 'done is not visible');
+
+      element.draft = false;
+      element.editing = false;
+      assert.isFalse(isVisible(element.$$('.edit')), 'edit is not visible');
+      assert.isFalse(isVisible(element.$$('.discard')),
+          'discard is not visible');
+      assert.isFalse(isVisible(element.$$('.save')), 'save is not visible');
+      assert.isFalse(isVisible(element.$$('.cancel')), 'cancel is not visible');
+      assert.isTrue(isVisible(element.$$('.reply')), 'reply is visible');
+      assert.isTrue(isVisible(element.$$('.quote')), 'quote is visible');
+      assert.isTrue(isVisible(element.$$('.done')), 'done is visible');
+
+      element.comment.id = 'foo';
+      element.draft = true;
+      element.editing = true;
+      assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
+    });
+
+    test('draft creation/cancelation', function(done) {
+      assert.isFalse(element.editing);
+      MockInteractions.tap(element.$$('.edit'));
+      assert.isTrue(element.editing);
+
+      element._editDraft = '';
+      // Save should be disabled on an empty message.
+      var disabled = element.$$('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+      element._editDraft = '     ';
+      disabled = element.$$('.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+
+      var numDiscardEvents = 0;
+      element.addEventListener('discard', function(e) {
+        numDiscardEvents++;
+        if (numDiscardEvents == 3) {
+          done();
+        }
+      });
+      MockInteractions.tap(element.$$('.cancel'));
+      MockInteractions.tap(element.$$('.discard'));
+      MockInteractions.pressAndReleaseKeyOn(element.$.editTextarea, 27); // esc
+    });
+
+    test('draft saving/editing', function(done) {
+      element.draft = true;
+      MockInteractions.tap(element.$$('.edit'));
+      element._editDraft = 'good news, everyone!';
+      MockInteractions.tap(element.$$('.save'));
+      assert.isTrue(element.disabled,
+          'Element should be disabled when creating draft.');
+
+      server.respond();
+
+      element._xhrPromise.then(function(req) {
+        assert.isFalse(element.disabled,
+            'Element should be enabled when done creating draft.');
+        assert.equal(req.status, 201);
+        assert.equal(req.url, '/changes/42/revisions/1/drafts');
+        assert.equal(req.response.message, 'created!');
+        assert.isFalse(element.editing);
+      }).then(function() {
+        MockInteractions.tap(element.$$('.edit'));
+        element._editDraft = 'You’ll be delivering a package to Chapek 9, a ' +
+            'world where humans are killed on sight.';
+        MockInteractions.tap(element.$$('.save'));
+        assert.isTrue(element.disabled,
+            'Element should be disabled when updating draft.');
+        server.respond();
+
+        element._xhrPromise.then(function(req) {
+          assert.isFalse(element.disabled,
+              'Element should be enabled when done updating draft.');
+          assert.equal(req.status, 200);
+          assert.equal(req.url,
+              '/changes/42/revisions/1/drafts/baf0414d_40572e03');
+          assert.equal(req.response.message, 'saved!');
+          assert.isFalse(element.editing);
+          done();
+        });
+      });
+    });
+
+    test('clicking on date link does not trigger nav', function() {
+      var showStub = sinon.stub(page, 'show');
+      var dateEl = element.$$('.date');
+      assert.ok(dateEl);
+      MockInteractions.tap(dateEl);
+      var dest = window.location.pathname + '#5';
+      assert(showStub.lastCall.calledWithExactly(dest, null, false),
+          'Should navigate to ' + dest + ' without triggering nav');
+      showStub.restore();
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
new file mode 100644
index 0000000..b945a45
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
@@ -0,0 +1,110 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+
+<dom-module id="gr-diff-preferences">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      :host[disabled] {
+        opacity: .5;
+        pointer-events: none;
+      }
+      input,
+      select {
+        font: inherit;
+      }
+      input[type="number"] {
+        width: 4em;
+      }
+      .header,
+      .actions {
+        padding: 1em 1.5em;
+      }
+      .header {
+        border-bottom: 1px solid #ddd;
+        font-weight: bold;
+      }
+      .mainContainer {
+        padding: 1em 0;
+      }
+      .pref {
+        align-items: center;
+        display: flex;
+        padding: .35em 1.5em;
+        width: 20em;
+      }
+      .pref:hover {
+        background-color: #ebf5fb;
+      }
+      .pref label {
+        cursor: pointer;
+        flex: 1;
+      }
+      .actions {
+        border-top: 1px solid #ddd;
+        display: flex;
+        justify-content: space-between;
+      }
+    </style>
+    <div class="header">
+      Diff View Preferences
+    </div>
+    <div class="mainContainer">
+      <div class="pref">
+        <label for="contextSelect">Context</label>
+        <select id="contextSelect" on-change="_handleContextSelectChange">
+          <option value="3">3 lines</option>
+          <option value="10">10 lines</option>
+          <option value="25">25 lines</option>
+          <option value="50">50 lines</option>
+          <option value="75">75 lines</option>
+          <option value="100">100 lines</option>
+          <option value="-1">Whole file</option>
+        </select>
+      </div>
+      <div class="pref">
+        <label for="columnsInput">Columns</label>
+        <input is="iron-input" type="number" id="columnsInput"
+            prevent-invalid-input
+            allowed-pattern="[0-9]"
+            bind-value="{{prefs.line_length}}">
+      </div>
+      <div class="pref">
+        <label for="tabSizeInput">Tab width</label>
+        <input is="iron-input" type="number" id="tabSizeInput"
+            prevent-invalid-input
+            allowed-pattern="[0-9]"
+            bind-value="{{prefs.tab_size}}">
+      </div>
+      <div class="pref">
+        <label for="showTabsInput">Show tabs</label>
+        <input is="iron-input" type="checkbox" id="showTabsInput"
+            on-tap="_handleShowTabsTap">
+      </div>
+    </div>
+    <div class="actions">
+      <gr-button primary on-tap="_handleSave">Save</gr-button>
+      <gr-button on-tap="_handleCancel">Cancel</gr-button>
+    </div>
+  </template>
+  <script src="gr-diff-preferences.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
new file mode 100644
index 0000000..70d176e
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
@@ -0,0 +1,72 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-diff-preferences',
+
+    /**
+     * Fired when the user presses the save button.
+     *
+     * @event save
+     */
+
+    /**
+     * Fired when the user presses the cancel button.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      prefs: {
+        type: Object,
+        notify: true,
+        value: function() { return {}; },
+      },
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+    },
+
+    observers: [
+      '_prefsChanged(prefs.*)',
+    ],
+
+    _prefsChanged: function(changeRecord) {
+      var prefs = changeRecord.base;
+      this.$.contextSelect.value = prefs.context;
+      this.$.showTabsInput.checked = prefs.show_tabs;
+    },
+
+    _handleContextSelectChange: function(e) {
+      var selectEl = Polymer.dom(e).rootTarget;
+      this.set('prefs.context', parseInt(selectEl.value, 10));
+    },
+
+    _handleShowTabsTap: function(e) {
+      this.set('prefs.show_tabs', Polymer.dom(e).rootTarget.checked);
+    },
+
+    _handleSave: function() {
+      this.fire('save', null, {bubbles: false});
+    },
+
+    _handleCancel: function() {
+      this.fire('cancel', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
new file mode 100644
index 0000000..2d86a05
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
@@ -0,0 +1,75 @@
+<!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-preferences</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-preferences.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-preferences></gr-diff-preferences>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-preferences tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('model changes', function() {
+      element.prefs = {
+        context: 10,
+        line_length: 100,
+        show_tabs: true,
+        tab_size: 8,
+      };
+
+      element.$.contextSelect.value = '50';
+      element.fire('change', {}, {node: element.$.contextSelect});
+      element.$.columnsInput.bindValue = 80;
+      element.$.tabSizeInput.bindValue = 4;
+      MockInteractions.tap(element.$.showTabsInput);
+
+      assert.equal(element.prefs.context, 50);
+      assert.equal(element.prefs.line_length, 80);
+      assert.equal(element.prefs.tab_size, 4);
+      assert.isFalse(element.prefs.show_tabs);
+    });
+
+    test('events', function(done) {
+      var savePromise = new Promise(function(resolve) {
+        element.addEventListener('save', function() { resolve(); });
+      });
+      var cancelPromise = new Promise(function(resolve) {
+        element.addEventListener('cancel', function() { resolve(); });
+      });
+      Promise.all([savePromise, cancelPromise]).then(function() {
+        done();
+      });
+      MockInteractions.tap(element.$$('gr-button[primary]'));
+      MockInteractions.tap(element.$$('gr-button:not([primary])'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-side/gr-diff-side.html b/polygerrit-ui/app/elements/diff/gr-diff-side/gr-diff-side.html
new file mode 100644
index 0000000..972dc2d
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-side/gr-diff-side.html
@@ -0,0 +1,97 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
+
+<dom-module id="gr-diff-side">
+  <template>
+    <style>
+      :host,
+      .container {
+        display: flex;
+        flex: 0 0 auto;
+      }
+      .lineNum:before,
+      .code:before {
+        /* To ensure the height is non-zero in these elements, a
+           zero-width space is set as its content. The character
+           itself doesn't matter. Just that there is something
+           there. */
+        content: '\200B';
+      }
+      .lineNum {
+        background-color: #eee;
+        color: #666;
+        padding: 0 .75em;
+        text-align: right;
+      }
+      .canComment .lineNum {
+        cursor: pointer;
+        text-decoration: underline;
+      }
+      .canComment .lineNum:hover {
+        background-color: #ccc;
+      }
+      .lightHighlight {
+        background-color: var(--light-highlight-color);
+      }
+      hl,
+      .darkHighlight {
+        background-color: var(--dark-highlight-color);
+      }
+      .br:after {
+        /* Line feed */
+        content: '\A';
+      }
+      .tab {
+        display: inline-block;
+      }
+      .tab.withIndicator:before {
+        color: #C62828;
+        /* >> character */
+        content: '\00BB';
+      }
+      .numbers,
+      .content {
+        white-space: pre;
+      }
+      .numbers .filler {
+        background-color: #eee;
+      }
+      .contextControl {
+        background-color: #fef;
+      }
+      .contextControl a:link,
+      .contextControl a:visited {
+        display: block;
+        text-decoration: none;
+      }
+      .numbers .contextControl {
+        padding: 0 .75em;
+        text-align: right;
+      }
+      .content .contextControl {
+        text-align: center;
+      }
+    </style>
+    <div class$="[[_computeContainerClass(canComment)]]">
+      <div class="numbers" id="numbers"></div>
+      <div class="content" id="content"></div>
+    </div>
+  </template>
+  <script src="gr-diff-side.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-side/gr-diff-side.js b/polygerrit-ui/app/elements/diff/gr-diff-side/gr-diff-side.js
new file mode 100644
index 0000000..518da3e
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-side/gr-diff-side.js
@@ -0,0 +1,613 @@
+// 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 CharCode = {
+    LESS_THAN: '<'.charCodeAt(0),
+    GREATER_THAN: '>'.charCodeAt(0),
+    AMPERSAND: '&'.charCodeAt(0),
+    SEMICOLON: ';'.charCodeAt(0),
+  };
+
+  var TAB_REGEX = /\t/g;
+
+  Polymer({
+    is: 'gr-diff-side',
+
+    /**
+     * Fired when an expand context control is clicked.
+     *
+     * @event expand-context
+     */
+
+    /**
+     * Fired when a thread's height is changed.
+     *
+     * @event thread-height-change
+     */
+
+    /**
+     * Fired when a draft should be added.
+     *
+     * @event add-draft
+     */
+
+    /**
+     * Fired when a thread is removed.
+     *
+     * @event remove-thread
+     */
+
+    properties: {
+      canComment: {
+        type: Boolean,
+        value: false,
+      },
+      content: {
+        type: Array,
+        notify: true,
+        observer: '_contentChanged',
+      },
+      prefs: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      changeNum: String,
+      patchNum: String,
+      path: String,
+      projectConfig: {
+        type: Object,
+        observer: '_projectConfigChanged',
+      },
+
+      _lineFeedHTML: {
+        type: String,
+        value: '<span class="style-scope gr-diff-side br"></span>',
+        readOnly: true,
+      },
+      _highlightStartTag: {
+        type: String,
+        value: '<hl class="style-scope gr-diff-side">',
+        readOnly: true,
+      },
+      _highlightEndTag: {
+        type: String,
+        value: '</hl>',
+        readOnly: true,
+      },
+      _diffChunkLineNums: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _commentThreadLineNums: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _focusedLineNum: {
+        type: Number,
+        value: 1,
+      },
+    },
+
+    listeners: {
+      'tap': '_tapHandler',
+    },
+
+    observers: [
+      '_prefsChanged(prefs.*)',
+    ],
+
+    rowInserted: function(index) {
+      this.renderLineIndexRange(index, index);
+      this._updateDOMIndices();
+      this._updateJumpIndices();
+    },
+
+    rowRemoved: function(index) {
+      var removedEls = Polymer.dom(this.root).querySelectorAll(
+          '[data-index="' + index + '"]');
+      for (var i = 0; i < removedEls.length; i++) {
+        removedEls[i].parentNode.removeChild(removedEls[i]);
+      }
+      this._updateDOMIndices();
+      this._updateJumpIndices();
+    },
+
+    rowUpdated: function(index) {
+      var removedEls = Polymer.dom(this.root).querySelectorAll(
+          '[data-index="' + index + '"]');
+      for (var i = 0; i < removedEls.length; i++) {
+        removedEls[i].parentNode.removeChild(removedEls[i]);
+      }
+      this.renderLineIndexRange(index, index);
+    },
+
+    scrollToLine: function(lineNum) {
+      if (isNaN(lineNum) || lineNum < 1) { return; }
+
+      var el = this.$$('.numbers .lineNum[data-line-num="' + lineNum + '"]');
+      if (!el) { return; }
+
+      // Calculate where the line 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);
+    },
+
+    scrollToNextDiffChunk: function() {
+      this._scrollToNextChunkOrThread(this._diffChunkLineNums);
+    },
+
+    scrollToPreviousDiffChunk: function() {
+      this._scrollToPreviousChunkOrThread(this._diffChunkLineNums);
+    },
+
+    scrollToNextCommentThread: function() {
+      this._scrollToNextChunkOrThread(this._commentThreadLineNums);
+    },
+
+    scrollToPreviousCommentThread: function() {
+      this._scrollToPreviousChunkOrThread(this._commentThreadLineNums);
+    },
+
+    renderLineIndexRange: function(startIndex, endIndex) {
+      this._render(this.content, startIndex, endIndex);
+    },
+
+    hideElementsWithIndex: function(index) {
+      var els = Polymer.dom(this.root).querySelectorAll(
+          '[data-index="' + index + '"]');
+      for (var i = 0; i < els.length; i++) {
+        els[i].setAttribute('hidden', true);
+      }
+    },
+
+    getRowHeight: function(index) {
+      var row = this.content[index];
+      // Filler elements should not be taken into account when determining
+      // height calculations.
+      if (row.type == 'FILLER') {
+        return 0;
+      }
+      if (row.height != null) {
+        return row.height;
+      }
+
+      var selector = '[data-index="' + index + '"]';
+      var els = Polymer.dom(this.root).querySelectorAll(selector);
+      if (els.length != 2) {
+        throw Error('Rows should only consist of two elements');
+      }
+      return Math.max(els[0].offsetHeight, els[1].offsetHeight);
+    },
+
+    getRowNaturalHeight: function(index) {
+      var contentEl = this.$$('.content [data-index="' + index + '"]');
+      return contentEl.naturalHeight || contentEl.offsetHeight;
+    },
+
+    setRowNaturalHeight: function(index) {
+      var lineEl = this.$$('.numbers [data-index="' + index + '"]');
+      var contentEl = this.$$('.content [data-index="' + index + '"]');
+      contentEl.style.height = null;
+      var height = contentEl.offsetHeight;
+      lineEl.style.height = height + 'px';
+      this.content[index].height = height;
+      return height;
+    },
+
+    setRowHeight: function(index, height) {
+      var selector = '[data-index="' + index + '"]';
+      var els = Polymer.dom(this.root).querySelectorAll(selector);
+      for (var i = 0; i < els.length; i++) {
+        els[i].style.height = height + 'px';
+      }
+      this.content[index].height = height;
+    },
+
+    _scrollToNextChunkOrThread: function(lineNums) {
+      for (var i = 0; i < lineNums.length; i++) {
+        if (lineNums[i] > this._focusedLineNum) {
+          this._focusedLineNum = lineNums[i];
+          this.scrollToLine(this._focusedLineNum);
+          return;
+        }
+      }
+    },
+
+    _scrollToPreviousChunkOrThread: function(lineNums) {
+      for (var i = lineNums.length - 1; i >= 0; i--) {
+        if (this._focusedLineNum > lineNums[i]) {
+          this._focusedLineNum = lineNums[i];
+          this.scrollToLine(this._focusedLineNum);
+          return;
+        }
+      }
+    },
+
+    _updateJumpIndices: function() {
+      this._commentThreadLineNums = [];
+      this._diffChunkLineNums = [];
+      var inHighlight = false;
+      for (var i = 0; i < this.content.length; i++) {
+        switch (this.content[i].type) {
+          case 'COMMENT_THREAD':
+            this._commentThreadLineNums.push(
+                this.content[i].comments[0].line);
+            break;
+          case 'CODE':
+            // Only grab the first line of the highlighted chunk.
+            if (!inHighlight && this.content[i].highlight) {
+              this._diffChunkLineNums.push(this.content[i].lineNum);
+              inHighlight = true;
+            } else if (!this.content[i].highlight) {
+              inHighlight = false;
+            }
+            break;
+        }
+      }
+    },
+
+    _updateDOMIndices: function() {
+      // There is no way to select elements with a data-index greater than a
+      // given value. For now, just update all DOM elements.
+      var lineEls = Polymer.dom(this.root).querySelectorAll(
+          '.numbers [data-index]');
+      var contentEls = Polymer.dom(this.root).querySelectorAll(
+          '.content [data-index]');
+      if (lineEls.length != contentEls.length) {
+        throw Error(
+            'There must be the same number of line and content elements');
+      }
+      var index = 0;
+      for (var i = 0; i < this.content.length; i++) {
+        if (this.content[i].hidden) { continue; }
+
+        lineEls[index].setAttribute('data-index', i);
+        contentEls[index].setAttribute('data-index', i);
+        index++;
+      }
+    },
+
+    _prefsChanged: function(changeRecord) {
+      var prefs = changeRecord.base;
+      this.$.content.style.width = prefs.line_length + 'ch';
+    },
+
+    _projectConfigChanged: function(projectConfig) {
+      var threadEls =
+          Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
+      for (var i = 0; i < threadEls.length; i++) {
+        threadEls[i].projectConfig = projectConfig;
+      }
+    },
+
+    _contentChanged: function(diff) {
+      this._clearChildren(this.$.numbers);
+      this._clearChildren(this.$.content);
+      this._render(diff, 0, diff.length - 1);
+      this._updateJumpIndices();
+    },
+
+    _computeContainerClass: function(canComment) {
+      return 'container' + (canComment ? ' canComment' : '');
+    },
+
+    _tapHandler: function(e) {
+      var lineEl = Polymer.dom(e).rootTarget;
+      if (!this.canComment || !lineEl.classList.contains('lineNum')) {
+        return;
+      }
+
+      e.preventDefault();
+      var index = parseInt(lineEl.getAttribute('data-index'), 10);
+      var line = parseInt(lineEl.getAttribute('data-line-num'), 10);
+      this.fire('add-draft', {
+        index: index,
+        line: line
+      }, {bubbles: false});
+    },
+
+    _clearChildren: function(el) {
+      while (el.firstChild) {
+        el.removeChild(el.firstChild);
+      }
+    },
+
+    _handleContextControlClick: function(context, e) {
+      e.preventDefault();
+      this.fire('expand-context', {context: context}, {bubbles: false});
+    },
+
+    _render: function(diff, startIndex, endIndex) {
+      var beforeLineEl;
+      var beforeContentEl;
+      if (endIndex != diff.length - 1) {
+        beforeLineEl = this.$$('.numbers [data-index="' + endIndex + '"]');
+        beforeContentEl = this.$$('.content [data-index="' + endIndex + '"]');
+        if (!beforeLineEl && !beforeContentEl) {
+          // `endIndex` may be present within the model, but not in the DOM.
+          // Insert it before its successive element.
+          beforeLineEl = this.$$(
+              '.numbers [data-index="' + (endIndex + 1) + '"]');
+          beforeContentEl = this.$$(
+              '.content [data-index="' + (endIndex + 1) + '"]');
+        }
+      }
+
+      for (var i = startIndex; i <= endIndex; i++) {
+        if (diff[i].hidden) { continue; }
+
+        switch (diff[i].type) {
+          case 'CODE':
+            this._renderCode(diff[i], i, beforeLineEl, beforeContentEl);
+            break;
+          case 'FILLER':
+            this._renderFiller(diff[i], i, beforeLineEl, beforeContentEl);
+            break;
+          case 'CONTEXT_CONTROL':
+            this._renderContextControl(diff[i], i, beforeLineEl,
+                beforeContentEl);
+            break;
+          case 'COMMENT_THREAD':
+            this._renderCommentThread(diff[i], i, beforeLineEl,
+                beforeContentEl);
+            break;
+        }
+      }
+    },
+
+    _handleCommentThreadHeightChange: function(e) {
+      var threadEl = Polymer.dom(e).rootTarget;
+      var index = parseInt(threadEl.getAttribute('data-index'), 10);
+      this.content[index].height = e.detail.height;
+      var lineEl = this.$$('.numbers [data-index="' + index + '"]');
+      lineEl.style.height = e.detail.height + 'px';
+      this.fire('thread-height-change', {
+        index: index,
+        height: e.detail.height,
+      }, {bubbles: false});
+    },
+
+    _handleCommentThreadDiscard: function(e) {
+      var threadEl = Polymer.dom(e).rootTarget;
+      var index = parseInt(threadEl.getAttribute('data-index'), 10);
+      this.fire('remove-thread', {index: index}, {bubbles: false});
+    },
+
+    _renderCommentThread: function(thread, index, beforeLineEl,
+        beforeContentEl) {
+      var lineEl = this._createElement('div', 'commentThread');
+      lineEl.classList.add('filler');
+      lineEl.setAttribute('data-index', index);
+      var threadEl = document.createElement('gr-diff-comment-thread');
+      threadEl.addEventListener('height-change',
+          this._handleCommentThreadHeightChange.bind(this));
+      threadEl.addEventListener('discard',
+          this._handleCommentThreadDiscard.bind(this));
+      threadEl.setAttribute('data-index', index);
+      threadEl.changeNum = this.changeNum;
+      threadEl.patchNum = thread.patchNum || this.patchNum;
+      threadEl.path = this.path;
+      threadEl.comments = thread.comments;
+      threadEl.showActions = this.canComment;
+      threadEl.projectConfig = this.projectConfig;
+
+      this.$.numbers.insertBefore(lineEl, beforeLineEl);
+      this.$.content.insertBefore(threadEl, beforeContentEl);
+    },
+
+    _renderContextControl: function(control, index, beforeLineEl,
+        beforeContentEl) {
+      var lineEl = this._createElement('div', 'contextControl');
+      lineEl.setAttribute('data-index', index);
+      lineEl.textContent = '@@';
+      var contentEl = this._createElement('div', 'contextControl');
+      contentEl.setAttribute('data-index', index);
+      var a = this._createElement('a');
+      a.href = '#';
+      a.textContent = 'Show ' + control.numLines + ' common ' +
+          (control.numLines == 1 ? 'line' : 'lines') + '...';
+      a.addEventListener('click',
+          this._handleContextControlClick.bind(this, control));
+      contentEl.appendChild(a);
+
+      this.$.numbers.insertBefore(lineEl, beforeLineEl);
+      this.$.content.insertBefore(contentEl, beforeContentEl);
+    },
+
+    _renderFiller: function(filler, index, beforeLineEl, beforeContentEl) {
+      var lineFillerEl = this._createElement('div', 'filler');
+      lineFillerEl.setAttribute('data-index', index);
+      var fillerEl = this._createElement('div', 'filler');
+      fillerEl.setAttribute('data-index', index);
+      var numLines = filler.numLines || 1;
+
+      lineFillerEl.textContent = '\n'.repeat(numLines);
+      for (var i = 0; i < numLines; i++) {
+        var newlineEl = this._createElement('span', 'br');
+        fillerEl.appendChild(newlineEl);
+      }
+
+      this.$.numbers.insertBefore(lineFillerEl, beforeLineEl);
+      this.$.content.insertBefore(fillerEl, beforeContentEl);
+    },
+
+    _renderCode: function(code, index, beforeLineEl, beforeContentEl) {
+      var lineNumEl = this._createElement('div', 'lineNum');
+      lineNumEl.setAttribute('data-line-num', code.lineNum);
+      lineNumEl.setAttribute('data-index', index);
+      var numLines = code.numLines || 1;
+      lineNumEl.textContent = code.lineNum + '\n'.repeat(numLines);
+
+      var contentEl = this._createElement('div', 'code');
+      contentEl.setAttribute('data-line-num', code.lineNum);
+      contentEl.setAttribute('data-index', index);
+
+      if (code.highlight) {
+        contentEl.classList.add(code.intraline.length > 0 ?
+            'lightHighlight' : 'darkHighlight');
+      }
+
+      var html = util.escapeHTML(code.content);
+      if (code.highlight && code.intraline.length > 0) {
+        html = this._addIntralineHighlights(code.content, html,
+            code.intraline);
+      }
+      if (numLines > 1) {
+        html = this._addNewLines(code.content, html, numLines);
+      }
+      html = this._addTabWrappers(code.content, 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 (code.content == html) {
+        contentEl.textContent = code.content;
+      } else {
+        contentEl.innerHTML = html;
+      }
+
+      this.$.numbers.insertBefore(lineNumEl, beforeLineEl);
+      this.$.content.insertBefore(contentEl, beforeContentEl);
+    },
+
+    // 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 ('<').
+    _advanceChar: function(html, index) {
+      // Any tags don't count as characters
+      while (index < html.length &&
+             html.charCodeAt(index) == CharCode.LESS_THAN) {
+        while (index < html.length &&
+               html.charCodeAt(index) != CharCode.GREATER_THAN) {
+          index++;
+        }
+        index++;  // skip the ">" itself
+      }
+      // An HTML entity (e.g., &lt;) counts as one character.
+      if (index < html.length &&
+          html.charCodeAt(index) == CharCode.AMPERSAND) {
+        while (index < html.length &&
+               html.charCodeAt(index) != CharCode.SEMICOLON) {
+          index++;
+        }
+      }
+      return index + 1;
+    },
+
+    _addIntralineHighlights: function(content, html, highlights) {
+      var startTag = this._highlightStartTag;
+      var endTag = this._highlightEndTag;
+
+      for (var i = 0; i < highlights.length; i++) {
+        var hl = highlights[i];
+
+        var htmlStartIndex = 0;
+        for (var j = 0; j < hl.startIndex; j++) {
+          htmlStartIndex = this._advanceChar(html, htmlStartIndex);
+        }
+
+        var htmlEndIndex = 0;
+        if (hl.endIndex != null) {
+          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) + startTag +
+                html.slice(htmlStartIndex, htmlEndIndex) + endTag +
+                html.slice(htmlEndIndex);
+        }
+      }
+      return html;
+    },
+
+    _addNewLines: function(content, html, numLines) {
+      var htmlIndex = 0;
+      var indices = [];
+      var numChars = 0;
+      for (var i = 0; i < content.length; i++) {
+        if (numChars > 0 && numChars % this.prefs.line_length == 0) {
+          indices.push(htmlIndex);
+        }
+        htmlIndex = this._advanceChar(html, htmlIndex);
+        if (content[i] == '\t') {
+          numChars += this.prefs.tab_size;
+        } else {
+          numChars++;
+        }
+      }
+      var result = html;
+      var linesLeft = numLines;
+      // 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]) + this._lineFeedHTML +
+            result.slice(indices[i]);
+        linesLeft--;
+      }
+      // numLines is the total number of lines this code block should take up.
+      // Fill in the remaining ones.
+      for (var i = 0; i < linesLeft; i++) {
+        result += this._lineFeedHTML;
+      }
+      return result;
+    },
+
+    _addTabWrappers: function(content, html) {
+      // TODO(andybons): CSS tab-size is not supported in IE.
+      // Force this to be a number to prevent arbitrary injection.
+      var tabSize = +this.prefs.tab_size;
+      var htmlStr = '<span class="style-scope gr-diff-side tab ' +
+          (this.prefs.show_tabs ? 'withIndicator" ' : '" ') +
+          'style="tab-size:' + tabSize + ';' +
+          '-moz-tab-size:' + tabSize + ';">\t</span>';
+      return html.replace(TAB_REGEX, htmlStr);
+    },
+
+    _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-side');
+      if (!!className) {
+        el.classList.add(className);
+      }
+      return el;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-side/gr-diff-side_test.html b/polygerrit-ui/app/elements/diff/gr-diff-side/gr-diff-side_test.html
new file mode 100644
index 0000000..85a1011
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-side/gr-diff-side_test.html
@@ -0,0 +1,300 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-side</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-side.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-side></gr-diff-side>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-side tests', function() {
+    var element;
+
+    function isVisibleInWindow(el) {
+      var rect = el.getBoundingClientRect();
+      return rect.top >= 0 && rect.left >= 0 &&
+          rect.bottom <= window.innerHeight && rect.right <= window.innerWidth;
+    }
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('comments', function() {
+      assert.isFalse(element.$$('.container').classList.contains('canComment'));
+      element.canComment = true;
+      assert.isTrue(element.$$('.container').classList.contains('canComment'));
+      // TODO(andybons): Check for comment creation events firing/not firing
+      // when implemented.
+    });
+
+    test('scroll to line', function() {
+      var content = [];
+      for (var i = 0; i < 300; i++) {
+        content.push({
+          type: 'CODE',
+          content: 'All work and no play makes Jack a dull boy',
+          numLines: 1,
+          lineNum: i + 1,
+          highlight: false,
+          intraline: [],
+        });
+      }
+      element.content = content;
+
+      window.scrollTo(0, 0);
+      element.scrollToLine(-12849);
+      assert.equal(window.scrollY, 0);
+      element.scrollToLine('sup');
+      assert.equal(window.scrollY, 0);
+      var lineEl = element.$$('.numbers .lineNum[data-line-num="150"]');
+      assert.ok(lineEl);
+      element.scrollToLine(150);
+      assert.isAbove(window.scrollY, 0);
+      assert.isTrue(isVisibleInWindow(lineEl), 'element should be visible');
+    });
+
+    test('intraline highlights', function() {
+      var content = '        <gr-linked-text content="' +
+          '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>';
+      var html = util.escapeHTML(content);
+      var highlights = [
+        {startIndex: 0, endIndex: 33},
+        {startIndex: 75},
+      ];
+      assert.equal(
+          content.slice(highlights[0].startIndex, highlights[0].endIndex),
+          '        <gr-linked-text content="');
+      assert.equal(content.slice(highlights[1].startIndex),
+          '"></gr-linked-text>');
+      var result = element._addIntralineHighlights(content, html, highlights);
+      var expected = element._highlightStartTag +
+          '        &lt;gr-linked-text content=&quot;' +
+          element._highlightEndTag +
+          '[[_computeCurrentRevisionMessage(change)]]' +
+          element._highlightStartTag +
+          '&quot;&gt;&lt;&#x2F;gr-linked-text&gt;' +
+          element._highlightEndTag;
+      assert.equal(result, expected);
+    });
+
+    test('newlines', function() {
+      element.prefs = {
+        line_length: 80,
+        tab_size: 4,
+      };
+
+      element.content = [{
+        type: 'CODE',
+        content: 'A'.repeat(50),
+        numLines: 1,
+        lineNum: 1,
+        highlight: false,
+        intraline: [],
+      }];
+
+      var lineEl = element.$$('.numbers .lineNum[data-line-num="1"]');
+      assert.ok(lineEl);
+      var contentEl = element.$$('.content .code[data-line-num="1"]');
+      assert.ok(contentEl);
+      assert.equal(contentEl.innerHTML, 'A'.repeat(50));
+
+      element.content = [{
+        type: 'CODE',
+        content: 'A'.repeat(100),
+        numLines: 2,
+        lineNum: 1,
+        highlight: false,
+        intraline: [],
+      }];
+
+      lineEl = element.$$('.numbers .lineNum[data-line-num="1"]');
+      assert.ok(lineEl);
+      contentEl = element.$$('.content .code[data-line-num="1"]');
+      assert.ok(contentEl);
+      assert.equal(contentEl.innerHTML,
+          'A'.repeat(80) + element._lineFeedHTML +
+          'A'.repeat(20) + element._lineFeedHTML);
+    });
+
+    test('tabs', function(done) {
+      element.prefs = {
+        line_length: 80,
+        tab_size: 4,
+        show_tabs: true,
+      };
+
+      element.content = [{
+        type: 'CODE',
+        content: 'A'.repeat(50) + '\t' + 'A'.repeat(50),
+        numLines: 2,
+        lineNum: 1,
+        highlight: false,
+        intraline: [],
+      }];
+
+      var lineEl = element.$$('.numbers .lineNum[data-line-num="1"]');
+      assert.ok(lineEl);
+      var contentEl = element.$$('.content .code[data-line-num="1"]');
+      assert.ok(contentEl);
+      var spanEl = contentEl.childNodes[1];
+      assert.equal(spanEl.tagName, 'SPAN');
+      assert.isTrue(spanEl.classList.contains(
+          'style-scope', 'gr-diff-side', 'tab', 'withIndicator'));
+
+      element.prefs.show_tabs = false;
+      element.content = [{
+        type: 'CODE',
+        content: 'A'.repeat(50) + '\t' + 'A'.repeat(50),
+        numLines: 2,
+        lineNum: 1,
+        highlight: false,
+        intraline: [],
+      }];
+      contentEl = element.$$('.content .code[data-line-num="1"]');
+      assert.ok(contentEl);
+      spanEl = contentEl.childNodes[1];
+      assert.equal(spanEl.tagName, 'SPAN');
+      assert.isTrue(spanEl.classList.contains(
+          'style-scope', 'gr-diff-side', 'tab'));
+
+      var alertStub = sinon.stub(window, 'alert');
+      element.prefs.tab_size =
+          '"><img src="/" onerror="alert(1);"><span class="';
+      element.content = [{
+        type: 'CODE',
+        content: '\t',
+        numLines: 1,
+        lineNum: 1,
+        highlight: false,
+        intraline: [],
+      }];
+      element.async(function() {
+        assert.isFalse(alertStub.called);
+        alertStub.restore();
+        done();
+      }, 100);  // Allow some time for the img error event to fire.
+    });
+
+    test('diff context', function() {
+      var content = [
+        {type: 'CODE', hidden: true, content: '<!DOCTYPE html>'},
+        {type: 'CODE', hidden: true, content: '<meta charset="utf-8">'},
+        {type: 'CODE', hidden: true, content: '<title>My great page</title>'},
+        {type: 'CODE', hidden: true, content: '<style>'},
+        {type: 'CODE', hidden: true, content: '  *,'},
+        {type: 'CODE', hidden: true, content: '  *:before,'},
+        {type: 'CODE', hidden: true, content: '  *:after {'},
+        {type: 'CODE', hidden: true, content: '    box-sizing: border-box;'},
+        {type: 'CONTEXT_CONTROL', numLines: 8, start: 0, end: 8},
+        {type: 'CODE', hidden: false, content: '  }'},
+      ];
+      element.content = content;
+
+      // Only the context elements and the following code line elements should
+      // be present in the DOM.
+      var contextEls =
+          Polymer.dom(element.root).querySelectorAll('.contextControl');
+      assert.equal(contextEls.length, 2);
+      var codeLineEls =
+          Polymer.dom(element.root).querySelectorAll('.lineNum, .code');
+      assert.equal(codeLineEls.length, 2);
+
+      for (var i = 0; i <= 8; i++) {
+        element.content[i].hidden = false;
+      }
+      element.renderLineIndexRange(0, 8);
+      element.hideElementsWithIndex(8);
+
+      contextEls =
+          Polymer.dom(element.root).querySelectorAll('.contextControl');
+      for (var i = 0; i < contextEls.length; i++) {
+        assert.isTrue(contextEls[i].hasAttribute('hidden'));
+      }
+
+      codeLineEls =
+          Polymer.dom(element.root).querySelectorAll('.lineNum, .code');
+
+      // Nine lines should now be present in the DOM.
+      assert.equal(codeLineEls.length, 9 * 2);
+    });
+
+    test('tap line to add a draft', function() {
+      var numAddDraftEvents = 0;
+      sinon.stub(element, 'fire', function(eventName) {
+        if (eventName == 'add-draft') {
+          numAddDraftEvents++;
+        }
+      });
+      element.content = [{type: 'CODE', content: '<!DOCTYPE html>'}];
+      element.canComment = false;
+      flushAsynchronousOperations();
+
+      var lineEl = element.$$('.lineNum');
+      assert.ok(lineEl);
+      MockInteractions.tap(lineEl);
+      assert.equal(numAddDraftEvents, 0);
+
+      element.canComment = true;
+      MockInteractions.tap(lineEl);
+      assert.equal(numAddDraftEvents, 1);
+    });
+
+    test('jump to diff chunk/thread', function() {
+      element.content = [
+        {type: 'CODE', content: '', intraline: [], lineNum: 1, highlight: true},
+        {type: 'CODE', content: '', intraline: [], lineNum: 2, highlight: true},
+        {type: 'CODE', content: '', intraline: [], lineNum: 3 },
+        {type: 'CODE', content: '', intraline: [], lineNum: 4 },
+        {type: 'COMMENT_THREAD', comments: [ { line: 4 }]},
+        {type: 'CODE', content: '', intraline: [], lineNum: 5 },
+        {type: 'CODE', content: '', intraline: [], lineNum: 6, highlight: true},
+        {type: 'CODE', content: '', intraline: [], lineNum: 7, highlight: true},
+        {type: 'CODE', content: '', intraline: [], lineNum: 8 },
+        {type: 'COMMENT_THREAD', comments: [ { line: 8 }]},
+        {type: 'CODE', content: '', intraline: [], lineNum: 9 },
+        {type: 'CODE', content: '', intraline: [], lineNum: 10,
+            highlight: true},
+      ];
+
+      var scrollToLineStub = sinon.stub(element, 'scrollToLine');
+      element.scrollToNextDiffChunk();
+      assert.isTrue(scrollToLineStub.lastCall.calledWithExactly(6));
+      element.scrollToPreviousDiffChunk();
+      assert.isTrue(scrollToLineStub.lastCall.calledWithExactly(1));
+      element.scrollToNextCommentThread();
+      assert.isTrue(scrollToLineStub.lastCall.calledWithExactly(4));
+      element.scrollToNextCommentThread();
+      assert.isTrue(scrollToLineStub.lastCall.calledWithExactly(8));
+      element.scrollToPreviousDiffChunk();
+      assert.isTrue(scrollToLineStub.lastCall.calledWithExactly(6));
+
+      scrollToLineStub.restore();
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
new file mode 100644
index 0000000..0dc18ae
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -0,0 +1,174 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-ajax/gr-ajax.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-request/gr-request.html">
+<link rel="import" href="../gr-diff/gr-diff.html">
+
+<dom-module id="gr-diff-view">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+      }
+      h3 {
+        margin-top: 1em;
+        padding: .75em var(--default-horizontal-margin);
+      }
+      .reviewed {
+        display: inline-block;
+        margin: 0 .25em;
+        vertical-align: .15em;
+      }
+      .jumpToFileContainer {
+        display: inline-block;
+      }
+      .mobileJumpToFileContainer {
+        display: none;
+      }
+      .downArrow {
+        display: inline-block;
+        font-size: .6em;
+        vertical-align: middle;
+      }
+      .dropdown-trigger {
+        color: #00e;
+        cursor: pointer;
+        padding: 0;
+      }
+      .dropdown-content {
+        background-color: #fff;
+        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+      }
+      .dropdown-content a {
+        cursor: pointer;
+        display: block;
+        font-weight: normal;
+        padding: .3em .5em;
+      }
+      .dropdown-content a:before {
+        color: #ccc;
+        content: attr(data-key-nav);
+        display: inline-block;
+        margin-right: .5em;
+        width: .3em;
+      }
+      .dropdown-content a:hover {
+        background-color: #00e;
+        color: #fff;
+      }
+      .dropdown-content a[selected] {
+        color: #000;
+        font-weight: bold;
+        pointer-events: none;
+        text-decoration: none;
+      }
+      .dropdown-content a[selected]:hover {
+        background-color: #fff;
+        color: #000;
+      }
+      gr-button {
+        font: inherit;
+        padding: .3em 0;
+        text-decoration: none;
+      }
+      @media screen and (max-width: 50em) {
+        .dash {
+          display: none;
+        }
+        .reviewed {
+          vertical-align: -.1em;
+        }
+        .jumpToFileContainer {
+          display: none;
+        }
+        .mobileJumpToFileContainer {
+          display: block;
+          width: 100%;
+        }
+      }
+    </style>
+    <gr-ajax id="changeDetailXHR"
+        auto
+        url="[[_computeChangeDetailPath(_changeNum)]]"
+        params="[[_computeChangeDetailQueryParams()]]"
+        last-response="{{_change}}"></gr-ajax>
+    <gr-ajax id="filesXHR"
+        auto
+        url="[[_computeFilesPath(_changeNum, _patchRange.patchNum)]]"
+        on-response="_handleFilesResponse"></gr-ajax>
+    <gr-ajax id="configXHR"
+        auto
+        url="[[_computeProjectConfigPath(_change.project)]]"
+        last-response="{{_projectConfig}}"></gr-ajax>
+    <h3>
+      <a href$="[[_computeChangePath(_changeNum, _patchRange.patchNum, _change.revisions)]]">
+        [[_changeNum]]</a><span>:</span>
+      <span>[[_change.subject]]</span>
+      <span class="dash">—</span>
+      <input id="reviewed"
+          class="reviewed"
+          type="checkbox"
+          on-change="_handleReviewedChange"
+          hidden$="[[!_loggedIn]]" hidden>
+      <div class="jumpToFileContainer">
+        <gr-button link class="dropdown-trigger" id="trigger" on-tap="_showDropdownTapHandler">
+          <span>[[_computeFileDisplayName(_path)]]</span>
+          <span class="downArrow">&#9660;</span>
+        </gr-button>
+        <iron-dropdown id="dropdown" vertical-align="top" vertical-offset="25">
+          <div class="dropdown-content">
+            <template is="dom-repeat" items="[[_fileList]]" as="path">
+              <a href$="[[_computeDiffURL(_changeNum, _patchRange, path)]]"
+                 selected$="[[_computeFileSelected(path, _path)]]"
+                 data-key-nav$="[[_computeKeyNav(path, _path, _fileList)]]"
+                 on-tap="_handleFileTap">
+                 [[_computeFileDisplayName(path)]]
+              </a>
+            </template>
+          </div>
+        </iron-dropdown>
+      </div>
+      <div class="mobileJumpToFileContainer">
+        <select on-change="_handleMobileSelectChange">
+          <template is="dom-repeat" items="[[_fileList]]" as="path">
+            <option
+                value$="[[path]]"
+                selected$="[[_computeFileSelected(path, _path)]]">
+              [[_computeFileDisplayName(path)]]
+            </option>
+          </template>
+        </select>
+      </div>
+    </h3>
+    <gr-diff id="diff"
+        change-num="[[_changeNum]]"
+        prefs="{{prefs}}"
+        patch-range="[[_patchRange]]"
+        path="[[_path]]"
+        project-config="[[_projectConfig]]"
+        available-patches="[[_computeAvailablePatches(_change.revisions)]]"
+        on-render="_handleDiffRender">
+    </gr-diff>
+  </template>
+  <script src="gr-diff-view.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
new file mode 100644
index 0000000..847a641
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -0,0 +1,315 @@
+// 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 COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
+
+  Polymer({
+    is: 'gr-diff-view',
+
+    /**
+     * Fired when the title of the page should change.
+     *
+     * @event title-change
+     */
+
+    properties: {
+      prefs: {
+        type: Object,
+        notify: true,
+      },
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
+      changeViewState: {
+        type: Object,
+        notify: true,
+        value: function() { return {}; },
+      },
+
+      _patchRange: Object,
+      _change: Object,
+      _changeNum: String,
+      _diff: Object,
+      _fileList: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _path: {
+        type: String,
+        observer: '_pathChanged',
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _xhrPromise: Object,  // Used for testing.
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.RESTClientBehavior,
+    ],
+
+    ready: function() {
+      app.accountReady.then(function() {
+        this._loggedIn = app.loggedIn;
+        if (this._loggedIn) {
+          this._setReviewed(true);
+        }
+      }.bind(this));
+    },
+
+    attached: function() {
+      if (this._path) {
+        this.fire('title-change',
+            {title: this._computeFileDisplayName(this._path)});
+      }
+      window.addEventListener('resize', this._boundWindowResizeHandler);
+    },
+
+    detached: function() {
+      window.removeEventListener('resize', this._boundWindowResizeHandler);
+    },
+
+    _handleReviewedChange: function(e) {
+      this._setReviewed(Polymer.dom(e).rootTarget.checked);
+    },
+
+    _setReviewed: function(reviewed) {
+      this.$.reviewed.checked = reviewed;
+      var method = reviewed ? 'PUT' : 'DELETE';
+      var url = this.changeBaseURL(this._changeNum,
+          this._patchRange.patchNum) + '/files/' +
+          encodeURIComponent(this._path) + '/reviewed';
+      this._send(method, url).catch(function(err) {
+        alert('Couldn’t change file review status. Check the console ' +
+            'and contact the PolyGerrit team for assistance.');
+        throw err;
+      }.bind(this));
+    },
+
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+
+      switch (e.keyCode) {
+        case 219:  // '['
+          e.preventDefault();
+          this._navToFile(this._fileList, -1);
+          break;
+        case 221:  // ']'
+          e.preventDefault();
+          this._navToFile(this._fileList, 1);
+          break;
+        case 78:  // 'n'
+          if (e.shiftKey) {
+            this.$.diff.scrollToNextCommentThread();
+          } else {
+            this.$.diff.scrollToNextDiffChunk();
+          }
+          break;
+        case 80:  // 'p'
+          if (e.shiftKey) {
+            this.$.diff.scrollToPreviousCommentThread();
+          } else {
+            this.$.diff.scrollToPreviousDiffChunk();
+          }
+          break;
+        case 65:  // 'a'
+          if (!this._loggedIn) { return; }
+
+          this.set('changeViewState.showReplyDialog', true);
+          /* falls through */ // required by JSHint
+        case 85:  // 'u'
+          if (this._changeNum && this._patchRange.patchNum) {
+            e.preventDefault();
+            page.show(this._computeChangePath(
+                this._changeNum,
+                this._patchRange.patchNum,
+                this._change && this._change.revisions));
+          }
+          break;
+        case 188:  // ','
+          this.$.diff.showDiffPreferences();
+          break;
+      }
+    },
+
+    _handleDiffRender: function() {
+      if (window.location.hash.length > 0) {
+        this.$.diff.scrollToLine(
+            parseInt(window.location.hash.substring(1), 10));
+      }
+    },
+
+    _navToFile: function(fileList, direction) {
+      if (fileList.length == 0) { return; }
+
+      var idx = fileList.indexOf(this._path) + direction;
+      if (idx < 0 || idx > fileList.length - 1) {
+        page.show(this._computeChangePath(
+            this._changeNum,
+            this._patchRange.patchNum,
+            this._change && this._change.revisions));
+        return;
+      }
+      page.show(this._computeDiffURL(this._changeNum,
+                                     this._patchRange,
+                                     fileList[idx]));
+    },
+
+    _paramsChanged: function(value) {
+      if (value.view != this.tagName.toLowerCase()) { return; }
+
+      this._changeNum = value.changeNum;
+      this._patchRange = {
+        patchNum: value.patchNum,
+        basePatchNum: value.basePatchNum || 'PARENT',
+      };
+      this._path = value.path;
+
+      this.fire('title-change',
+          {title: this._computeFileDisplayName(this._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) {
+        return;
+      }
+
+      this.$.diff.reload();
+    },
+
+    _pathChanged: function(path) {
+      if (this._fileList.length == 0) { return; }
+
+      this.set('changeViewState.selectedFileIndex',
+          this._fileList.indexOf(path));
+
+      if (this._loggedIn) {
+        this._setReviewed(true);
+      }
+    },
+
+    _computeDiffURL: function(changeNum, patchRange, path) {
+      var patchStr = patchRange.patchNum;
+      if (patchRange.basePatchNum != null &&
+          patchRange.basePatchNum != 'PARENT') {
+        patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
+      }
+      return '/c/' + changeNum + '/' + patchStr + '/' + path;
+    },
+
+    _computeAvailablePatches: function(revisions) {
+      var patchNums = [];
+      for (var rev in revisions) {
+        patchNums.push(revisions[rev]._number);
+      }
+      return patchNums.sort(function(a, b) { return a - b; });
+    },
+
+    _computeChangePath: function(changeNum, patchNum, revisions) {
+      var base = '/c/' + changeNum + '/';
+
+      // The change may not have loaded yet, making revisions unavailable.
+      if (!revisions) {
+        return base + patchNum;
+      }
+
+      var latestPatchNum = -1;
+      for (var rev in revisions) {
+        latestPatchNum = Math.max(latestPatchNum, revisions[rev]._number);
+      }
+      if (parseInt(patchNum, 10) != latestPatchNum) {
+        return base + patchNum;
+      }
+
+      return base;
+    },
+
+    _computeFileDisplayName: function(path) {
+      return path == COMMIT_MESSAGE_PATH ? 'Commit message' : path;
+    },
+
+    _computeChangeDetailPath: function(changeNum) {
+      return '/changes/' + changeNum + '/detail';
+    },
+
+    _computeChangeDetailQueryParams: function() {
+      return {O: this.listChangesOptionsToHex(
+          this.ListChangesOption.ALL_REVISIONS
+      )};
+    },
+
+    _computeFilesPath: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/files';
+    },
+
+    _computeProjectConfigPath: function(project) {
+      return '/projects/' + encodeURIComponent(project) + '/config';
+    },
+
+    _computeFileSelected: function(path, currentPath) {
+      return path == currentPath;
+    },
+
+    _computeKeyNav: function(path, selectedPath, fileList) {
+      var selectedIndex = fileList.indexOf(selectedPath);
+      if (fileList.indexOf(path) == selectedIndex - 1) {
+        return '[';
+      }
+      if (fileList.indexOf(path) == selectedIndex + 1) {
+        return ']';
+      }
+      return '';
+    },
+
+    _handleFileTap: function(e) {
+      this.$.dropdown.close();
+    },
+
+    _handleMobileSelectChange: function(e) {
+      var path = Polymer.dom(e).rootTarget.value;
+      page.show(
+          this._computeDiffURL(this._changeNum, this._patchRange, path));
+    },
+
+    _handleFilesResponse: function(e, req) {
+      this._fileList = Object.keys(e.detail.response).sort();
+    },
+
+    _showDropdownTapHandler: function(e) {
+      this.$.dropdown.open();
+    },
+
+    _send: function(method, url) {
+      var xhr = document.createElement('gr-request');
+      this._xhrPromise = xhr.send({
+        method: method,
+        url: url,
+      });
+      return this._xhrPromise;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
new file mode 100644
index 0000000..bfe4906
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -0,0 +1,395 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../scripts/fake-app.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-view.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-view></gr-diff-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-view tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+      element.$.changeDetailXHR.auto = false;
+      element.$.filesXHR.auto = false;
+      element.$.configXHR.auto = false;
+      element.$.diff.auto = false;
+
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'PUT',
+        '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed',
+        [
+          201,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          '""',
+        ]
+      );
+      server.respondWith(
+        'DELETE',
+        '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed',
+        [
+          204,
+          {'Content-Type': 'application/json'},
+          '',
+        ]
+      );
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    test('keyboard shortcuts', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        patchNum: '10',
+      };
+      element._change = {
+        revisions: {
+          a: { _number: 10, },
+        },
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      element.changeViewState.selectedFileIndex = 1;
+
+      var showStub = sinon.stub(page, 'show');
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/wheatley.md'),
+          'Should navigate to /c/42/10/wheatley.md');
+      element._path = 'wheatley.md';
+      assert.equal(element.changeViewState.selectedFileIndex, 2);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/glados.txt'),
+          'Should navigate to /c/42/10/glados.txt');
+      element._path = 'glados.txt';
+      assert.equal(element.changeViewState.selectedFileIndex, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/10/chell.go'),
+          'Should navigate to /c/42/10/chell.go');
+      element._path = 'chell.go';
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+      assert.equal(element.changeViewState.selectedFileIndex, 0);
+
+      var showPrefsStub = sinon.stub(element.$.diff, 'showDiffPreferences');
+      MockInteractions.pressAndReleaseKeyOn(element, 188);  // ','
+      assert(showPrefsStub.calledOnce);
+
+      var scrollStub = sinon.stub(element.$.diff, 'scrollToNextDiffChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 78);  // 'n'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      scrollStub = sinon.stub(element.$.diff, 'scrollToPreviousDiffChunk');
+      MockInteractions.pressAndReleaseKeyOn(element, 80);  // 'p'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      scrollStub = sinon.stub(element.$.diff, 'scrollToNextCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, ['shift']);  // 'N'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      scrollStub = sinon.stub(element.$.diff, 'scrollToPreviousCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, ['shift']);  // 'P'
+      assert(scrollStub.calledOnce);
+      scrollStub.restore();
+
+      showPrefsStub.restore();
+      showStub.restore();
+    });
+
+    test('keyboard shortcuts with patch range', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      element._change = {
+        revisions: {
+          a: { _number: 10, },
+        },
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+
+      var showStub = sinon.stub(page, 'show');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
+          'only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/wheatley.md'),
+          'Should navigate to /c/42/5..10/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/glados.txt'),
+          'Should navigate to /c/42/5..10/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/chell.go'),
+          'Should navigate to /c/42/5..10/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+          'Should navigate to /c/42/');
+
+      showStub.restore();
+    });
+
+    test('keyboard shortcuts with old patch number', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        patchNum: '1',
+      };
+      element._change = {
+        revisions: {
+          a: { _number: 1, },
+          b: { _number: 2, },
+        },
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+
+      var showStub = sinon.stub(page, 'show');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
+          'only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
+          'Should navigate to /c/42/1/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
+          'Should navigate to /c/42/1/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
+          'Should navigate to /c/42/1/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      showStub.restore();
+    });
+
+    test('go up to change via kb without change loaded', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        patchNum: '1',
+      };
+
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+
+      var showStub = sinon.stub(page, 'show');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
+          'only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+
+      element._loggedIn = true;
+      MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'a'
+      assert.isTrue(element.changeViewState.showReplyDialog);
+
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'u'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
+          'Should navigate to /c/42/1/wheatley.md');
+      element._path = 'wheatley.md';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
+          'Should navigate to /c/42/1/glados.txt');
+      element._path = 'glados.txt';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
+          'Should navigate to /c/42/1/chell.go');
+      element._path = 'chell.go';
+
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
+      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+          'Should navigate to /c/42/1');
+
+      showStub.restore();
+    });
+
+    test('jump to file dropdown', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        patchNum: '10',
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      var linkEls =
+          Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
+      assert.equal(linkEls.length, 3);
+      assert.isFalse(linkEls[0].hasAttribute('selected'));
+      assert.isTrue(linkEls[1].hasAttribute('selected'));
+      assert.isFalse(linkEls[2].hasAttribute('selected'));
+      assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
+      assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
+      assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/chell.go');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/10/glados.txt');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/wheatley.md');
+
+      assert.equal(element._computeFileDisplayName('/foo/bar/baz'),
+          '/foo/bar/baz');
+      assert.equal(element._computeFileDisplayName('/COMMIT_MSG'),
+          'Commit message');
+    });
+
+    test('jump to file dropdown with patch range', function() {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '5',
+        patchNum: '10',
+      };
+      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+      element._path = 'glados.txt';
+      flushAsynchronousOperations();
+      var linkEls =
+          Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
+      assert.equal(linkEls.length, 3);
+      assert.isFalse(linkEls[0].hasAttribute('selected'));
+      assert.isTrue(linkEls[1].hasAttribute('selected'));
+      assert.isFalse(linkEls[2].hasAttribute('selected'));
+      assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
+      assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
+      assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
+      assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/chell.go');
+      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10/glados.txt');
+      assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/wheatley.md');
+    });
+
+    test('file review status', function(done) {
+      element._loggedIn = true;
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: '1',
+        patchNum: '2',
+      };
+      element._fileList = ['/COMMIT_MSG'];
+      element._path = '/COMMIT_MSG';
+
+      server.respond();
+
+      element.async(function() {
+        var commitMsg = Polymer.dom(element.root).querySelector(
+            'input[type="checkbox"]');
+
+        assert.isTrue(commitMsg.checked);
+
+        MockInteractions.tap(commitMsg);
+        server.respond();
+        element._xhrPromise.then(function(req) {
+          assert.isFalse(commitMsg.checked);
+          assert.equal(req.status, 204);
+          assert.equal(req.url,
+              '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed');
+
+          MockInteractions.tap(commitMsg);
+          server.respond();
+        }).then(function() {
+          element._xhrPromise.then(function(req) {
+            assert.isTrue(commitMsg.checked);
+            assert.equal(req.status, 201);
+            assert.equal(req.url,
+                '/changes/42/revisions/2/files/%2FCOMMIT_MSG/reviewed');
+
+            done();
+          });
+        });
+      }, 1);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
new file mode 100644
index 0000000..21ee076
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -0,0 +1,123 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-ajax/gr-ajax.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-request/gr-request.html">
+
+<link rel="import" href="../gr-diff-preferences/gr-diff-preferences.html">
+<link rel="import" href="../gr-diff-side/gr-diff-side.html">
+<link rel="import" href="../gr-patch-range-select/gr-patch-range-select.html">
+
+<dom-module id="gr-diff">
+  <template>
+    <style>
+      .loading {
+        padding: 0 var(--default-horizontal-margin) 1em;
+        color: #666;
+      }
+      .header {
+        display: flex;
+        justify-content: space-between;
+        margin: 0 var(--default-horizontal-margin) .75em;
+      }
+      .prefsButton {
+        text-align: right;
+      }
+      .diffContainer {
+        border-bottom: 1px solid #eee;
+        border-top: 1px solid #eee;
+        display: flex;
+        font: 12px var(--monospace-font-family);
+        overflow-x: auto;
+      }
+      gr-diff-side:first-of-type {
+        --light-highlight-color: #fee;
+        --dark-highlight-color: #ffd4d4;
+      }
+      gr-diff-side:last-of-type {
+        --light-highlight-color: #efe;
+        --dark-highlight-color: #d4ffd4;
+        border-right: 1px solid #ddd;
+      }
+    </style>
+    <gr-ajax id="diffXHR"
+        url="[[_computeDiffPath(changeNum, patchRange.patchNum, path)]]"
+        params="[[_computeDiffQueryParams(patchRange.basePatchNum)]]"
+        last-response="{{_diffResponse}}"
+        loading="{{_loading}}"></gr-ajax>
+    <gr-ajax id="baseCommentsXHR"
+        url="[[_computeCommentsPath(changeNum, patchRange.basePatchNum)]]"></gr-ajax>
+    <gr-ajax id="commentsXHR"
+        url="[[_computeCommentsPath(changeNum, patchRange.patchNum)]]"></gr-ajax>
+    <gr-ajax id="baseDraftsXHR"
+        url="[[_computeDraftsPath(changeNum, patchRange.basePatchNum)]]"></gr-ajax>
+    <gr-ajax id="draftsXHR"
+        url="[[_computeDraftsPath(changeNum, patchRange.patchNum)]]"></gr-ajax>
+    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+    <div hidden$="[[_loading]]" hidden>
+      <div class="header">
+        <gr-patch-range-select
+            path="[[path]]"
+            change-num="[[changeNum]]"
+            patch-range="[[patchRange]]"
+            available-patches="[[availablePatches]]"></gr-patch-range-select>
+        <gr-button link
+           class="prefsButton"
+           on-tap="_handlePrefsTap"
+           hidden$="[[!prefs]]"
+           hidden>Diff View Preferences</gr-button>
+      </div>
+      <gr-overlay id="prefsOverlay" with-backdrop>
+        <gr-diff-preferences
+            prefs="{{prefs}}"
+            on-save="_handlePrefsSave"
+            on-cancel="_handlePrefsCancel"></gr-diff-preferences>
+      </gr-overlay>
+
+      <div class="diffContainer">
+        <gr-diff-side id="leftDiff"
+            change-num="[[changeNum]]"
+            patch-num="[[patchRange.basePatchNum]]"
+            path="[[path]]"
+            content="{{_diff.leftSide}}"
+            prefs="[[prefs]]"
+            can-comment="[[_loggedIn]]"
+            project-config="[[projectConfig]]"
+            on-expand-context="_handleExpandContext"
+            on-thread-height-change="_handleThreadHeightChange"
+            on-add-draft="_handleAddDraft"
+            on-remove-thread="_handleRemoveThread"></gr-diff-side>
+        <gr-diff-side id="rightDiff"
+            change-num="[[changeNum]]"
+            patch-num="[[patchRange.patchNum]]"
+            path="[[path]]"
+            content="{{_diff.rightSide}}"
+            prefs="[[prefs]]"
+            can-comment="[[_loggedIn]]"
+            project-config="[[projectConfig]]"
+            on-expand-context="_handleExpandContext"
+            on-thread-height-change="_handleThreadHeightChange"
+            on-add-draft="_handleAddDraft"
+            on-remove-thread="_handleRemoveThread"></gr-diff-side>
+      </div>
+    </div>
+  </template>
+  <script src="gr-diff.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
new file mode 100644
index 0000000..485e2cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -0,0 +1,746 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-diff',
+
+    /**
+     * Fired when the diff is rendered.
+     *
+     * @event render
+     */
+
+    properties: {
+      availablePatches: Array,
+      changeNum: String,
+      /*
+       * A single object to encompass basePatchNum and patchNum is used
+       * so that both can be set at once without incremental observers
+       * firing after each property changes.
+       */
+      patchRange: Object,
+      path: String,
+      prefs: {
+        type: Object,
+        notify: true,
+      },
+      projectConfig: Object,
+
+      _prefsReady: {
+        type: Object,
+        readOnly: true,
+        value: function() {
+          return new Promise(function(resolve) {
+            this._resolvePrefsReady = resolve;
+          }.bind(this));
+        },
+      },
+      _baseComments: Array,
+      _comments: Array,
+      _drafts: Array,
+      _baseDrafts: Array,
+      /**
+       * Base (left side) comments and drafts grouped by line number.
+       * Only used for initial rendering.
+       */
+      _groupedBaseComments: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      /**
+       * Comments and drafts (right side) grouped by line number.
+       * Only used for initial rendering.
+       */
+      _groupedComments: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      _diffResponse: Object,
+      _diff: {
+        type: Object,
+        value: function() { return {}; },
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      _initialRenderComplete: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _savedPrefs: Object,
+
+      _diffRequestsPromise: Object,  // Used for testing.
+      _diffPreferencesPromise: Object,  // Used for testing.
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    observers: [
+      '_prefsChanged(prefs.*)',
+    ],
+
+    ready: function() {
+      app.accountReady.then(function() {
+        this._loggedIn = app.loggedIn;
+      }.bind(this));
+    },
+
+    scrollToLine: function(lineNum) {
+      // TODO(andybons): Should this always be the right side?
+      this.$.rightDiff.scrollToLine(lineNum);
+    },
+
+    scrollToNextDiffChunk: function() {
+      this.$.rightDiff.scrollToNextDiffChunk();
+    },
+
+    scrollToPreviousDiffChunk: function() {
+      this.$.rightDiff.scrollToPreviousDiffChunk();
+    },
+
+    scrollToNextCommentThread: function() {
+      this.$.rightDiff.scrollToNextCommentThread();
+    },
+
+    scrollToPreviousCommentThread: function() {
+      this.$.rightDiff.scrollToPreviousCommentThread();
+    },
+
+    reload: function(changeNum, patchRange, path) {
+      // If a diff takes a considerable amount of time to render, the previous
+      // diff can end up showing up while the DOM is constructed. Clear the
+      // content on a reload to prevent this.
+      this._diff = {
+        leftSide: [],
+        rightSide: [],
+      };
+
+      var promises = [
+        this._prefsReady,
+        this.$.diffXHR.generateRequest().completes
+      ];
+
+      var basePatchNum = this.patchRange.basePatchNum;
+
+      return app.accountReady.then(function() {
+        promises.push(this._getCommentsAndDrafts(basePatchNum, app.loggedIn));
+        this._diffRequestsPromise = Promise.all(promises).then(function() {
+          this._render();
+        }.bind(this)).catch(function(err) {
+          alert('Oops. Something went wrong. Check the console and bug the ' +
+              'PolyGerrit team for assistance.');
+          throw err;
+        });
+      }.bind(this));
+    },
+
+    showDiffPreferences: function() {
+      this.$.prefsOverlay.open();
+    },
+
+    _prefsChanged: function(changeRecord) {
+      if (this._initialRenderComplete) {
+        this._render();
+      }
+      this._resolvePrefsReady(changeRecord.base);
+    },
+
+    _render: function() {
+      this._groupCommentsAndDrafts();
+      this._processContent();
+
+      // Allow for the initial rendering to complete before firing the event.
+      this.async(function() {
+        this.fire('render', null, {bubbles: false});
+      }.bind(this), 1);
+
+      this._initialRenderComplete = true;
+    },
+
+    _getCommentsAndDrafts: function(basePatchNum, loggedIn) {
+      function onlyParent(c) { return c.side == 'PARENT'; }
+      function withoutParent(c) { return c.side != 'PARENT'; }
+
+      var promises = [];
+      var commentsPromise = this.$.commentsXHR.generateRequest().completes;
+      promises.push(commentsPromise.then(function(req) {
+        var comments = req.response[this.path] || [];
+        if (basePatchNum == 'PARENT') {
+          this._baseComments = comments.filter(onlyParent);
+        }
+        this._comments = comments.filter(withoutParent);
+      }.bind(this)));
+
+      if (basePatchNum != 'PARENT') {
+        commentsPromise = this.$.baseCommentsXHR.generateRequest().completes;
+        promises.push(commentsPromise.then(function(req) {
+          this._baseComments =
+            (req.response[this.path] || []).filter(withoutParent);
+        }.bind(this)));
+      }
+
+      if (!loggedIn) {
+        this._baseDrafts = [];
+        this._drafts = [];
+        return Promise.all(promises);
+      }
+
+      var draftsPromise = this.$.draftsXHR.generateRequest().completes;
+      promises.push(draftsPromise.then(function(req) {
+        var drafts = req.response[this.path] || [];
+        if (basePatchNum == 'PARENT') {
+          this._baseDrafts = drafts.filter(onlyParent);
+        }
+        this._drafts = drafts.filter(withoutParent);
+      }.bind(this)));
+
+      if (basePatchNum != 'PARENT') {
+        draftsPromise = this.$.baseDraftsXHR.generateRequest().completes;
+        promises.push(draftsPromise.then(function(req) {
+          this._baseDrafts =
+              (req.response[this.path] || []).filter(withoutParent);
+        }.bind(this)));
+      }
+
+      return Promise.all(promises);
+    },
+
+    _computeDiffPath: function(changeNum, patchNum, path) {
+      return this.changeBaseURL(changeNum, patchNum) + '/files/' +
+          encodeURIComponent(path) + '/diff';
+    },
+
+    _computeCommentsPath: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/comments';
+    },
+
+    _computeDraftsPath: function(changeNum, patchNum) {
+      return this.changeBaseURL(changeNum, patchNum) + '/drafts';
+    },
+
+    _computeDiffQueryParams: function(basePatchNum) {
+      var params =  {
+        context: 'ALL',
+        intraline: null
+      };
+      if (basePatchNum != 'PARENT') {
+        params.base = basePatchNum;
+      }
+      return params;
+    },
+
+    _handlePrefsTap: function(e) {
+      e.preventDefault();
+
+      // TODO(andybons): This is not supported in IE. Implement a polyfill.
+      // NOTE: Object.assign is NOT automatically a deep copy. If prefs adds
+      // an object as a value, it must be marked enumerable.
+      this._savedPrefs = Object.assign({}, this.prefs);
+      this.$.prefsOverlay.open();
+    },
+
+    _handlePrefsSave: function(e) {
+      e.stopPropagation();
+      var el = Polymer.dom(e).rootTarget;
+      el.disabled = true;
+      app.accountReady.then(function() {
+        if (!this._loggedIn) {
+          el.disabled = false;
+          this.$.prefsOverlay.close();
+          return;
+        }
+        this._saveDiffPreferences().then(function() {
+          this.$.prefsOverlay.close();
+          el.disabled = false;
+        }.bind(this)).catch(function(err) {
+          el.disabled = false;
+          alert('Oops. Something went wrong. Check the console and bug the ' +
+                'PolyGerrit team for assistance.');
+          throw err;
+        });
+      }.bind(this));
+    },
+
+    _saveDiffPreferences: function() {
+      var xhr = document.createElement('gr-request');
+      this._diffPreferencesPromise = xhr.send({
+        method: 'PUT',
+        url: '/accounts/self/preferences.diff',
+        body: this.prefs,
+      });
+      return this._diffPreferencesPromise;
+    },
+
+    _handlePrefsCancel: function(e) {
+      e.stopPropagation();
+      this.prefs = this._savedPrefs;
+      this.$.prefsOverlay.close();
+    },
+
+    _handleExpandContext: function(e) {
+      var ctx = e.detail.context;
+      var contextControlIndex = -1;
+      for (var i = ctx.start; i <= ctx.end; i++) {
+        this._diff.leftSide[i].hidden = false;
+        this._diff.rightSide[i].hidden = false;
+        if (this._diff.leftSide[i].type == 'CONTEXT_CONTROL' &&
+            this._diff.rightSide[i].type == 'CONTEXT_CONTROL') {
+          contextControlIndex = i;
+        }
+      }
+      this._diff.leftSide[contextControlIndex].hidden = true;
+      this._diff.rightSide[contextControlIndex].hidden = true;
+
+      this.$.leftDiff.hideElementsWithIndex(contextControlIndex);
+      this.$.rightDiff.hideElementsWithIndex(contextControlIndex);
+
+      this.$.leftDiff.renderLineIndexRange(ctx.start, ctx.end);
+      this.$.rightDiff.renderLineIndexRange(ctx.start, ctx.end);
+    },
+
+    _handleThreadHeightChange: function(e) {
+      var index = e.detail.index;
+      var diffEl = Polymer.dom(e).rootTarget;
+      var otherSide = diffEl == this.$.leftDiff ?
+          this.$.rightDiff : this.$.leftDiff;
+
+      var threadHeight = e.detail.height;
+      var otherSideHeight;
+      if (otherSide.content[index].type == 'COMMENT_THREAD') {
+        otherSideHeight = otherSide.getRowNaturalHeight(index);
+      } else {
+        otherSideHeight = otherSide.getRowHeight(index);
+      }
+      var maxHeight = Math.max(threadHeight, otherSideHeight);
+      this.$.leftDiff.setRowHeight(index, maxHeight);
+      this.$.rightDiff.setRowHeight(index, maxHeight);
+    },
+
+    _handleAddDraft: function(e) {
+      var insertIndex = e.detail.index + 1;
+      var diffEl = Polymer.dom(e).rootTarget;
+      var content = diffEl.content;
+      if (content[insertIndex] &&
+          content[insertIndex].type == 'COMMENT_THREAD') {
+        // A thread is already here. Do nothing.
+        return;
+      }
+      var comment = {
+        type: 'COMMENT_THREAD',
+        comments: [{
+          __draft: true,
+          __draftID: Math.random().toString(36),
+          line: e.detail.line,
+          path: this.path,
+        }]
+      };
+      if (diffEl == this.$.leftDiff &&
+          this.patchRange.basePatchNum == 'PARENT') {
+        comment.comments[0].side = 'PARENT';
+        comment.patchNum = this.patchRange.patchNum;
+      }
+
+      if (content[insertIndex] &&
+          content[insertIndex].type == 'FILLER') {
+        content[insertIndex] = comment;
+        diffEl.rowUpdated(insertIndex);
+      } else {
+        content.splice(insertIndex, 0, comment);
+        diffEl.rowInserted(insertIndex);
+      }
+
+      var otherSide = diffEl == this.$.leftDiff ?
+          this.$.rightDiff : this.$.leftDiff;
+      if (otherSide.content[insertIndex] == null ||
+          otherSide.content[insertIndex].type != 'COMMENT_THREAD') {
+        otherSide.content.splice(insertIndex, 0, {
+          type: 'FILLER',
+        });
+        otherSide.rowInserted(insertIndex);
+      }
+    },
+
+    _handleRemoveThread: function(e) {
+      var diffEl = Polymer.dom(e).rootTarget;
+      var otherSide = diffEl == this.$.leftDiff ?
+          this.$.rightDiff : this.$.leftDiff;
+      var index = e.detail.index;
+
+      if (otherSide.content[index].type == 'FILLER') {
+        otherSide.content.splice(index, 1);
+        otherSide.rowRemoved(index);
+        diffEl.content.splice(index, 1);
+        diffEl.rowRemoved(index);
+      } else if (otherSide.content[index].type == 'COMMENT_THREAD') {
+        diffEl.content[index] = {type: 'FILLER'};
+        diffEl.rowUpdated(index);
+        var height = otherSide.setRowNaturalHeight(index);
+        diffEl.setRowHeight(index, height);
+      } else {
+        throw Error('A thread cannot be opposite anything but filler or ' +
+            'another thread');
+      }
+    },
+
+    _processContent: function() {
+      var leftSide = [];
+      var rightSide = [];
+      var initialLineNum = 0 + (this._diffResponse.content.skip || 0);
+      var ctx = {
+        hidingLines: false,
+        lastNumLinesHidden: 0,
+        left: {
+          lineNum: initialLineNum,
+        },
+        right: {
+          lineNum: initialLineNum,
+        }
+      };
+      var content = this._breakUpCommonChunksWithComments(ctx,
+          this._diffResponse.content);
+      var context = this.prefs.context;
+      if (context == -1) {
+        // Show the entire file.
+        context = Infinity;
+      }
+      for (var i = 0; i < content.length; i++) {
+        if (i == 0) {
+          ctx.skipRange = [0, context];
+        } else if (i == content.length - 1) {
+          ctx.skipRange = [context, 0];
+        } else {
+          ctx.skipRange = [context, context];
+        }
+        ctx.diffChunkIndex = i;
+        this._addDiffChunk(ctx, content[i], leftSide, rightSide);
+      }
+
+      this._diff = {
+        leftSide: leftSide,
+        rightSide: rightSide,
+      };
+    },
+
+    // In order to show comments out of the bounds of the selected context,
+    // treat them as diffs within the model so that the content (and context
+    // surrounding it) renders correctly.
+    _breakUpCommonChunksWithComments: function(ctx, content) {
+      var result = [];
+      var leftLineNum = ctx.left.lineNum;
+      var rightLineNum = ctx.right.lineNum;
+      for (var i = 0; i < content.length; i++) {
+        if (!content[i].ab) {
+          result.push(content[i]);
+          if (content[i].a) {
+            leftLineNum += content[i].a.length;
+          }
+          if (content[i].b) {
+            rightLineNum += content[i].b.length;
+          }
+          continue;
+        }
+        var chunk = content[i].ab;
+        var currentChunk = {ab: []};
+        for (var j = 0; j < chunk.length; j++) {
+          leftLineNum++;
+          rightLineNum++;
+          if (this._groupedBaseComments[leftLineNum] == null &&
+              this._groupedComments[rightLineNum] == null) {
+            currentChunk.ab.push(chunk[j]);
+          } else {
+            if (currentChunk.ab && currentChunk.ab.length > 0) {
+              result.push(currentChunk);
+              currentChunk = {ab: []};
+            }
+            // Append an annotation to indicate that this line should not be
+            // highlighted even though it's implied with both `a` and `b`
+            // defined. This is needed since there may be two lines that
+            // should be highlighted but are equal (blank lines, for example).
+            result.push({
+              __noHighlight: true,
+              a: [chunk[j]],
+              b: [chunk[j]],
+            });
+          }
+        }
+        if (currentChunk.ab != null && currentChunk.ab.length > 0) {
+          result.push(currentChunk);
+        }
+      }
+      return result;
+    },
+
+    _groupCommentsAndDrafts: function() {
+      this._baseDrafts.forEach(function(d) { d.__draft = true; });
+      this._drafts.forEach(function(d) { d.__draft = true; });
+      var allLeft = this._baseComments.concat(this._baseDrafts);
+      var allRight = this._comments.concat(this._drafts);
+
+      var leftByLine = {};
+      var rightByLine = {};
+      var mapFunc = function(byLine) {
+        return function(c) {
+          // File comments/drafts are grouped with line 1 for now.
+          var line = c.line || 1;
+          if (byLine[line] == null) {
+            byLine[line] = [];
+          }
+          byLine[line].push(c);
+        };
+      };
+      allLeft.forEach(mapFunc(leftByLine));
+      allRight.forEach(mapFunc(rightByLine));
+
+      this._groupedBaseComments = leftByLine;
+      this._groupedComments = rightByLine;
+    },
+
+    _addContextControl: function(ctx, leftSide, rightSide) {
+      var numLinesHidden = ctx.lastNumLinesHidden;
+      var leftStart = leftSide.length - numLinesHidden;
+      var leftEnd = leftSide.length;
+      var rightStart = rightSide.length - numLinesHidden;
+      var rightEnd = rightSide.length;
+      if (leftStart != rightStart || leftEnd != rightEnd) {
+        throw Error(
+            'Left and right ranges for context control should be equal:' +
+            'Left: [' + leftStart + ', ' + leftEnd + '] ' +
+            'Right: [' + rightStart + ', ' + rightEnd + ']');
+      }
+      var obj = {
+        type: 'CONTEXT_CONTROL',
+        numLines: numLinesHidden,
+        start: leftStart,
+        end: leftEnd,
+      };
+      // NOTE: Be careful, here. This object is meant to be immutable. If the
+      // object is altered within one side's array it will reflect the
+      // alterations in another.
+      leftSide.push(obj);
+      rightSide.push(obj);
+    },
+
+    _addCommonDiffChunk: function(ctx, chunk, leftSide, rightSide) {
+      for (var i = 0; i < chunk.ab.length; i++) {
+        var numLines = Math.ceil(
+            this._visibleLineLength(chunk.ab[i]) / this.prefs.line_length);
+        var hidden = i >= ctx.skipRange[0] &&
+            i < chunk.ab.length - ctx.skipRange[1];
+        if (ctx.hidingLines && hidden == false) {
+          // No longer hiding lines. Add a context control.
+          this._addContextControl(ctx, leftSide, rightSide);
+          ctx.lastNumLinesHidden = 0;
+        }
+        ctx.hidingLines = hidden;
+        if (hidden) {
+          ctx.lastNumLinesHidden++;
+        }
+
+        // Blank lines within a diff content array indicate a newline.
+        leftSide.push({
+          type: 'CODE',
+          hidden: hidden,
+          content: chunk.ab[i] || '\n',
+          numLines: numLines,
+          lineNum: ++ctx.left.lineNum,
+        });
+        rightSide.push({
+          type: 'CODE',
+          hidden: hidden,
+          content: chunk.ab[i] || '\n',
+          numLines: numLines,
+          lineNum: ++ctx.right.lineNum,
+        });
+
+        this._addCommentsIfPresent(ctx, leftSide, rightSide);
+      }
+      if (ctx.lastNumLinesHidden > 0) {
+        this._addContextControl(ctx, leftSide, rightSide);
+      }
+    },
+
+    _addDiffChunk: function(ctx, chunk, leftSide, rightSide) {
+      if (chunk.ab) {
+        this._addCommonDiffChunk(ctx, chunk, leftSide, rightSide);
+        return;
+      }
+
+      var leftHighlights = [];
+      if (chunk.edit_a) {
+        leftHighlights =
+            this._normalizeIntralineHighlights(chunk.a, chunk.edit_a);
+      }
+      var rightHighlights = [];
+      if (chunk.edit_b) {
+        rightHighlights =
+            this._normalizeIntralineHighlights(chunk.b, chunk.edit_b);
+      }
+
+      var aLen = (chunk.a && chunk.a.length) || 0;
+      var bLen = (chunk.b && chunk.b.length) || 0;
+      var maxLen = Math.max(aLen, bLen);
+      for (var i = 0; i < maxLen; i++) {
+        var hasLeftContent = chunk.a && i < chunk.a.length;
+        var hasRightContent = chunk.b && i < chunk.b.length;
+        var leftContent = hasLeftContent ? chunk.a[i] : '';
+        var rightContent = hasRightContent ? chunk.b[i] : '';
+        var highlight = !chunk.__noHighlight;
+        var maxNumLines = this._maxLinesSpanned(leftContent, rightContent);
+        if (hasLeftContent) {
+          leftSide.push({
+            type: 'CODE',
+            content: leftContent || '\n',
+            numLines: maxNumLines,
+            lineNum: ++ctx.left.lineNum,
+            highlight: highlight,
+            intraline: highlight && leftHighlights.filter(function(hl) {
+              return hl.contentIndex == i;
+            }),
+          });
+        } else {
+          leftSide.push({
+            type: 'FILLER',
+            numLines: maxNumLines,
+          });
+        }
+        if (hasRightContent) {
+          rightSide.push({
+            type: 'CODE',
+            content: rightContent || '\n',
+            numLines: maxNumLines,
+            lineNum: ++ctx.right.lineNum,
+            highlight: highlight,
+            intraline: highlight && rightHighlights.filter(function(hl) {
+              return hl.contentIndex == i;
+            }),
+          });
+        } else {
+          rightSide.push({
+            type: 'FILLER',
+            numLines: maxNumLines,
+          });
+        }
+        this._addCommentsIfPresent(ctx, leftSide, rightSide);
+      }
+    },
+
+    _addCommentsIfPresent: function(ctx, leftSide, rightSide) {
+      var leftComments = this._groupedBaseComments[ctx.left.lineNum];
+      var rightComments = this._groupedComments[ctx.right.lineNum];
+      if (leftComments) {
+        var thread = {
+          type: 'COMMENT_THREAD',
+          comments: leftComments,
+        };
+        if (this.patchRange.basePatchNum == 'PARENT') {
+          thread.patchNum = this.patchRange.patchNum;
+        }
+        leftSide.push(thread);
+      }
+      if (rightComments) {
+        rightSide.push({
+          type: 'COMMENT_THREAD',
+          comments: rightComments,
+        });
+      }
+      if (leftComments && !rightComments) {
+        rightSide.push({type: 'FILLER'});
+      } else if (!leftComments && rightComments) {
+        leftSide.push({type: 'FILLER'});
+      }
+      this._groupedBaseComments[ctx.left.lineNum] = null;
+      this._groupedComments[ctx.right.lineNum] = null;
+    },
+
+    // The `highlights` array consists of a list of <skip length, mark length>
+    // pairs, where the skip length is the number of characters between the
+    // end of the previous edit and the start of this edit, and the mark
+    // length is the number of edited characters following the skip. The start
+    // of the edits is from the beginning of the related diff content lines.
+    //
+    // Note that the implied newline character at the end of each line is
+    // included in the length calculation, and thus it is possible for the
+    // edits to span newlines.
+    //
+    // A line highlight object consists of three fields:
+    // - contentIndex: The index of the diffChunk `content` field (the line
+    //   being referred to).
+    // - startIndex: Where the highlight should begin.
+    // - endIndex: (optional) Where the highlight should end. If omitted, the
+    //   highlight is meant to be a continuation onto the next line.
+    _normalizeIntralineHighlights: function(content, highlights) {
+      var contentIndex = 0;
+      var idx = 0;
+      var normalized = [];
+      for (var i = 0; i < highlights.length; i++) {
+        var line = content[contentIndex] + '\n';
+        var hl = highlights[i];
+        var j = 0;
+        while (j < hl[0]) {
+          if (idx == line.length) {
+            idx = 0;
+            line = content[++contentIndex] + '\n';
+            continue;
+          }
+          idx++;
+          j++;
+        }
+        var lineHighlight = {
+          contentIndex: contentIndex,
+          startIndex: idx,
+        };
+
+        j = 0;
+        while (line && j < hl[1]) {
+          if (idx == line.length) {
+            idx = 0;
+            line = content[++contentIndex] + '\n';
+            normalized.push(lineHighlight);
+            lineHighlight = {
+              contentIndex: contentIndex,
+              startIndex: idx,
+            };
+            continue;
+          }
+          idx++;
+          j++;
+        }
+        lineHighlight.endIndex = idx;
+        normalized.push(lineHighlight);
+      }
+      return normalized;
+    },
+
+    _visibleLineLength: function(contents) {
+      // http://jsperf.com/performance-of-match-vs-split
+      var numTabs = contents.split('\t').length - 1;
+      return contents.length - numTabs + (this.prefs.tab_size * numTabs);
+    },
+
+    _maxLinesSpanned: function(left, right) {
+      return Math.max(
+          Math.ceil(this._visibleLineLength(left) / this.prefs.line_length),
+          Math.ceil(this._visibleLineLength(right) / this.prefs.line_length));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
new file mode 100644
index 0000000..9a8cb81
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -0,0 +1,574 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/fake-app.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff></gr-diff>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+      element.changeNum = 42;
+      element.path = 'sieve.go';
+      element.prefs = {
+        context: 10,
+        tab_size: 8,
+      };
+
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'GET',
+        /\/changes\/42\/revisions\/(1|2)\/files\/sieve\.go\/diff(.*)/,
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({
+            change_type: 'MODIFIED',
+            content: [
+              {
+                ab: [
+                  '<!DOCTYPE html>',
+                  '<meta charset="utf-8">',
+                  '<title>My great page</title>',
+                  '<style>',
+                  '  *,',
+                  '  *:before,',
+                  '  *:after {',
+                  '    box-sizing: border-box;',
+                  '  }',
+                  '</style>',
+                  '<header>',
+                ]
+              },
+              {
+                a: [
+                  '  Welcome ',
+                  '  to the wooorld of tomorrow!',
+                ],
+                b: [
+                  '  Hello, world!',
+                ],
+              },
+              {
+                ab: [
+                  '</header>',
+                  '<body>',
+                  'Leela: This is the only place the ship can’t hear us, so ',
+                  'everyone pretend to shower.',
+                  'Fry: Same as every day. Got it.',
+                ]
+              },
+            ]
+          }),
+        ]
+      );
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/1/comments',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({
+            '/COMMIT_MSG': [],
+            'sieve.go': [
+              {
+                author: {
+                  _account_id: 1000000,
+                  name: 'Andrew Bonventre',
+                  email: 'andybons@gmail.com',
+                },
+                id: '9af53d3f_5f2b8b82',
+                line: 1,
+                message: 'this isn’t quite right',
+                updated: '2015-12-10 02:50:21.627000000',
+              },
+              {
+                author: {
+                  _account_id: 1000000,
+                  name: 'Andrew Bonventre',
+                  email: 'andybons@gmail.com',
+                },
+                id: '9af53d3f_bf1cd76b',
+                line: 1,
+                side: 'PARENT',
+                message: 'how did this work in the first place?',
+                updated: '2015-12-10 00:08:42.255000000',
+              },
+            ],
+          }),
+        ]
+      );
+      server.respondWith(
+        'GET',
+        '/changes/42/revisions/2/comments',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({
+            '/COMMIT_MSG': [],
+            'sieve.go': [
+              {
+                author: {
+                  _account_id: 1010008,
+                  name: 'Dave Borowitz',
+                  email: 'dborowitz@google.com',
+                },
+                id: '001a2067_f30f3048',
+                line: 12,
+                message: 'What on earth are you thinking, here?',
+                updated: '2015-12-12 02:51:37.973000000',
+              },
+              {
+                author: {
+                  _account_id: 1010008,
+                  name: 'Dave Borowitz',
+                  email: 'dborowitz@google.com',
+                },
+                id: '001a2067_f6b1b1c8',
+                in_reply_to: '9af53d3f_bf1cd76b',
+                line: 1,
+                side: 'PARENT',
+                message: 'Yeah not sure how this worked either?',
+                updated: '2015-12-12 02:51:37.973000000',
+              },
+              {
+                author: {
+                  _account_id: 1000000,
+                  name: 'Andrew Bonventre',
+                  email: 'andybons@gmail.com',
+                },
+                id: 'a0407443_30dfe8fb',
+                in_reply_to: '001a2067_f30f3048',
+                line: 12,
+                message: '¯\\_(ツ)_/¯',
+                updated: '2015-12-12 18:50:21.627000000',
+              },
+            ],
+          }),
+        ]
+      );
+
+      server.respondWith(
+        'PUT',
+        '/accounts/self/preferences.diff',
+        [
+          200,
+          {'Content-Type': 'application/json'},
+          ')]}\'\n' +
+          JSON.stringify({context: 25}),
+        ]
+      );
+
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    test('comments with parent', function(done) {
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 1,
+      };
+
+      element.reload();
+      server.respond();
+
+      element._diffRequestsPromise.then(function() {
+        assert.equal(element._baseComments.length, 1);
+        assert.equal(element._comments.length, 1);
+        assert.equal(element._baseDrafts.length, 0);
+        assert.equal(element._drafts.length, 0);
+        done();
+      });
+    });
+
+    test('comments between two patches', function(done) {
+      element.patchRange = {
+        basePatchNum: 1,
+        patchNum: 2,
+      };
+
+      element.reload();
+      server.respond();
+
+      element._diffRequestsPromise.then(function() {
+        assert.equal(element._baseComments.length, 1);
+        assert.equal(element._comments.length, 2);
+        assert.equal(element._baseDrafts.length, 0);
+        assert.equal(element._drafts.length, 0);
+        done();
+      });
+    });
+
+    test('comment rendering', function(done) {
+      element.prefs.context = -1;
+      element._loggedIn = true;
+      element.patchRange = {
+        basePatchNum: 1,
+        patchNum: 2,
+      };
+
+      element.reload();
+      server.respond();
+
+      // Allow events to fire and the threads to render.
+      element.async(function() {
+        var leftThreadEls =
+            Polymer.dom(element.$.leftDiff.root).querySelectorAll(
+                'gr-diff-comment-thread');
+        assert.equal(leftThreadEls.length, 1);
+        assert.equal(leftThreadEls[0].comments.length, 1);
+
+        var rightThreadEls =
+            Polymer.dom(element.$.rightDiff.root).querySelectorAll(
+                'gr-diff-comment-thread');
+        assert.equal(rightThreadEls.length, 1);
+        assert.equal(rightThreadEls[0].comments.length, 2);
+
+        var index = leftThreadEls[0].getAttribute('data-index');
+        var leftFillerEls =
+            Polymer.dom(element.$.leftDiff.root).querySelectorAll(
+                '.commentThread.filler[data-index="' + index + '"]');
+        assert.equal(leftFillerEls.length, 1);
+        var rightFillerEls =
+            Polymer.dom(element.$.rightDiff.root).querySelectorAll(
+                '[data-index="' + index + '"]');
+        assert.equal(rightFillerEls.length, 2);
+
+        for (var i = 0; i < rightFillerEls.length; i++) {
+          assert.isTrue(rightFillerEls[i].classList.contains('filler'));
+        }
+        var originalHeight = rightFillerEls[0].offsetHeight;
+        assert.equal(rightFillerEls[1].offsetHeight, originalHeight);
+        assert.equal(leftThreadEls[0].offsetHeight, originalHeight);
+        assert.equal(leftFillerEls[0].offsetHeight, originalHeight);
+
+        // Create a comment on the opposite side of the first comment.
+        var rightLineEL = element.$.rightDiff.$$(
+              '.lineNum[data-index="' + (index - 1) + '"]');
+        assert.ok(rightLineEL);
+        MockInteractions.tap(rightLineEL);
+        element.async(function() {
+          var newThreadEls =
+            Polymer.dom(element.$.rightDiff.root).querySelectorAll(
+                '[data-index="' + index + '"]');
+          assert.equal(newThreadEls.length, 2);
+          for (var i = 0; i < newThreadEls.length; i++) {
+            assert.isTrue(
+                newThreadEls[i].classList.contains('commentThread') ||
+                newThreadEls[i].tagName == 'GR-DIFF-COMMENT-THREAD');
+          }
+          var newHeight = newThreadEls[0].offsetHeight;
+          assert.equal(newThreadEls[1].offsetHeight, newHeight);
+          assert.equal(leftFillerEls[0].offsetHeight, newHeight);
+          assert.equal(leftThreadEls[0].offsetHeight, newHeight);
+
+          // The editing mode height of the right comment will be greater than
+          // the non-editing mode height of the left comment.
+          assert.isAbove(newHeight, originalHeight);
+
+          // Discard the right thread and ensure the left comment heights are
+          // back to their original values.
+          newThreadEls[1].addEventListener('discard', function() {
+            rightFillerEls =
+                Polymer.dom(element.$.rightDiff.root).querySelectorAll(
+                    '[data-index="' + index + '"]');
+            assert.equal(rightFillerEls.length, 2);
+
+            for (var i = 0; i < rightFillerEls.length; i++) {
+              assert.isTrue(rightFillerEls[i].classList.contains('filler'));
+            }
+            var originalHeight = rightFillerEls[0].offsetHeight;
+            assert.equal(rightFillerEls[1].offsetHeight, originalHeight);
+            assert.equal(leftThreadEls[0].offsetHeight, originalHeight);
+            assert.equal(leftFillerEls[0].offsetHeight, originalHeight);
+            done();
+          });
+          var commentEl = newThreadEls[1].$$('gr-diff-comment');
+          commentEl.fire('discard', null, {bubbles: false});
+        }, 1);
+      }, 1);
+    });
+
+    test('intraline normalization', function() {
+      // The content and highlights are in the format returned by the Gerrit
+      // REST API.
+      var content = [
+        '      <section class="summary">',
+        '        <gr-linked-text content="' +
+            '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+        '      </section>',
+      ];
+      var highlights = [
+        [31, 34], [42, 26]
+      ];
+      var results = element._normalizeIntralineHighlights(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 31,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 0,
+          endIndex: 33,
+        },
+        {
+          contentIndex: 1,
+          startIndex: 75,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 0,
+          endIndex: 6,
+        }
+      ]);
+
+      content = [
+        '        this._path = value.path;',
+        '',
+        '        // When navigating away from the page, there is a possibility that the',
+        '        // patch number is no longer a part of the URL (say when navigating to',
+        '        // the top-level change info view) and therefore undefined in `params`.',
+        '        if (!this._patchRange.patchNum) {',
+      ];
+      highlights = [
+        [14, 17],
+        [11, 70],
+        [12, 67],
+        [12, 67],
+        [14, 29],
+      ];
+      results = element._normalizeIntralineHighlights(content, highlights);
+      assert.deepEqual(results, [
+        {
+          contentIndex: 0,
+          startIndex: 14,
+          endIndex: 31,
+        },
+        {
+          contentIndex: 2,
+          startIndex: 8,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 3,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 4,
+          startIndex: 11,
+          endIndex: 78,
+        },
+        {
+          contentIndex: 5,
+          startIndex: 12,
+          endIndex: 41,
+        }
+      ]);
+    });
+
+    test('context', function() {
+      element.prefs.context = 3;
+      element._diffResponse = {
+        content: [
+          {
+            ab: [
+              '<!DOCTYPE html>',
+              '<meta charset="utf-8">',
+              '<title>My great page</title>',
+              '<style>',
+              '  *,',
+              '  *:before,',
+              '  *:after {',
+              '    box-sizing: border-box;',
+              '  }',
+              '</style>',
+              '<header>',
+            ]
+          },
+          {
+            a: [
+              '  Welcome ',
+              '  to the wooorld of tomorrow!',
+            ],
+            b: [
+              '  Hello, world!',
+            ],
+          },
+          {
+            ab: [
+              '</header>',
+              '<body>',
+              'Leela: This is the only place the ship can’t hear us, so ',
+              'everyone pretend to shower.',
+              'Fry: Same as every day. Got it.',
+            ]
+          },
+        ]
+      };
+      element._processContent();
+
+      // First eight lines should be hidden on both sides.
+      for (var i = 0; i < 8; i++) {
+        assert.isTrue(element._diff.leftSide[i].hidden);
+        assert.isTrue(element._diff.rightSide[i].hidden);
+      }
+      // A context control should be at index 8 on both sides.
+      var leftContext = element._diff.leftSide[8];
+      var rightContext = element._diff.rightSide[8];
+      assert.deepEqual(leftContext, rightContext);
+      assert.equal(leftContext.numLines, 8);
+      assert.equal(leftContext.start, 0);
+      assert.equal(leftContext.end, 8);
+
+      // Line indices 9-16 should be shown.
+      for (var i = 9; i <= 16; i++) {
+        // notOk (falsy) because the `hidden` attribute may not be present.
+        assert.notOk(element._diff.leftSide[i].hidden);
+        assert.notOk(element._diff.rightSide[i].hidden);
+      }
+
+      // Lines at indices 17 and 18 should be hidden.
+      assert.isTrue(element._diff.leftSide[17].hidden);
+      assert.isTrue(element._diff.rightSide[17].hidden);
+      assert.isTrue(element._diff.leftSide[18].hidden);
+      assert.isTrue(element._diff.rightSide[18].hidden);
+
+      // Context control at index 19.
+      leftContext = element._diff.leftSide[19];
+      rightContext = element._diff.rightSide[19];
+      assert.deepEqual(leftContext, rightContext);
+      assert.equal(leftContext.numLines, 2);
+      assert.equal(leftContext.start, 17);
+      assert.equal(leftContext.end, 19);
+    });
+
+    test('save prefs', function(done) {
+      element._loggedIn = false;
+
+      element.prefs = {
+        tab_size: 4,
+        context: 50,
+      };
+      element.fire('save', {}, {node: element.$$('gr-diff-preferences')});
+      assert.isTrue(element._diffPreferencesPromise == null);
+
+      element._loggedIn = true;
+      element.fire('save', {}, {node: element.$$('gr-diff-preferences')});
+      server.respond();
+
+      element._diffPreferencesPromise.then(function(req) {
+        assert.equal(req.xhr.requestBody, JSON.stringify(element.prefs));
+        done();
+      });
+    });
+
+    test('visible line length', function() {
+      assert.equal(element._visibleLineLength('A'.repeat(5)), 5);
+      assert.equal(
+          element._visibleLineLength('A'.repeat(5) + '\t' + 'A'.repeat(5)), 18);
+    });
+
+    test('break up common diff chunks', function() {
+      element._groupedBaseComments = {
+        1: {},
+      };
+      element._groupedComments = {
+        10: {},
+      };
+      var ctx = {
+        left: {lineNum: 0},
+        right: {lineNum: 0},
+      };
+      var content = [
+        {
+          ab: [
+            'Copyright (C) 2015 The Android Open Source Project',
+            '',
+            'Licensed under the Apache License, Version 2.0 (the "License");',
+            'you may not use this file except in compliance with the License.',
+            'You may obtain a copy of the License at',
+            '',
+            'http://www.apache.org/licenses/LICENSE-2.0',
+            '',
+            'Unless required by applicable law or agreed to in writing, ',
+            'software distributed under the License is distributed on an ',
+            '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
+            'either express or implied. See the License for the specific ',
+            'language governing permissions and limitations under the License.',
+          ]
+        }
+      ];
+      var result = element._breakUpCommonChunksWithComments(ctx, content);
+      assert.deepEqual(result, [
+        {
+          __noHighlight: true,
+          a: ['Copyright (C) 2015 The Android Open Source Project'],
+          b: ['Copyright (C) 2015 The Android Open Source Project'],
+        },
+        {
+          ab: [
+            '',
+            'Licensed under the Apache License, Version 2.0 (the "License");',
+            'you may not use this file except in compliance with the License.',
+            'You may obtain a copy of the License at',
+            '',
+            'http://www.apache.org/licenses/LICENSE-2.0',
+            '',
+            'Unless required by applicable law or agreed to in writing, ',
+          ]
+        },
+        {
+          __noHighlight: true,
+          a: ['software distributed under the License is distributed on an '],
+          b: ['software distributed under the License is distributed on an ']
+        },
+        {
+          ab: [
+            '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
+            'either express or implied. See the License for the specific ',
+            'language governing permissions and limitations under the License.',
+          ]
+        }
+      ]);
+    });
+  });
+
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
new file mode 100644
index 0000000..b0ee0b73
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -0,0 +1,53 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-patch-range-select">
+  <template>
+    <style>
+      :host {
+        display: block;
+      }
+      .patchRange {
+        display: inline-block;
+      }
+    </style>
+    Patch set:
+    <span class="patchRange">
+      <select id="leftPatchSelect" on-change="_handlePatchChange">
+        <option value="PARENT"
+            selected$="[[_computeLeftSelected('PARENT', patchRange)]]">Base</option>
+        <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
+          <option value$="[[patchNum]]"
+              selected$="[[_computeLeftSelected(patchNum, patchRange)]]"
+              disabled$="[[_computeLeftDisabled(patchNum, patchRange)]]">[[patchNum]]</option>
+        </template>
+      </select>
+    </span>
+    &rarr;
+    <span class="patchRange">
+      <select id="rightPatchSelect" on-change="_handlePatchChange">
+        <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
+          <option value$="[[patchNum]]"
+              selected$="[[_computeRightSelected(patchNum, patchRange)]]"
+              disabled$="[[_computeRightDisabled(patchNum, patchRange)]]">[[patchNum]]</option>
+        </template>
+      </select>
+    </span>
+  </template>
+  <script src="gr-patch-range-select.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
new file mode 100644
index 0000000..3439ecd
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-patch-range-select',
+
+    properties: {
+      availablePatches: Array,
+      changeNum: String,
+      patchRange: Object,
+      path: String,
+    },
+
+    _handlePatchChange: function(e) {
+      var leftPatch = this.$.leftPatchSelect.value;
+      var rightPatch = this.$.rightPatchSelect.value;
+      var rangeStr = rightPatch;
+      if (leftPatch != 'PARENT') {
+        rangeStr = leftPatch + '..' + rangeStr;
+      }
+      page.show('/c/' + this.changeNum + '/' + rangeStr + '/' + this.path);
+    },
+
+    _computeLeftSelected: function(patchNum, patchRange) {
+      return patchNum == patchRange.basePatchNum;
+    },
+
+    _computeRightSelected: function(patchNum, patchRange) {
+      return patchNum == patchRange.patchNum;
+    },
+
+    _computeLeftDisabled: function(patchNum, patchRange) {
+      return parseInt(patchNum, 10) >= parseInt(patchRange.patchNum, 10);
+    },
+
+    _computeRightDisabled: function(patchNum, patchRange) {
+      if (patchRange.basePatchNum == 'PARENT') { return false; }
+      return parseInt(patchNum, 10) <= parseInt(patchRange.basePatchNum, 10);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
new file mode 100644
index 0000000..a7d909e
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-patch-range-select</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../bower_components/page/page.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-patch-range-select.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-patch-range-select auto></gr-patch-range-select>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-patch-range-select tests', function() {
+    var element;
+
+    setup(function() {
+      element = fixture('basic');
+    });
+
+    test('enabled/disabled options', function() {
+      var patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '3',
+      };
+      ['1', '2', '3'].forEach(function(patchNum) {
+        assert.isFalse(element._computeRightDisabled(patchNum, patchRange));
+      });
+      ['PARENT', '1', '2'].forEach(function(patchNum) {
+        assert.isFalse(element._computeLeftDisabled(patchNum, patchRange));
+      });
+      assert.isTrue(element._computeLeftDisabled('3', patchRange));
+
+      patchRange.basePatchNum = '2';
+      assert.isTrue(element._computeLeftDisabled('3', patchRange));
+      assert.isTrue(element._computeRightDisabled('1', patchRange));
+      assert.isTrue(element._computeRightDisabled('2', patchRange));
+      assert.isFalse(element._computeRightDisabled('3', patchRange));
+    });
+
+    test('navigation', function(done) {
+      var showStub = sinon.stub(page, 'show');
+      var leftSelectEl = element.$.leftPatchSelect;
+      var rightSelectEl = element.$.rightPatchSelect;
+      element.changeNum = '42';
+      element.path = 'path/to/file.txt';
+      element.availablePatches = ['1', '2', '3'];
+      flushAsynchronousOperations();
+
+      var numEvents = 0;
+      leftSelectEl.addEventListener('change', function(e) {
+        numEvents++;
+        if (numEvents == 1) {
+          assert(showStub.lastCall.calledWithExactly(
+              '/c/42/3/path/to/file.txt'),
+              'Should navigate to /c/42/3/path/to/file.txt');
+          leftSelectEl.value = '1';
+          element.fire('change', {}, {node: leftSelectEl});
+        } else if (numEvents == 2) {
+          assert(showStub.lastCall.calledWithExactly(
+              '/c/42/1..3/path/to/file.txt'),
+              'Should navigate to /c/42/1..3/path/to/file.txt');
+          showStub.restore();
+          done();
+        }
+      });
+      leftSelectEl.value = 'PARENT';
+      rightSelectEl.value = '3';
+      element.fire('change', {}, {node: leftSelectEl});
+    });
+  });
+</script>