Draft comments initial implementation

This implements basic functionality for draft CRUD operations.

There are a few things that are TBD:
+ Layout edge cases within the diff view.
+ Reply/Done actions in threads.
+ Not allowing the user to add drafts if logged out.
+ I’m sure a few more things...

Feature: Issue 3649
Change-Id: Ia7419eecee5d5b20e73e17241990d7a7ffede0e8
diff --git a/lib/js/BUCK b/lib/js/BUCK
index d03753e..060788c 100644
--- a/lib/js/BUCK
+++ b/lib/js/BUCK
@@ -105,6 +105,21 @@
 )
 
 bower_component(
+  name = 'iron-autogrow-textarea',
+  package = 'polymerelements/iron-autogrow-textarea',
+  version = '1.0.10',
+  deps = [
+    ':iron-behaviors',
+    ':iron-flex-layout',
+    ':iron-form-element-behavior',
+    ':iron-validatable-behavior',
+    ':polymer',
+  ],
+  license = 'polymer',
+  sha1 = 'd368240e60a4b02ffc731ad8f45f3c8bbf47e9bd',
+)
+
+bower_component(
   name = 'iron-behaviors',
   package = 'polymerelements/iron-behaviors',
   version = '1.0.11',
@@ -151,6 +166,15 @@
 )
 
 bower_component(
+  name = 'iron-form-element-behavior',
+  package = 'polymerelements/iron-form-element-behavior',
+  version = '1.0.6',
+  deps = [':polymer'],
+  license = 'polymer',
+  sha1 = '8d9e6530edc1b99bec1a5c34853911fba3701220',
+)
+
+bower_component(
   name = 'iron-input',
   package = 'polymerelements/iron-input',
   version = '1.0.6',
@@ -206,7 +230,6 @@
   name = 'iron-test-helpers',
   package = 'polymerelements/iron-test-helpers',
   version = '1.0.6',
-  semver = '~1.0.6',
   deps = [':polymer'],
   license = 'DO_NOT_DISTRIBUTE',
   sha1 = 'c0f7c7f010ca3c63fb08ae0d9462e400380cde2c',
diff --git a/polygerrit-ui/.gitignore b/polygerrit-ui/.gitignore
index af8e683..73b5221 100644
--- a/polygerrit-ui/.gitignore
+++ b/polygerrit-ui/.gitignore
@@ -1,5 +1,6 @@
 node_modules
 npm-debug.log
 dist
+bower.json
 bower_components
 .tmp
diff --git a/polygerrit-ui/BUCK b/polygerrit-ui/BUCK
index af42cab..15b11f5 100644
--- a/polygerrit-ui/BUCK
+++ b/polygerrit-ui/BUCK
@@ -5,6 +5,7 @@
   deps = [
     '//lib/js:iron-a11y-keys-behavior',
     '//lib/js:iron-ajax',
+    '//lib/js:iron-autogrow-textarea',
     '//lib/js:iron-dropdown',
     '//lib/js:iron-input',
     '//lib/js:page',
diff --git a/polygerrit-ui/app/elements/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/gr-diff-comment-thread.html
index b908cf7..5454914 100644
--- a/polygerrit-ui/app/elements/gr-diff-comment-thread.html
+++ b/polygerrit-ui/app/elements/gr-diff-comment-thread.html
@@ -27,7 +27,12 @@
       }
     </style>
     <template id="commentList" is="dom-repeat" items="{{_orderedComments}}" as="comment">
-      <gr-diff-comment comment="[[comment]]"></gr-diff-comment>
+      <gr-diff-comment
+          comment="{{comment}}"
+          change-num="[[changeNum]]"
+          patch-num="[[patchNum]]"
+          draft="[[comment.__draft]]"
+          editing="[[!comment.message]]"></gr-diff-comment>
     </template>
   </template>
   <script>
@@ -44,17 +49,31 @@
        * @event gr-diff-comment-thread-height-changed
        */
 
+      /**
+       * Fired when the thread should be discarded.
+       *
+       * @event gr-diff-comment-thread-discard
+       */
+
       properties: {
+        changeNum: String,
         comments: {
           type: Array,
+          value: function() { return []; },
           observer: '_commentsChanged',
         },
+        patchNum: String,
+
         _orderedComments: Array,
       },
 
       ready: function() {
         this.addEventListener('gr-diff-comment-height-changed',
             this._handleCommentHeightChange.bind(this));
+        this.addEventListener('gr-diff-comment-reply',
+            this._handleCommentReply.bind(this));
+        this.addEventListener('gr-diff-comment-discard',
+            this._handleCommentDiscard.bind(this));
       },
 
       _commentsChanged: function(comments) {
@@ -62,7 +81,6 @@
       },
 
       _sortedComments: function(comments) {
-        var comments = comments || [];
         comments.sort(function(c1, c2) {
           return util.parseDate(c1.updated) - util.parseDate(c2.updated);
         });
@@ -105,6 +123,37 @@
             {height: this.offsetHeight});
       },
 
+      _handleCommentReply: function(e) {
+        console.log('should add reply...')
+      },
+
+      _handleCommentDiscard: function(e) {
+        var diffCommentEl = e.target;
+        var idx = this._indexOf(diffCommentEl.comment, this.comments);
+        if (idx == -1) {
+          throw Error('Cannot find comment ' +
+              JSON.stringify(diffCommentEl.comment));
+        }
+        this.comments.splice(idx, 1);
+        this._commentsChanged(this.comments);
+        if (this.comments.length == 0 && this.parentNode) {
+          this.parentNode.removeChild(this);
+        }
+        this.fire('gr-diff-comment-thread-height-changed',
+            {height: this.offsetHeight});
+      },
+
+      _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;
+      },
+
     });
   })();
   </script>
diff --git a/polygerrit-ui/app/elements/gr-diff-comment.html b/polygerrit-ui/app/elements/gr-diff-comment.html
index 343f7d3..f137996 100644
--- a/polygerrit-ui/app/elements/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/gr-diff-comment.html
@@ -15,6 +15,9 @@
 -->
 
 <link rel="import" href="../bower_components/polymer/polymer.html">
+<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="../bower_components/iron-ajax/iron-request.html">
 <link rel="import" href="gr-date-formatter.html">
 
 <dom-module id="gr-diff-comment">
@@ -24,6 +27,12 @@
         border: 1px solid #ddd;
         display: block;
       }
+      :host([disabled]) {
+        pointer-events: none;
+      }
+      :host([disabled]) .container {
+        opacity: .5;
+      }
       .header,
       .message,
       .actions {
@@ -31,32 +40,113 @@
       }
       .header {
         background-color: #eee;
+        display: flex;
         font-family: 'Open Sans', sans-serif;
       }
-      gr-date-formatter {
-        float: right;
+      .headerLeft {
+        flex: 1;
+      }
+      .authorName,
+      .draftLabel {
+        font-weight: bold;
+      }
+      .draftLabel {
+        color: #999;
+        display: none;
+      }
+      .date {
+        justify-content: flex-end;
         margin-left: 5px;
       }
-      .authorName {
-        font-weight: bold;
+      a.date:link,
+      a.date:visited {
+        color: #666;
+        text-decoration: none;
+      }
+      a.date:hover {
+        text-decoration: underline;
       }
       .message {
         white-space: pre-wrap;
       }
       .actions {
-        /** TODO: remove once the actions actually do something. **/
-        display: none;
+        display: flex;
         padding-top: 0;
       }
+      .action {
+        margin-right: 1em;
+      }
+      .action[disabled] {
+        opacity: .5;
+        pointer-events: none;
+      }
+      .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(.done) {
+        display: none;
+      }
+      .draft .reply,
+      .draft .done {
+        display: none;
+      }
+      .draft .draftLabel {
+        display: inline;
+      }
+      .draft:not(.editing) .save,
+      .draft:not(.editing) .cancel {
+        display: none;
+      }
+      .editing .message,
+      .editing .reply,
+      .editing .done,
+      .editing .edit {
+        display: none;
+      }
+      .editing .editMessage {
+        display: block;
+      }
     </style>
-    <div class="header" id="header">
-      <span class="authorName">[[comment.author.name]]</span>
-      <gr-date-formatter date-str="[[comment.updated]]"></gr-date-formatter>
-    </div>
-    <div class="message">[[comment.message]]</div>
-    <div class="actions">
-      <a class="reply" href="#" on-tap="_handleReply">Reply</a>
-      <a class="done" href="#" on-tap="_handleDone">Done</a>
+    <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"
+          max-rows="4"
+          bind-value="{{_editDraft}}"></iron-autogrow-textarea>
+      <div class="message">[[comment.message]]</div>
+      <div class="actions">
+        <a class="action reply" href="#" on-tap="_handleReply">Reply</a>
+        <a class="action done" href="#" on-tap="_handleDone">Done</a>
+        <a class="action edit" href="#" on-tap="_handleEdit">Edit</a>
+        <a class="action save" href="#"
+            disabled$="[[_computeSaveDisabled(_editDraft)]]"
+            on-tap="_handleSave">Save</a>
+        <a class="action cancel" href="#" on-tap="_handleCancel">Cancel</a>
+        <div class="danger">
+          <a class="action discard" href="#" on-tap="_handleDiscard">Discard</a>
+        </div>
+      </div>
     </div>
   </template>
   <script>
@@ -84,17 +174,81 @@
        * @event gr-diff-comment-done
        */
 
+      /**
+       * Fired when this comment is discarded.
+       *
+       * @event gr-diff-comment-discard
+       */
+
       properties: {
-        comment: Object,
+        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,
+
+        _xhrPromise: Object,
+        _editDraft: String,
       },
 
       attached: function() {
-        this.fire('gr-diff-comment-height-changed',
+        this._heightChanged();
+      },
+
+      _heightChanged: function() {
+        this.async(function() {
+          this.fire('gr-diff-comment-height-changed',
             {height: this.offsetHeight});
+        }.bind(this));
+      },
+
+      _draftChanged: function(draft) {
+        this.$.container.classList.toggle('draft', draft);
+      },
+
+      _editingChanged: function(editing) {
+        this.$.container.classList.toggle('editing', editing);
+        if (editing) {
+          this.async(function() {
+            this.$.editTextarea.textarea.focus();
+          }.bind(this));
+        }
+        this._heightChanged();
+      },
+
+      _computeLinkToComment: function(comment) {
+        return '#' + comment.line;
+      },
+
+      _computeSaveDisabled: function(draft) {
+        return draft == null || draft.trim() == '';
+      },
+
+      _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) {
@@ -107,6 +261,89 @@
         this.fire('gr-diff-comment-done');
       },
 
+      _handleEdit: function(e) {
+        e.preventDefault();
+        this._editDraft = this.comment.message;
+        this.editing = true;
+      },
+
+      _handleSave: function(e) {
+        e.preventDefault();
+        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));
+      },
+
+      _handleCancel: function(e) {
+        e.preventDefault();
+        if (this.comment.message == null || this.comment.message.length == 0) {
+          this.fire('gr-diff-comment-discard');
+          return;
+        }
+        this._editDraft = this.comment.message;
+        this.editing = false;
+      },
+
+      _handleDiscard: function(e) {
+        e.preventDefault();
+        if (!this.comment.__draft) {
+          throw Error('Cannot discard a non-draft comment.');
+        }
+        this.disabled = true;
+        var commentID = this.comment.id;
+        if (!commentID) {
+          this.fire('gr-diff-comment-discard');
+          return;
+        }
+        this._send('DELETE', this._restEndpoint(commentID)).then(function(req) {
+          this.fire('gr-diff-comment-discard', this.comment);
+        }.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));
+      },
+
+      _send: function(method, url) {
+        var xhr = document.createElement('iron-request');
+        this._xhrPromise = xhr.send({
+          method: method,
+          headers: {
+            'Content-Type': 'application/json',
+            'X-Gerrit-Auth': util.getCookie('XSRF_TOKEN'),
+          },
+          url: url,
+          body: this.comment,
+          jsonPrefix: ')]}\'',
+        });
+        return this._xhrPromise;
+      },
+
+      _restEndpoint: function(id) {
+        var path = '/changes/' + this.changeNum + '/revisions/' +
+            this.patchNum + '/drafts';
+        if (id) {
+          path += '/' + id;
+        }
+        return path;
+      },
+
     });
   })();
   </script>
diff --git a/polygerrit-ui/app/elements/gr-diff-view.html b/polygerrit-ui/app/elements/gr-diff-view.html
index 6b2f3f5..81122c3 100644
--- a/polygerrit-ui/app/elements/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/gr-diff-view.html
@@ -81,10 +81,12 @@
       .content {
         position: relative;
       }
-      .lineNum.blank {
+      .lineNum.blank,
+      .threadFiller--redLine {
         border-right: 2px solid #F34D4D;
         margin-right: 3px;
       }
+
       .lineNum:not(.blank) {
         cursor: pointer;
       }
@@ -133,6 +135,16 @@
         url="[[_computeFilesPath(_changeNum, _patchNum)]]"
         json-prefix=")]}'"
         on-response="_handleFilesResponse"></iron-ajax>
+    <iron-ajax
+        id="leftDraftsXHR"
+        url="[[_computeDraftsPath(_changeNum, _basePatchNum)]]"
+        json-prefix=")]}'"
+        on-response="_handleLeftDraftsResponse"></iron-ajax>
+    <iron-ajax
+        id="rightDraftsXHR"
+        url="[[_computeDraftsPath(_changeNum, _patchNum)]]"
+        json-prefix=")]}'"
+        on-response="_handleRightDraftsResponse"></iron-ajax>
     <h3>
       <a href$="[[_computeChangePath(_changeNum)]]">[[_changeNum]]</a><span>:</span>
       <span>[[_change.subject]]</span> — <span>[[params.path]]</span>
@@ -184,11 +196,25 @@
           type: Array,
           value: function() { return []; },
         },
-        _leftComments: Array,
+        _leftComments: {
+          type: Array,
+          value: function() { return []; },
+        },
+        _leftDrafts: {
+          type: Array,
+          value: function() { return []; },
+        },
         _patchNum: String,
         _path: String,
         _rendered: Boolean,
-        _rightComments: Array,
+        _rightComments: {
+          type: Array,
+          value: function() { return []; },
+        },
+        _rightDrafts: {
+          type: Array,
+          value: function() { return []; },
+        },
       },
 
       listeners: {
@@ -210,21 +236,48 @@
           this._patchNum = null;
           this._diff = null;
           this._path = null;
-          this._leftComments = null;
-          this._rightComments = null;
+          this._leftComments = [];
+          this._rightComments = [];
+          this._leftDrafts = [];
+          this._rightDrafts = [];
           this._rendered = false;
           return;
         }
         // Assign the params here since a computed binding relying on
         // `_basePatchNum` won't fire in the case where it's not defined.
         this.$.diffXHR.params = this._diffQueryParams(this._basePatchNum);
-        this.$.diffXHR.generateRequest();
+
+        var requestPromises = [];
+        requestPromises.push(this.$.diffXHR.generateRequest().completes);
 
         if (this._basePatchNum) {
-          this.$.leftCommentsXHR.generateRequest();
+          requestPromises.push(
+              this.$.leftCommentsXHR.generateRequest().completes);
         }
-        this.$.rightCommentsXHR.generateRequest();
-        this.$.filesXHR.generateRequest();
+
+        requestPromises.push(
+            this.$.rightCommentsXHR.generateRequest().completes);
+        requestPromises.push(this.$.filesXHR.generateRequest().completes);
+
+        app.accountReady.then(function() {
+          if (app.loggedIn) {
+            if (this._basePatchNum) {
+              requestPromises.push(
+                  this.$.leftDraftsXHR.generateRequest().completes);
+            }
+            requestPromises.push(
+                this.$.rightDraftsXHR.generateRequest().completes);
+          }
+
+          Promise.all(requestPromises).then(function(requests) {
+            this._renderDiff(this._diff, this._leftComments,
+                this._rightComments, this._leftDrafts, this._rightDrafts);
+          }.bind(this), function(err) {
+            alert('Oops. Something went wrong. Check the console and bug the ' +
+                'PolyGerrit team for assistance.');
+            throw err;
+          });
+        }.bind(this));
       },
 
       _rulerWidthChanged: function(newValue, oldValue) {
@@ -273,6 +326,10 @@
         return '/changes/' + changeNum + '/revisions/' + patchNum + '/files';
       },
 
+      _computeDraftsPath: function(changeNum, patchNum) {
+        return '/changes/' + changeNum + '/revisions/' + patchNum + '/drafts';
+      },
+
       _diffQueryParams: function(basePatchNum) {
         var params =  {
           context: 'ALL',
@@ -286,21 +343,65 @@
 
       _diffContainerTapHandler: function(e) {
         var el = e.detail.sourceEvent.target;
-        if (el.classList.contains('lineNum')) {
-          // TODO: Implement adding draft comments.
+        // This tap handler only handles line number taps.
+        if (!el.classList.contains('lineNum')) { return; }
+
+        var leftSide = el.parentNode == this.$.leftDiffNumbers;
+        var rightSide = el.parentNode == this.$.rightDiffNumbers;
+        if (leftSide == rightSide) {
+          throw Error('Comment tap event cannot originate from both left and ' +
+              'right side');
         }
+
+        // If a draft or comment is already present at that line, don’t do
+        // anything.
+        var lineNum = el.getAttribute('data-line-num');
+        var patchNum = el.getAttribute('data-patch-num');
+
+        var existingEl = this.$$('gr-diff-comment-thread' +
+            '[data-patch-num="' + patchNum + '"]' +
+            '[data-line-num="' + lineNum + '"]');
+        if (existingEl) {
+          // A comment or draft is already present at this line.
+          return;
+        }
+
+        var tempDraftID = Math.floor(Math.random() * Math.pow(10, 10)) + '';
+        var drafts = [{
+          __draft: true,
+          __draftID: tempDraftID,
+          path: this._path,
+          line: lineNum,
+        }];
+
+        // If the comment is on the left side of a side-by-side diff with the
+        // parent on the left and a patch with patchNum on the right, the patch
+        // number passed to the backend is the right side patchNum when mutating
+        // a draft. The property `side` is used to determine that it should be
+        // on the parent patch, which is inconsistent and why this looks weird.
+        var patchNum = this._patchNum;
+        if (leftSide && this._basePatchNum == null) {
+          drafts[0].side = 'PARENT';
+          patchNum = 'PARENT';
+        }
+
+        this._addThread(drafts, patchNum, lineNum);
       },
 
       _handleLeftCommentsResponse: function(e, req) {
         this._leftComments = e.detail.response[this._path] || [];
-        this._maybeRenderDiff(this._diff, this._leftComments,
-            this._rightComments);
       },
 
       _handleRightCommentsResponse: function(e, req) {
         this._rightComments = e.detail.response[this._path] || [];
-        this._maybeRenderDiff(this._diff, this._leftComments,
-            this._rightComments);
+      },
+
+      _handleLeftDraftsResponse: function(e, req) {
+        this._leftDrafts = e.detail.response[this._path] || [];
+      },
+
+      _handleRightDraftsResponse: function(e, req) {
+        this._rightDrafts = e.detail.response[this._path] || [];
       },
 
       _handleFilesResponse: function(e, req) {
@@ -309,8 +410,6 @@
 
       _handleDiffResponse: function(e, req) {
         this._diff = e.detail.response;
-        this._maybeRenderDiff(this._diff, this._leftComments,
-            this._rightComments);
       },
 
       _handleKey: function(e) {
@@ -351,9 +450,17 @@
         return 'thread-' + patchNum + '-' + lineNum;
       },
 
-      _renderComments: function(comments, patchNum) {
-        // Group the comments by line number. Absense of a line number indicates
-        // a top-level file comment.
+      _renderCommentsAndDrafts: function(comments, drafts, patchNum) {
+        // Drafts and comments are combined here, with drafts annotated with a
+        // property.
+        var annotatedDrafts = drafts.map(function(d) {
+          d.__draft = true;
+          return d;
+        });
+        comments = comments.concat(annotatedDrafts);
+
+        // Group the comments and drafts by line number. Absence of a line
+        // number indicates a top-level file comment or draft.
         var threads = {};
 
         for (var i = 0; i < comments.length; i++) {
@@ -371,8 +478,16 @@
       _addThread: function(comments, patchNum, lineNum) {
         var el = document.createElement('gr-diff-comment-thread');
         el.comments = comments;
+        el.changeNum = this._changeNum;
+        // Assign the element's patchNum to the right side patchNum if the
+        // passed patchNum is 'PARENT' do to the odd behavior of the REST API.
+        // Don't overwrite patchNum since 'PARENT' is used for other properties.
+        el.patchNum = patchNum == 'PARENT' ? this._patchNum : patchNum;
+
         var threadID = this._threadID(patchNum, lineNum);
         el.setAttribute('data-thread-id', threadID);
+        el.setAttribute('data-line-num', lineNum);
+        el.setAttribute('data-patch-num', patchNum);
 
         // Find the element that the thread should be appended after. In the
         // case of a file comment, it will be appended after the first line.
@@ -406,7 +521,7 @@
         var els = Polymer.dom(this.root).querySelectorAll(
             '[data-row-num="' + rowNum + '"]');
         for (var i = 0; i < els.length; i++) {
-          // Is this is the side with the comment? Skip if so.
+          // Is this is the column with the comment? Skip if so.
           if (els[i].nextSibling &&
               els[i].nextSibling.tagName == 'GR-DIFF-COMMENT-THREAD') {
             continue;
@@ -414,6 +529,9 @@
           var fillerEl = document.createElement('div');
           fillerEl.setAttribute('data-thread-id', threadID);
           fillerEl.classList.add('js-threadFiller');
+          if (els[i].classList.contains('lineNum')) {
+            fillerEl.classList.add('threadFiller--redLine');
+          }
           fillerEl.style.height = e.detail.height + 'px';
           Polymer.dom(els[i].parentNode).insertBefore(
               fillerEl, els[i].nextSibling);
@@ -426,16 +544,14 @@
         }
       },
 
-      _maybeRenderDiff: function(diff, leftComments, rightComments) {
+      _renderDiff: function(
+          diff, leftComments, rightComments, leftDrafts, rightDrafts) {
         if (this._rendered) {
           this._clearChildren(this.$.leftDiffNumbers);
           this._clearChildren(this.$.leftDiffContent);
           this._clearChildren(this.$.rightDiffNumbers);
           this._clearChildren(this.$.rightDiffContent);
         }
-        if (!diff || !diff.content) { return; }
-        if (this._basePatchNum && leftComments == null) { return; }
-        if (rightComments == null) { return; }
 
         this.$.diffContainer.classList.toggle('rightOnly',
             diff.change_type == Changes.DiffType.ADDED);
@@ -461,10 +577,12 @@
         }
 
         if (leftComments) {
-          this._renderComments(leftComments, this._basePatchNum);
+          this._renderCommentsAndDrafts(leftComments, leftDrafts,
+              this._basePatchNum);
         }
         if (rightComments) {
-          this._renderComments(rightComments, this._patchNum);
+          this._renderCommentsAndDrafts(rightComments, rightDrafts,
+              this._patchNum);
         }
 
         if (this.rulerWidth) {
@@ -561,15 +679,13 @@
           el.setAttribute('data-row-num', ctx.rowNum);
         });
 
-        var self = this;
-        if (this._basePatchNum) {
-          [leftLineNumEl, leftColEl].forEach(function(el) {
-            el.setAttribute('data-patch-num', self._basePatchNum);
-          });
-        }
+        [leftLineNumEl, leftColEl].forEach(function(el) {
+          el.setAttribute('data-patch-num', this._basePatchNum || 'PARENT');
+        }.bind(this));
+
         [rightLineNumEl, rightColEl].forEach(function(el) {
-          el.setAttribute('data-patch-num', self._patchNum);
-        });
+          el.setAttribute('data-patch-num', this._patchNum);
+        }.bind(this));
 
         if (ctx.left.content != null) {
           leftLineNumEl.textContent = ctx.left.lineNum;
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index 2f5ecaf..93a0349 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -47,3 +47,18 @@
          target.tagName == 'BUTTON' ||
          target.tagName == 'A';
 };
+
+util.getCookie = function(name) {
+  var key = name + '=';
+  var cookies = document.cookie.split(';');
+  for(var i = 0; i < cookies.length; i++) {
+    var c = cookies[i];
+    while (c.charAt(0) == ' ') {
+      c = c.substring(1);
+    }
+    if (c.indexOf(key) == 0) {
+      return c.substring(key.length, c.length);
+    }
+  }
+  return '';
+};
diff --git a/polygerrit-ui/app/test/gr-diff-comment-test.html b/polygerrit-ui/app/test/gr-diff-comment-test.html
index 4c2b8c2..2e7f9bf 100644
--- a/polygerrit-ui/app/test/gr-diff-comment-test.html
+++ b/polygerrit-ui/app/test/gr-diff-comment-test.html
@@ -20,6 +20,8 @@
 
 <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="../elements/gr-diff-comment.html">
@@ -30,11 +32,27 @@
   </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) {
@@ -51,5 +69,185 @@
       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.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.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.$$('.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.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
+      assert.isFalse(isVisible(element.$$('.reply')), 'reply 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')), 'edit is visible');
+      assert.isTrue(isVisible(element.$$('.done')), 'edit is visible');
+
+      element.draft = true;
+    });
+
+    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('gr-diff-comment-discard', function(e) {
+        numDiscardEvents++;
+        if (numDiscardEvents == 2) {
+          done();
+        }
+      });
+      MockInteractions.tap(element.$$('.cancel'));
+      MockInteractions.tap(element.$$('.discard'));
+    });
+
+    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('proper event fires on done', function(done) {
+      element.addEventListener('gr-diff-comment-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();
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/test/gr-diff-view-test.html b/polygerrit-ui/app/test/gr-diff-view-test.html
index 09472a3..8a6ac3b 100644
--- a/polygerrit-ui/app/test/gr-diff-view-test.html
+++ b/polygerrit-ui/app/test/gr-diff-view-test.html
@@ -45,7 +45,6 @@
   </template>
 </test-fixture>
 
-
 <script>
   // Original diff:
   // Left side (side A):
@@ -101,7 +100,8 @@
 
     setup(function() {
       element = fixture('basic');
-      element._maybeRenderDiff({content: diffContent}, [], []);
+      element._renderDiff({content: diffContent}, [], [], [], []);
+      flushAsynchronousOperations();
     });
 
     test('ab content is the same for left and right sides', function() {
@@ -174,62 +174,79 @@
         assert.equal(els[i].style.left, '100ch');
       }
     });
-
   });
 
-  suite('comments', function() {
+  suite('comments and drafts', function() {
     var element;
 
-    setup(function() {
+    setup(function(done) {
       element = fixture('comments');
       element._patchNum = 1;
-      element._maybeRenderDiff({content: diffContent}, [], [
-        {
-          id: 'file_comment',
-          message: 'this is a file comment about the meaninglessness of life',
-          author: {
-            name: 'GLaDOS'
-          }
-        },
-        {
-          id: 'all_the_lemons',
-          line: 8,
-          message: 'MAKE LIFE TAKE THE LEMONS BACK',
-          author: {
-            name: 'Cave Johnson',
+      element._renderDiff({content: diffContent}, [], [
+          {
+            id: 'file_comment',
+            message: 'this is a file comment about the meaninglessness of life',
+            author: {
+              name: 'GLaDOS'
+            }
           },
-        }
-      ]);
+          {
+            id: 'all_the_lemons',
+            line: 8,
+            message: 'MAKE LIFE TAKE THE LEMONS BACK',
+            author: {
+              name: 'Cave Johnson',
+            }
+          }
+      ], [], []);
+
+      // On WebKit and Gecko, flushAsynchronousOperations isn't enough to allow
+      // the thread filler elements to properly render. Spin the runloop.
+      element.async(function() {
+        done();
+      }, 1);
     });
 
-    test('comment threads are rendered correctly', function(done) {
-      // On WebKit and Gecko, flushAsynchronousOperations isn't enough to allow
-      // the thread filler elements to properly render. Wait for the resize
-      // events that trigger their addition and check after the expected number
-      // come in.
-      var numEventsFired = 0;
-      element.addEventListener('gr-diff-comment-thread-height-changed',
-          function() {
-        numEventsFired++;
-        if (numEventsFired < 2) { return; }
-        assert.equal(numEventsFired, 2);
+    test('comment threads are rendered correctly', function() {
+      var threadEls = Polymer.dom(element.root).querySelectorAll(
+          'gr-diff-comment-thread[data-thread-id="thread-1-8"]');
+      assert.equal(threadEls.length, 1);
+      var fillerEls = Polymer.dom(element.root).querySelectorAll(
+          '.js-threadFiller[data-thread-id="thread-1-8"]');
+      assert.equal(fillerEls.length, 3);
 
-        var threadEls = Polymer.dom(element.root).querySelectorAll(
-            'gr-diff-comment-thread[data-thread-id="thread-1-8"]');
-        assert.equal(threadEls.length, 1);
-        var fillerEls = Polymer.dom(element.root).querySelectorAll(
-            '.js-threadFiller[data-thread-id="thread-1-8"]');
-        assert.equal(fillerEls.length, 3);
+      threadEls = Polymer.dom(element.root).querySelectorAll(
+          'gr-diff-comment-thread[data-thread-id="thread-1-FILE"]');
+      assert.equal(threadEls.length, 1);
+      fillerEls = Polymer.dom(element.root).querySelectorAll(
+          '.js-threadFiller[data-thread-id="thread-1-FILE"]');
+      assert.equal(fillerEls.length, 3);
+    });
 
-        threadEls = Polymer.dom(element.root).querySelectorAll(
-            'gr-diff-comment-thread[data-thread-id="thread-1-FILE"]');
-        assert.equal(threadEls.length, 1);
-        fillerEls = Polymer.dom(element.root).querySelectorAll(
-            '.js-threadFiller[data-thread-id="thread-1-FILE"]');
-        assert.equal(fillerEls.length, 3);
+    test('tapping a line with an existing thread', function() {
+      var threadEls = Polymer.dom(element.root).querySelectorAll(
+          'gr-diff-comment-thread[data-line-num="8"][data-patch-num="1"]');
+      assert.equal(threadEls.length, 1);
+      var lineEl = element.$$(
+          '.lineNum[data-line-num="8"][data-patch-num="1"]');
+      assert.ok(lineEl);
+      MockInteractions.tap(lineEl);
+      threadEls = Polymer.dom(element.root).querySelectorAll(
+          'gr-diff-comment-thread[data-line-num="8"][data-patch-num="1"]');
+      assert.equal(threadEls.length, 1);
+    });
 
-        done();
-      });
+    test('creating a draft', function() {
+      var threadEls = Polymer.dom(element.root).querySelectorAll(
+          'gr-diff-comment-thread[data-line-num="5"][data-patch-num="1"]');
+      assert.equal(threadEls.length, 0);
+      var lineEl = element.$$(
+          '.lineNum[data-line-num="5"][data-patch-num="1"]');
+      assert.ok(lineEl);
+      MockInteractions.tap(lineEl);
+      threadEls = Polymer.dom(element.root).querySelectorAll(
+          'gr-diff-comment-thread[data-line-num="5"][data-patch-num="1"]');
+      assert.equal(threadEls.length, 1);
     });
   });
 
@@ -242,7 +259,7 @@
       for (var i = 0; i < 300; i++) {
         longDiffContent[0].ab.push('');
       }
-      element._maybeRenderDiff({content: longDiffContent}, [], []);
+      element._renderDiff({content: longDiffContent}, [], [], [], []);
     });
 
     function isVisibleInWindow(el) {