Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

697 lines
23 KiB
JavaScript
Raw Normal View History

/**
* @license
* 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';
// Prevent redefinition.
if (window.GrDiffBuilder) { return; }
/**
* In JS, unicode code points above 0xFFFF occupy two elements of a string.
* For example '𐀏'.length is 2. An occurence of such a code point is called a
* surrogate pair.
*
* This regex segments a string along tabs ('\t') and surrogate pairs, since
* these are two cases where '1 char' does not automatically imply '1 column'.
*
* TODO: For human languages whose orthographies use combining marks, this
* approach won't correctly identify the grapheme boundaries. In those cases,
* a grapheme consists of multiple code points that should count as only one
* character against the column limit. Getting that correct (if it's desired)
* is probably beyond the limits of a regex, but there are nonstandard APIs to
* do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
*
* Further reading:
* On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
* Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
* A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
*/
const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
function GrDiffBuilder(diff, comments, prefs, projectName, outputEl, layers) {
this._diff = diff;
this._comments = comments;
this._prefs = prefs;
this._projectName = projectName;
this._outputEl = outputEl;
this.groups = [];
this._blameInfo = null;
this._parentIndex = undefined;
this.layers = layers || [];
if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
throw Error('Invalid tab size from preferences.');
}
if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
throw Error('Invalid line length from preferences.');
}
for (const layer of this.layers) {
if (layer.addListener) {
layer.addListener(this._handleLayerUpdate.bind(this));
}
}
}
GrDiffBuilder.GroupType = {
ADDED: 'b',
BOTH: 'ab',
REMOVED: 'a',
};
GrDiffBuilder.Highlights = {
ADDED: 'edit_b',
REMOVED: 'edit_a',
};
GrDiffBuilder.Side = {
LEFT: 'left',
RIGHT: 'right',
};
GrDiffBuilder.ContextButtonType = {
ABOVE: 'above',
BELOW: 'below',
ALL: 'all',
};
const PARTIAL_CONTEXT_AMOUNT = 10;
/**
* Abstract method
* @param {string} outputEl
* @param {number} fontSize
*/
GrDiffBuilder.prototype.addColumns = function() {
throw Error('Subclasses must implement addColumns');
};
/**
* Abstract method
* @param {Object} group
*/
GrDiffBuilder.prototype.buildSectionElement = function() {
throw Error('Subclasses must implement buildSectionElement');
};
GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) {
const element = this.buildSectionElement(group);
this._outputEl.insertBefore(element, opt_beforeSection);
group.element = element;
};
GrDiffBuilder.prototype.renderSection = function(element) {
for (let i = 0; i < this.groups.length; i++) {
const group = this.groups[i];
if (group.element === element) {
const newElement = this.buildSectionElement(group);
group.element.parentElement.replaceChild(newElement, group.element);
group.element = newElement;
break;
}
}
};
GrDiffBuilder.prototype.getGroupsByLineRange = function(
startLine, endLine, opt_side) {
const groups = [];
for (let i = 0; i < this.groups.length; i++) {
const group = this.groups[i];
if (group.lines.length === 0) {
continue;
}
let groupStartLine = 0;
let groupEndLine = 0;
if (opt_side) {
groupStartLine = group.lineRange[opt_side].start;
groupEndLine = group.lineRange[opt_side].end;
}
if (groupStartLine === 0) { // Line was removed or added.
groupStartLine = groupEndLine;
}
if (groupEndLine === 0) { // Line was removed or added.
groupEndLine = groupStartLine;
}
if (startLine <= groupEndLine && endLine >= groupStartLine) {
groups.push(group);
}
}
return groups;
};
GrDiffBuilder.prototype.getContentByLine = function(lineNumber, opt_side,
opt_root) {
const root = Polymer.dom(opt_root || this._outputEl);
const sideSelector = opt_side ? ('.' + opt_side) : '';
return root.querySelector('td.lineNum[data-value="' + lineNumber +
'"]' + sideSelector + ' ~ td.content .contentText');
};
/**
* Find line elements or line objects by a range of line numbers and a side.
*
* @param {number} start The first line number
* @param {number} end The last line number
* @param {string} opt_side The side of the range. Either 'left' or 'right'.
* @param {!Array<GrDiffLine>} out_lines The output list of line objects. Use
* null if not desired.
* @param {!Array<HTMLElement>} out_elements The output list of line elements.
* Use null if not desired.
*/
GrDiffBuilder.prototype.findLinesByRange = function(start, end, opt_side,
out_lines, out_elements) {
const groups = this.getGroupsByLineRange(start, end, opt_side);
for (const group of groups) {
let content = null;
for (const line of group.lines) {
if ((opt_side === 'left' && line.type === GrDiffLine.Type.ADD) ||
(opt_side === 'right' && line.type === GrDiffLine.Type.REMOVE)) {
continue;
}
const lineNumber = opt_side === 'left' ?
line.beforeNumber : line.afterNumber;
if (lineNumber < start || lineNumber > end) { continue; }
if (out_lines) { out_lines.push(line); }
if (out_elements) {
if (content) {
content = this._getNextContentOnSide(content, opt_side);
} else {
content = this.getContentByLine(lineNumber, opt_side,
group.element);
}
if (content) { out_elements.push(content); }
}
}
}
};
/**
* Re-renders the DIV.contentText elements for the given side and range of
* diff content.
*/
GrDiffBuilder.prototype._renderContentByRange = function(start, end, side) {
const lines = [];
const elements = [];
let line;
let el;
this.findLinesByRange(start, end, side, lines, elements);
for (let i = 0; i < lines.length; i++) {
line = lines[i];
el = elements[i];
if (!el) {
// Cannot re-render an element if it does not exist. This can happen
// if lines are collapsed and not visible on the page yet.
continue;
}
el.parentElement.replaceChild(this._createTextEl(line, side).firstChild,
el);
}
};
GrDiffBuilder.prototype.getSectionsByLineRange = function(
startLine, endLine, opt_side) {
return this.getGroupsByLineRange(startLine, endLine, opt_side).map(
group => { return group.element; });
};
// TODO(wyatta): Move this completely into the processor.
GrDiffBuilder.prototype._insertContextGroups = function(groups, lines,
hiddenRange) {
const linesBeforeCtx = lines.slice(0, hiddenRange[0]);
const hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
const linesAfterCtx = lines.slice(hiddenRange[1]);
if (linesBeforeCtx.length > 0) {
groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
}
const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
ctxLine.contextGroup =
new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
[ctxLine]));
if (linesAfterCtx.length > 0) {
groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
}
};
GrDiffBuilder.prototype._createContextControl = function(section, line) {
if (!line.contextGroup || !line.contextGroup.lines.length) {
return null;
}
const td = this._createElement('td');
const showPartialLinks =
line.contextGroup.lines.length > PARTIAL_CONTEXT_AMOUNT;
if (showPartialLinks) {
td.appendChild(this._createContextButton(
GrDiffBuilder.ContextButtonType.ABOVE, section, line));
td.appendChild(document.createTextNode(' - '));
}
td.appendChild(this._createContextButton(
GrDiffBuilder.ContextButtonType.ALL, section, line));
if (showPartialLinks) {
td.appendChild(document.createTextNode(' - '));
td.appendChild(this._createContextButton(
GrDiffBuilder.ContextButtonType.BELOW, section, line));
}
return td;
};
GrDiffBuilder.prototype._createContextButton = function(type, section, line) {
const contextLines = line.contextGroup.lines;
const context = PARTIAL_CONTEXT_AMOUNT;
const button = this._createElement('gr-button', 'showContext');
button.setAttribute('link', true);
button.setAttribute('no-uppercase', true);
let text;
const groups = []; // The groups that replace this one if tapped.
if (type === GrDiffBuilder.ContextButtonType.ALL) {
text = 'Show ' + contextLines.length + ' common line';
if (contextLines.length > 1) { text += 's'; }
groups.push(line.contextGroup);
} else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
text = '+' + context + '↑';
this._insertContextGroups(groups, contextLines,
[context, contextLines.length]);
} else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
text = '+' + context + '↓';
this._insertContextGroups(groups, contextLines,
[0, contextLines.length - context]);
}
Polymer.dom(button).textContent = text;
button.addEventListener('tap', e => {
e.detail = {
groups,
section,
};
// Let it bubble up the DOM tree.
});
return button;
};
GrDiffBuilder.prototype._getCommentsForLine = function(comments, line,
opt_side) {
function byLineNum(lineNum) {
return function(c) {
return (c.line === lineNum) ||
(c.line === undefined && lineNum === GrDiffLine.FILE);
};
}
const leftComments =
comments[GrDiffBuilder.Side.LEFT].filter(byLineNum(line.beforeNumber));
const rightComments =
comments[GrDiffBuilder.Side.RIGHT].filter(byLineNum(line.afterNumber));
leftComments.forEach(c => { c.__commentSide = 'left'; });
rightComments.forEach(c => { c.__commentSide = 'right'; });
let 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;
};
/**
* @param {number} changeNum
* @param {number|string} patchNum
* @param {string} path
* @param {boolean} isOnParent
* @param {string} commentSide
* @return {!Object}
*/
GrDiffBuilder.prototype.createCommentThreadGroup = function(changeNum,
patchNum, path, isOnParent, commentSide) {
const threadGroupEl =
document.createElement('gr-diff-comment-thread-group');
threadGroupEl.changeNum = changeNum;
threadGroupEl.commentSide = commentSide;
threadGroupEl.patchForNewThreads = patchNum;
threadGroupEl.path = path;
threadGroupEl.isOnParent = isOnParent;
threadGroupEl.projectName = this._projectName;
threadGroupEl.parentIndex = this._parentIndex;
return threadGroupEl;
};
/**
* @param {number} line
* @param {string=} opt_side
* @return {!Object}
*/
GrDiffBuilder.prototype._commentThreadGroupForLine = function(
line, opt_side) {
const comments =
this._getCommentsForLine(this._comments, line, opt_side);
if (!comments || comments.length === 0) {
return null;
}
let patchNum = this._comments.meta.patchRange.patchNum;
let isOnParent = comments[0].side === 'PARENT' || false;
if (line.type === GrDiffLine.Type.REMOVE ||
opt_side === GrDiffBuilder.Side.LEFT) {
if (this._comments.meta.patchRange.basePatchNum === 'PARENT' ||
Gerrit.PatchSetBehavior.isMergeParent(
this._comments.meta.patchRange.basePatchNum)) {
isOnParent = true;
} else {
patchNum = this._comments.meta.patchRange.basePatchNum;
}
}
const threadGroupEl = this.createCommentThreadGroup(
this._comments.meta.changeNum, patchNum, this._comments.meta.path,
isOnParent, opt_side);
threadGroupEl.comments = comments;
if (opt_side) {
threadGroupEl.setAttribute('data-side', opt_side);
}
return threadGroupEl;
};
GrDiffBuilder.prototype._createLineEl = function(
line, number, type, opt_class) {
const td = this._createElement('td');
if (opt_class) {
td.classList.add(opt_class);
}
if (line.type === GrDiffLine.Type.REMOVE) {
td.setAttribute('aria-label', `${number} removed`);
} else if (line.type === GrDiffLine.Type.ADD) {
td.setAttribute('aria-label', `${number} added`);
}
if (line.type === GrDiffLine.Type.BLANK) {
return td;
} else if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
td.classList.add('contextLineNum');
td.setAttribute('data-value', '@@');
td.textContent = '@@';
} else if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
td.classList.add('lineNum');
td.setAttribute('data-value', number);
td.textContent = number === 'FILE' ? 'File' : number;
}
return td;
};
GrDiffBuilder.prototype._createTextEl = function(line, opt_side) {
const td = this._createElement('td');
if (line.type !== GrDiffLine.Type.BLANK) {
td.classList.add('content');
}
td.classList.add(line.type);
const lineLimit =
!this._prefs.line_wrapping ? this._prefs.line_length : Infinity;
const contentText =
this._formatText(line.text, this._prefs.tab_size, lineLimit);
if (opt_side) {
contentText.setAttribute('data-side', opt_side);
}
for (const layer of this.layers) {
layer.annotate(contentText, line);
}
td.appendChild(contentText);
return td;
};
/**
* Returns a 'div' element containing the supplied |text| as its innerText,
* with '\t' characters expanded to a width determined by |tabSize|, and the
* text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
* desired.
*
* @param {string} text The text to be formatted.
* @param {number} tabSize The width of each tab stop.
* @param {number} lineLimit The column after which to wrap lines.
* @return {HTMLElement}
*/
GrDiffBuilder.prototype._formatText = function(text, tabSize, lineLimit) {
const contentText = this._createElement('div', 'contentText');
let columnPos = 0;
let textOffset = 0;
for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
if (segment) {
// |segment| contains only normal characters. If |segment| doesn't fit
// entirely on the current line, append chunks of |segment| followed by
// line breaks.
let rowStart = 0;
let rowEnd = lineLimit - columnPos;
while (rowEnd < segment.length) {
contentText.appendChild(
document.createTextNode(segment.substring(rowStart, rowEnd)));
contentText.appendChild(this._createElement('span', 'br'));
columnPos = 0;
rowStart = rowEnd;
rowEnd += lineLimit;
}
// Append the last part of |segment|, which fits on the current line.
contentText.appendChild(
document.createTextNode(segment.substring(rowStart)));
columnPos += (segment.length - rowStart);
textOffset += segment.length;
}
if (textOffset < text.length) {
// Handle the special character at |textOffset|.
if (text.startsWith('\t', textOffset)) {
// Append a single '\t' character.
let effectiveTabSize = tabSize - (columnPos % tabSize);
if (columnPos + effectiveTabSize > lineLimit) {
contentText.appendChild(this._createElement('span', 'br'));
columnPos = 0;
effectiveTabSize = tabSize;
}
contentText.appendChild(this._getTabWrapper(effectiveTabSize));
columnPos += effectiveTabSize;
textOffset++;
} else {
// Append a single surrogate pair.
if (columnPos >= lineLimit) {
contentText.appendChild(this._createElement('span', 'br'));
columnPos = 0;
}
contentText.appendChild(document.createTextNode(
text.substring(textOffset, textOffset + 2)));
textOffset += 2;
columnPos += 1;
}
}
}
return contentText;
};
/**
* Returns a <span> element holding a '\t' character, that will visually
* occupy |tabSize| many columns.
*
* @param {number} tabSize The effective size of this tab stop.
* @return {HTMLElement}
*/
Improves visible tabs rendering This change reverts visible tabs to use the » character. A few different approaches have been used for rendering these tab indicators: I: Before the Annotation Pipeline, tab indicators were configured by a class that was optionally applied to tab elements by the diff builder. The class was guarded by the show_tabs preference and a CSS rule created a `::before` pseudo element to insert the character. (Thus also preventing the » from being copyable text.) II: When the Annotation Pipeline was implemented, the ordering of layers called for intraline difference elements being rendered *inside* tab indicators. As a result, the » indicator would sometimes have a different background than the intraline difference, looking sloppy. To solve this, the pseudo element was positioned using absolute, allowing the pseudo element to consume no horizontal space and and the intraline background to extend across the entire tab. III:When performance tuning, it was discovered that the absolute-positioned tab indicators were positioned incorrectly when GPU acceleration was hinted for the diff, so the containing tab elements were made relative. IV: Continuing performance tuning, the tab indicators seemed to slow scrolling on very large diffs with tabs. It was mistakenly assumed (by me) that this was related to the pseudo-elements when it was actually caused by the thousands of positioning contexts they created using relative and absolute. Instead they were modified to use strike-through instead of a pseudo element, which was more-performant, but less-usable (it looked bad). With this change, we roll-back the clock a little and solve a core problem: namely that tab indicators were not annotated inside the intraline differences. Fixing that, positioning tricks are no-longer needed to make the background look right. To do this, we decouple the tab elements (which control tab width) from the tab indicators (which optionally make tabs visible). This is done using an annotation layer that inserts tab indicator elements wherever a tab character is used in the source content, and it does so after intraline differences have been applied. Bug: Issue 4441 Change-Id: I4fea2a3a20a7039bfeb746bd1e1c1004e43c4801
2016-08-25 11:31:42 -07:00
GrDiffBuilder.prototype._getTabWrapper = function(tabSize) {
// Force this to be a number to prevent arbitrary injection.
const result = this._createElement('span', 'tab');
result.style['tab-size'] = tabSize;
result.style['-moz-tab-size'] = tabSize;
result.innerText = '\t';
return result;
};
GrDiffBuilder.prototype._createElement = function(tagName, classStr) {
const 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 (classStr) {
for (const className of classStr.split(' ')) {
el.classList.add(className);
}
}
return el;
};
GrDiffBuilder.prototype._handleLayerUpdate = function(start, end, side) {
this._renderContentByRange(start, end, side);
};
/**
* Finds the next DIV.contentText element following the given element, and on
* the same side. Will only search within a group.
* @param {HTMLElement} content
* @param {string} side Either 'left' or 'right'
* @return {HTMLElement}
*/
GrDiffBuilder.prototype._getNextContentOnSide = function(content, side) {
throw Error('Subclasses must implement _getNextContentOnSide');
};
/**
* Determines whether the given group is either totally an addition or totally
* a removal.
* @param {!Object} group (GrDiffGroup)
* @return {boolean}
*/
GrDiffBuilder.prototype._isTotal = function(group) {
return group.type === GrDiffGroup.Type.DELTA &&
(!group.adds.length || !group.removes.length) &&
!(!group.adds.length && !group.removes.length);
};
/**
* Set the blame information for the diff. For any already-rendered line,
* re-render its blame cell content.
* @param {Object} blame
*/
GrDiffBuilder.prototype.setBlame = function(blame) {
this._blameInfo = blame;
// TODO(wyatta): make this loop asynchronous.
for (const commit of blame) {
for (const range of commit.ranges) {
for (let i = range.start; i <= range.end; i++) {
// TODO(wyatta): this query is expensive, but, when traversing a
// range, the lines are consecutive, and given the previous blame
// cell, the next one can be reached cheaply.
const el = this._getBlameByLineNum(i);
if (!el) { continue; }
// Remove the element's children (if any).
while (el.hasChildNodes()) {
el.removeChild(el.lastChild);
}
const blame = this._getBlameForBaseLine(i, commit);
el.appendChild(blame);
}
}
}
};
GrDiffBuilder.prototype.setParentIndex = function(index) {
this._parentIndex = index;
};
/**
* Find the blame cell for a given line number.
* @param {number} lineNum
* @return {HTMLTableDataCellElement}
*/
GrDiffBuilder.prototype._getBlameByLineNum = function(lineNum) {
const root = Polymer.dom(this._outputEl);
return root.querySelector(`td.blame[data-line-number="${lineNum}"]`);
};
/**
* Given a base line number, return the commit containing that line in the
* current set of blame information. If no blame information has been
* provided, null is returned.
* @param {number} lineNum
* @return {Object} The commit information.
*/
GrDiffBuilder.prototype._getBlameCommitForBaseLine = function(lineNum) {
if (!this._blameInfo) { return null; }
for (const blameCommit of this._blameInfo) {
for (const range of blameCommit.ranges) {
if (range.start <= lineNum && range.end >= lineNum) {
return blameCommit;
}
}
}
return null;
};
/**
* Given the number of a base line, get the content for the blame cell of that
* line. If there is no blame information for that line, returns null.
* @param {number} lineNum
* @param {Object=} opt_commit Optionally provide the commit object, so that
* it does not need to be searched.
* @return {HTMLSpanElement}
*/
GrDiffBuilder.prototype._getBlameForBaseLine = function(lineNum, opt_commit) {
const commit = opt_commit || this._getBlameCommitForBaseLine(lineNum);
if (!commit) { return null; }
const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
const date = (new Date(commit.time * 1000)).toLocaleDateString();
const blameNode = this._createElement('span',
isStartOfRange ? 'startOfRange' : '');
const shaNode = this._createElement('span', 'sha');
shaNode.innerText = commit.id.substr(0, 7);
blameNode.appendChild(shaNode);
blameNode.append(` on ${date} by ${commit.author}`);
return blameNode;
};
/**
* Create a blame cell for the given base line. Blame information will be
* included in the cell if available.
* @param {GrDiffLine} line
* @return {HTMLTableDataCellElement}
*/
GrDiffBuilder.prototype._createBlameCell = function(line) {
const blameTd = this._createElement('td', 'blame');
blameTd.setAttribute('data-line-number', line.beforeNumber);
if (line.beforeNumber) {
const content = this._getBlameForBaseLine(line.beforeNumber);
if (content) {
blameTd.appendChild(content);
}
}
return blameTd;
};
window.GrDiffBuilder = GrDiffBuilder;
})(window, GrDiffGroup, GrDiffLine);