Wyatt Allen 72b87fcb2a Added the diff cursor element
Adds gr-diff-cursor, which is a specialized cursor for navigating
through diff content. It tracks the diff line being targeted, and, in
side-by-side mode, it tracks the side of the diff being targeted.

Also includes special behavior that ships blank spaces and other
non-commentable content in diffs, as well as automatically switching
sides when appropriate.

Support for more than one diff.

Also updates the diff builders to emit some useful element classes.

Note that this only adds the diff cursor along with tests. This is
setting up the work to actually add the diff cursor to the diff views.

Bug: Issue 4033
Change-Id: I991fd514f56ddd19d43d8e1354ad0d4fc71930c4
2016-05-18 11:11:02 -07:00

599 lines
19 KiB
JavaScript

// 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(window, GrDiffGroup, GrDiffLine) {
'use strict';
function GrDiffBuilder(diff, comments, prefs, outputEl) {
this._comments = comments;
this._prefs = prefs;
this._outputEl = outputEl;
this._groups = [];
this._commentLocations = this._getCommentLocations(comments);
this._processContent(diff.content, this._groups, prefs.context);
}
GrDiffBuilder.LESS_THAN_CODE = '<'.charCodeAt(0);
GrDiffBuilder.GREATER_THAN_CODE = '>'.charCodeAt(0);
GrDiffBuilder.AMPERSAND_CODE = '&'.charCodeAt(0);
GrDiffBuilder.SEMICOLON_CODE = ';'.charCodeAt(0);
GrDiffBuilder.TAB_REGEX = /\t/g;
GrDiffBuilder.LINE_FEED_HTML =
'<span class="style-scope gr-diff br"></span>';
GrDiffBuilder.GroupType = {
ADDED: 'b',
BOTH: 'ab',
REMOVED: 'a',
};
GrDiffBuilder.Highlights = {
ADDED: 'edit_b',
REMOVED: 'edit_a',
};
GrDiffBuilder.Side = {
LEFT: 'left',
RIGHT: 'right',
};
GrDiffBuilder.prototype.emitDiff = function() {
for (var i = 0; i < this._groups.length; i++) {
this.emitGroup(this._groups[i]);
}
};
GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) {
throw Error('Subclasses must implement emitGroup');
};
GrDiffBuilder.prototype._processContent = function(content, groups, context) {
this._appendFileComments(groups);
var WHOLE_FILE = -1;
context = content.length > 1 ? context : WHOLE_FILE;
var lineNums = {
left: 0,
right: 0,
};
content = this._splitCommonGroupsWithComments(content, lineNums);
for (var i = 0; i < content.length; i++) {
var group = content[i];
var lines = [];
if (group[GrDiffBuilder.GroupType.BOTH] !== undefined) {
var rows = group[GrDiffBuilder.GroupType.BOTH];
this._appendCommonLines(rows, lines, lineNums);
var hiddenRange = [context, rows.length - context];
if (i === 0) {
hiddenRange[0] = 0;
} else if (i === content.length - 1) {
hiddenRange[1] = rows.length;
}
if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 0) {
this._insertContextGroups(groups, lines, hiddenRange);
} else {
groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, lines));
}
continue;
}
if (group[GrDiffBuilder.GroupType.REMOVED] !== undefined) {
var highlights = undefined;
if (group[GrDiffBuilder.Highlights.REMOVED] !== undefined) {
highlights = this._normalizeIntralineHighlights(
group[GrDiffBuilder.GroupType.REMOVED],
group[GrDiffBuilder.Highlights.REMOVED]);
}
this._appendRemovedLines(group[GrDiffBuilder.GroupType.REMOVED], lines,
lineNums, highlights);
}
if (group[GrDiffBuilder.GroupType.ADDED] !== undefined) {
var highlights = undefined;
if (group[GrDiffBuilder.Highlights.ADDED] !== undefined) {
highlights = this._normalizeIntralineHighlights(
group[GrDiffBuilder.GroupType.ADDED],
group[GrDiffBuilder.Highlights.ADDED]);
}
this._appendAddedLines(group[GrDiffBuilder.GroupType.ADDED], lines,
lineNums, highlights);
}
groups.push(new GrDiffGroup(GrDiffGroup.Type.DELTA, lines));
}
};
GrDiffBuilder.prototype._appendFileComments = function(groups) {
var line = new GrDiffLine(GrDiffLine.Type.BOTH);
line.beforeNumber = GrDiffLine.FILE;
line.afterNumber = GrDiffLine.FILE;
groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]));
};
GrDiffBuilder.prototype._getCommentLocations = function(comments) {
var result = {
left: {},
right: {},
};
for (var side in comments) {
if (side !== GrDiffBuilder.Side.LEFT &&
side !== GrDiffBuilder.Side.RIGHT) {
continue;
}
comments[side].forEach(function(c) {
result[side][c.line || GrDiffLine.FILE] = true;
});
}
return result;
};
GrDiffBuilder.prototype._commentIsAtLineNum = function(side, lineNum) {
return this._commentLocations[side][lineNum] === true;
};
// In order to show comments out of the bounds of the selected context,
// treat them as separate chunks within the model so that the content (and
// context surrounding it) renders correctly.
GrDiffBuilder.prototype._splitCommonGroupsWithComments = function(content,
lineNums) {
var result = [];
var leftLineNum = lineNums.left;
var rightLineNum = lineNums.right;
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._commentIsAtLineNum(GrDiffBuilder.Side.LEFT, leftLineNum) ||
this._commentIsAtLineNum(GrDiffBuilder.Side.RIGHT, rightLineNum)) {
if (currentChunk.ab && currentChunk.ab.length > 0) {
result.push(currentChunk);
currentChunk = {ab: []};
}
result.push({ab: [chunk[j]]});
} else {
currentChunk.ab.push(chunk[j]);
}
}
if (currentChunk.ab != null && currentChunk.ab.length > 0) {
result.push(currentChunk);
}
}
return result;
};
// 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.
GrDiffBuilder.prototype._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;
};
GrDiffBuilder.prototype._insertContextGroups = function(groups, lines,
hiddenRange) {
var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
var linesAfterCtx = lines.slice(hiddenRange[1]);
if (linesBeforeCtx.length > 0) {
groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
}
var ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
ctxLine.contextLines = hiddenLines;
groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
[ctxLine]));
if (linesAfterCtx.length > 0) {
groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
}
};
GrDiffBuilder.prototype._appendCommonLines = function(rows, lines, lineNums) {
for (var i = 0; i < rows.length; i++) {
var line = new GrDiffLine(GrDiffLine.Type.BOTH);
line.text = rows[i];
line.beforeNumber = ++lineNums.left;
line.afterNumber = ++lineNums.right;
lines.push(line);
}
};
GrDiffBuilder.prototype._appendRemovedLines = function(rows, lines, lineNums,
opt_highlights) {
for (var i = 0; i < rows.length; i++) {
var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
line.text = rows[i];
line.beforeNumber = ++lineNums.left;
if (opt_highlights) {
line.highlights = opt_highlights.filter(function(hl) {
return hl.contentIndex === i;
});
}
lines.push(line);
}
};
GrDiffBuilder.prototype._appendAddedLines = function(rows, lines, lineNums,
opt_highlights) {
for (var i = 0; i < rows.length; i++) {
var line = new GrDiffLine(GrDiffLine.Type.ADD);
line.text = rows[i];
line.afterNumber = ++lineNums.right;
if (opt_highlights) {
line.highlights = opt_highlights.filter(function(hl) {
return hl.contentIndex === i;
});
}
lines.push(line);
}
};
GrDiffBuilder.prototype._createContextControl = function(section, line) {
if (!line.contextLines.length) {
return null;
}
var td = this._createElement('td');
var button = this._createElement('gr-button', 'showContext');
button.setAttribute('link', true);
var commonLines = line.contextLines.length;
var text = 'Show ' + commonLines + ' common line';
if (commonLines > 1) {
text += 's';
}
text += '...';
button.textContent = text;
button.addEventListener('tap', function(e) {
e.detail = {
group: new GrDiffGroup(GrDiffGroup.Type.BOTH, line.contextLines),
section: section,
};
// Let it bubble up the DOM tree.
});
td.appendChild(button);
return td;
};
GrDiffBuilder.prototype._getCommentsForLine = function(comments, line,
opt_side) {
function byLineNum(lineNum) {
return function(c) {
return (c.line === lineNum) ||
(c.line === undefined && lineNum === GrDiffLine.FILE);
};
}
var leftComments =
comments[GrDiffBuilder.Side.LEFT].filter(byLineNum(line.beforeNumber));
var rightComments =
comments[GrDiffBuilder.Side.RIGHT].filter(byLineNum(line.afterNumber));
var result;
switch (opt_side) {
case GrDiffBuilder.Side.LEFT:
result = leftComments;
break;
case GrDiffBuilder.Side.RIGHT:
result = rightComments;
break;
default:
result = leftComments.concat(rightComments);
break;
}
return result;
};
GrDiffBuilder.prototype.createCommentThread = function(changeNum, patchNum,
path, side, projectConfig) {
var threadEl = document.createElement('gr-diff-comment-thread');
threadEl.changeNum = changeNum;
threadEl.patchNum = patchNum;
threadEl.path = path;
threadEl.side = side;
threadEl.projectConfig = projectConfig;
return threadEl;
};
GrDiffBuilder.prototype._commentThreadForLine = function(line, opt_side) {
var comments = this._getCommentsForLine(this._comments, line, opt_side);
if (!comments || comments.length === 0) {
return null;
}
var patchNum = this._comments.meta.patchRange.patchNum;
var side = 'REVISION';
if (line.type === GrDiffLine.Type.REMOVE ||
opt_side === GrDiffBuilder.Side.LEFT) {
if (this._comments.meta.patchRange.basePatchNum === 'PARENT') {
side = 'PARENT';
} else {
patchNum = this._comments.meta.patchRange.basePatchNum;
}
}
var threadEl = this.createCommentThread(
this._comments.meta.changeNum,
patchNum,
this._comments.meta.path,
side,
this._comments.meta.projectConfig);
threadEl.comments = comments;
return threadEl;
};
GrDiffBuilder.prototype._createLineEl = function(line, number, type,
opt_class) {
var td = this._createElement('td');
if (opt_class) {
td.classList.add(opt_class);
}
if (line.type === GrDiffLine.Type.BLANK) {
return td;
} else if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
td.classList.add('contextLineNum');
td.setAttribute('data-value', '@@');
} else if (line.type === GrDiffLine.Type.BOTH || line.type == type) {
td.classList.add('lineNum');
td.setAttribute('data-value', number);
}
return td;
};
GrDiffBuilder.prototype._createTextEl = function(line) {
var td = this._createElement('td');
if (line.type !== GrDiffLine.Type.BLANK) {
td.classList.add('content');
}
td.classList.add(line.type);
var text = line.text;
var html = util.escapeHTML(text);
td.classList.add(line.highlights.length > 0 ?
'lightHighlight' : 'darkHighlight');
if (line.highlights.length > 0) {
html = this._addIntralineHighlights(text, html, line.highlights);
}
if (this._textLength(text, this._prefs.tab_size) >
this._prefs.line_length) {
html = this._addNewlines(text, html);
}
html = this._addTabWrappers(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 (html === text) {
td.textContent = text;
} else {
td.innerHTML = html;
}
return td;
};
GrDiffBuilder.prototype._textLength = function(text, tabSize) {
// TODO(andybons): Unicode support.
var numChars = 0;
for (var i = 0; i < text.length; i++) {
if (text[i] === '\t') {
numChars += tabSize;
} else {
numChars++;
}
}
return numChars;
};
// Advance `index` by the appropriate number of characters that would
// represent one source code character and return that index. For
// example, for source code '<span>' the escaped html string is
// '&lt;span&gt;'. Advancing from index 0 on the prior html string would
// return 4, since &lt; maps to one source code character ('<').
GrDiffBuilder.prototype._advanceChar = function(html, index) {
// TODO(andybons): Unicode is all kinds of messed up in JS. Account for it.
// https://mathiasbynens.be/notes/javascript-unicode
// Tags don't count as characters
while (index < html.length &&
html.charCodeAt(index) == GrDiffBuilder.LESS_THAN_CODE) {
while (index < html.length &&
html.charCodeAt(index) != GrDiffBuilder.GREATER_THAN_CODE) {
index++;
}
index++; // skip the ">" itself
}
// An HTML entity (e.g., &lt;) counts as one character.
if (index < html.length &&
html.charCodeAt(index) == GrDiffBuilder.AMPERSAND_CODE) {
while (index < html.length &&
html.charCodeAt(index) != GrDiffBuilder.SEMICOLON_CODE) {
index++;
}
}
return index + 1;
};
GrDiffBuilder.prototype._addNewlines = function(text, html) {
var htmlIndex = 0;
var indices = [];
var numChars = 0;
for (var i = 0; i < text.length; i++) {
if (numChars > 0 && numChars % this._prefs.line_length === 0) {
indices.push(htmlIndex);
}
htmlIndex = this._advanceChar(html, htmlIndex);
if (text[i] === '\t') {
numChars += this._prefs.tab_size;
} else {
numChars++;
}
}
var result = html;
// 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]) + GrDiffBuilder.LINE_FEED_HTML +
result.slice(indices[i]);
}
return result;
};
GrDiffBuilder.prototype._addTabWrappers = function(html) {
var htmlStr = this._getTabWrapper(this._prefs.tab_size,
this._prefs.show_tabs);
return html.replace(GrDiffBuilder.TAB_REGEX, htmlStr);
};
GrDiffBuilder.prototype._addIntralineHighlights = function(content, html,
highlights) {
var START_TAG = '<hl class="style-scope gr-diff">';
var END_TAG = '</hl>';
for (var i = 0; i < highlights.length; i++) {
var hl = highlights[i];
var htmlStartIndex = 0;
// Find the index of the HTML string to insert the start tag.
for (var j = 0; j < hl.startIndex; j++) {
htmlStartIndex = this._advanceChar(html, htmlStartIndex);
}
var htmlEndIndex = 0;
if (hl.endIndex !== undefined) {
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) + START_TAG +
html.slice(htmlStartIndex, htmlEndIndex) + END_TAG +
html.slice(htmlEndIndex);
}
}
return html;
};
GrDiffBuilder.prototype._getTabWrapper = function(tabSize, showTabs) {
// Force this to be a number to prevent arbitrary injection.
tabSize = +tabSize;
if (isNaN(tabSize)) {
throw Error('Invalid tab size from preferences.');
}
var str = '<span class="style-scope gr-diff tab ';
if (showTabs) {
str += 'withIndicator';
}
str += '" style="';
// TODO(andybons): CSS tab-size is not supported in IE.
str += 'tab-size:' + tabSize + ';';
str += '-moz-tab-size:' + tabSize + ';';
str += '">\t</span>';
return str;
};
GrDiffBuilder.prototype._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');
if (!!className) {
el.classList.add(className);
}
return el;
};
window.GrDiffBuilder = GrDiffBuilder;
})(window, GrDiffGroup, GrDiffLine);