gerrit/polygerrit-ui/app/elements/gr-diff.html
Andrew Bonventre b3caf48ffa Persist diff context length on server
This code will eventually live within a diff preference pane
once options other than context are supported.

Change-Id: I3c752a18d163a55cb3398c0a46544593b0a9faed
2016-01-08 13:28:37 -05:00

733 lines
24 KiB
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.
-->
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="gr-ajax.html">
<link rel="import" href="gr-diff-side.html">
<link rel="import" href="gr-patch-range-select.html">
<link rel="import" href="gr-request.html">
<dom-module id="gr-diff">
<template>
<style>
.header {
display: flex;
margin: 0 var(--default-horizontal-margin) .75em;
}
.contextControl {
flex: 1;
text-align: right;
}
.diffContainer {
border-bottom: 1px solid #eee;
border-top: 1px solid #eee;
display: flex;
font-family: 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}}"></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="header">
<gr-patch-range-select
path="[[path]]"
change-num="[[changeNum]]"
patch-range="[[patchRange]]"
available-patches="[[availablePatches]]"></gr-patch-range-select>
<div class="contextControl" hidden$="[[!prefs.context]]" hidden>
Context:
<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>
<div class="diffContainer">
<gr-diff-side id="leftDiff"
change-num="[[changeNum]]"
patch-num="[[patchRange.basePatchNum]]"
path="[[path]]"
content="{{_diff.leftSide}}"
width="[[sideWidth]]"
can-comment="[[_loggedIn]]"
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}}"
width="[[sideWidth]]"
can-comment="[[_loggedIn]]"
on-expand-context="_handleExpandContext"
on-thread-height-change="_handleThreadHeightChange"
on-add-draft="_handleAddDraft"
on-remove-thread="_handleRemoveThread"></gr-diff-side>
</div>
</template>
<script>
(function() {
'use strict';
Polymer({
is: 'gr-diff',
/**
* Fired when the diff is rendered.
*
* @event render
*/
properties: {
auto: {
type: Boolean,
value: false,
},
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,
sideWidth: {
type: Number,
value: 80,
},
prefs: {
type: Object,
notify: true,
},
_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,
},
_diffRequestsPromise: Object, // Used for testing.
_diffPreferencesPromise: Object, // Used for testing.
},
observers: [
'_prefsChanged(prefs.*)',
'_diffOptionsChanged(changeNum, patchRange, path)',
],
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);
},
_prefsChanged: function(changeRecord) {
var prefs = changeRecord.base;
this.$.contextSelect.value = prefs.context;
if (this._initialRenderComplete) {
this._render();
}
this._resolvePrefsReady(prefs);
},
_diffOptionsChanged: function(changeNum, patchRange, path) {
if (!this.auto) { return; }
var promises = [
this._prefsReady,
this.$.diffXHR.generateRequest().completes
];
var basePatchNum = patchRange.basePatchNum;
var patchNum = patchRange.patchNum;
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));
},
_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 Changes.baseURL(changeNum, patchNum) + '/files/' +
encodeURIComponent(path) + '/diff';
},
_computeCommentsPath: function(changeNum, patchNum) {
return Changes.baseURL(changeNum, patchNum) + '/comments';
},
_computeDraftsPath: function(changeNum, patchNum) {
return Changes.baseURL(changeNum, patchNum) + '/drafts';
},
_computeDiffQueryParams: function(basePatchNum) {
var params = {
context: 'ALL',
intraline: null
};
if (basePatchNum != 'PARENT') {
params.base = basePatchNum;
}
return params;
},
_handleContextSelectChange: function(e) {
var selectEl = Polymer.dom(e).rootTarget;
this.set('prefs.context', parseInt(selectEl.value, 10));
app.accountReady.then(function() {
if (!this._loggedIn) { return; }
this._diffPreferencesPromise =
this._saveDiffPreferences().catch(function(err) {
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;
},
_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 (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._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,
};
},
_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(chunk.ab[i].length / this.sideWidth);
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 maxNumLines = this._maxLinesSpanned(leftContent, rightContent);
if (hasLeftContent) {
leftSide.push({
type: 'CODE',
content: leftContent || '\n',
numLines: maxNumLines,
lineNum: ++ctx.left.lineNum,
highlight: true,
intraline: 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: true,
intraline: 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) {
leftSide.push({
type: 'COMMENT_THREAD',
comments: leftComments,
});
}
if (rightComments) {
rightSide.push({
type: 'COMMENT_THREAD',
comments: rightComments,
});
}
if (leftComments && !rightComments) {
rightSide.push({ type: 'FILLER' });
} else if (!leftComments && rightComments) {
leftSide.push({ type: 'FILLER' });
}
delete(this._groupedBaseComments[ctx.left.lineNum]);
delete(this._groupedComments[ctx.right.lineNum]);
},
// 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;
},
_maxLinesSpanned: function(left, right) {
return Math.max(Math.ceil(left.length / this.sideWidth),
Math.ceil(right.length / this.sideWidth));
},
});
})();
</script>
</dom-module>