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
+ // '<span>'. Advancing from index 0 on the prior html string would
+ // return 4, since < 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., <) 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 +
+ ' <gr-linked-text content="' +
+ element._highlightEndTag +
+ '[[_computeCurrentRevisionMessage(change)]]' +
+ element._highlightStartTag +
+ '"></gr-linked-text>' +
+ 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">▼</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>
+ →
+ <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>