blob: a3d8260b2e5a4048cc59f122661092cd7a6feddf [file] [log] [blame]
<!--
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="gr-ajax.html">
<link rel="import" href="gr-diff-preferences.html">
<link rel="import" href="gr-diff-side.html">
<link rel="import" href="gr-overlay.html">
<link rel="import" href="gr-patch-range-select.html">
<link rel="import" href="gr-request.html">
<dom-module id="gr-diff">
<template>
<style>
.loading {
padding: 0 var(--default-horizontal-margin) 1em;
color: #666;
}
.header {
display: flex;
margin: 0 var(--default-horizontal-margin) .75em;
}
.prefsButton {
flex: 1;
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;
white-space: pre;
}
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>
<a class="prefsButton"
href="#"
on-tap="_handlePrefsTap"
hidden$="[[!prefs]]"
hidden>Diff View Preferences</a>
</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>
(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.
*/
_groupedBaseComments: {
type: Object,
value: function() { return {}; },
},
/**
* Comments and drafts (right side) grouped by line number.
*/
_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);
},
reload: function(changeNum, patchRange, path) {
var promises = [
this._prefsReady,
this.$.diffXHR.generateRequest().completes
];
var basePatchNum = this.patchRange.basePatchNum;
var patchNum = this.patchRange.patchNum;
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) {
var promises = [];
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: [] };
var inCommentGroup = false;
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));
},
});
})();
</script>
</dom-module>