blob: a6382c4b87b4fb16d8e7caeedc799e441690af27 [file] [log] [blame]
// Copyright (C) 2020 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';
const DiffViewMode = {
SIDE_BY_SIDE: 'SIDE_BY_SIDE',
UNIFIED: 'UNIFIED_DIFF',
};
const TRAILING_WHITESPACE_PATTERN = /\s+$/;
/**
* @appliesMixin Gerrit.FireMixin
*/
class GrDiffBuilderElement extends Polymer.mixinBehaviors( [
Gerrit.FireBehavior,
], Polymer.GestureEventListeners(
Polymer.LegacyElementMixin(
Polymer.Element))) {
static get is() { return 'gr-diff-builder'; }
/**
* Fired when the diff begins rendering.
*
* @event render-start
*/
/**
* Fired when the diff finishes rendering text content.
*
* @event render-content
*/
static get properties() {
return {
diff: Object,
changeNum: String,
patchNum: String,
viewMode: String,
isImageDiff: Boolean,
baseImage: Object,
revisionImage: Object,
parentIndex: Number,
path: String,
projectName: String,
_builder: Object,
_groups: Array,
_layers: Array,
_showTabs: Boolean,
/** @type {!Array<!Gerrit.HoveredRange>} */
commentRanges: {
type: Array,
value: () => [],
},
/** @type {!Array<!Gerrit.CoverageRange>} */
coverageRanges: {
type: Array,
value: () => [],
},
_leftCoverageRanges: {
type: Array,
computed: '_computeLeftCoverageRanges(coverageRanges)',
},
_rightCoverageRanges: {
type: Array,
computed: '_computeRightCoverageRanges(coverageRanges)',
},
/**
* The promise last returned from `render()` while the asynchronous
* rendering is running - `null` otherwise. Provides a `cancel()`
* method that rejects it with `{isCancelled: true}`.
*
* @type {?Object}
*/
_cancelableRenderPromise: Object,
layers: {
type: Array,
value: [],
},
};
}
get diffElement() {
return this.queryEffectiveChildren('#diffTable');
}
static get observers() {
return [
'_groupsChanged(_groups.splices)',
];
}
_computeLeftCoverageRanges(coverageRanges) {
return coverageRanges.filter(range => range && range.side === 'left');
}
_computeRightCoverageRanges(coverageRanges) {
return coverageRanges.filter(range => range && range.side === 'right');
}
render(keyLocations, prefs) {
// Setting up annotation layers must happen after plugins are
// installed, and |render| satisfies the requirement, however,
// |attached| doesn't because in the diff view page, the element is
// attached before plugins are installed.
this._setupAnnotationLayers();
this._showTabs = !!prefs.show_tabs;
this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
// Stop the processor if it's running.
this.cancel();
this._builder = this._getDiffBuilder(this.diff, prefs);
this.$.processor.context = prefs.context;
this.$.processor.keyLocations = keyLocations;
this._clearDiffContent();
this._builder.addColumns(this.diffElement, prefs.font_size);
const isBinary = !!(this.isImageDiff || this.diff.binary);
this.dispatchEvent(new CustomEvent(
'render-start', {bubbles: true, composed: true}));
this._cancelableRenderPromise = util.makeCancelable(
this.$.processor.process(this.diff.content, isBinary)
.then(() => {
if (this.isImageDiff) {
this._builder.renderDiff();
}
this.dispatchEvent(new CustomEvent('render-content',
{bubbles: true, composed: true}));
}));
return this._cancelableRenderPromise
.finally(() => { this._cancelableRenderPromise = null; })
// Mocca testing does not like uncaught rejections, so we catch
// the cancels which are expected and should not throw errors in
// tests.
.catch(e => { if (!e.isCanceled) return Promise.reject(e); });
}
_setupAnnotationLayers() {
const layers = [
this._createTrailingWhitespaceLayer(),
this._createIntralineLayer(),
this._createTabIndicatorLayer(),
this.$.rangeLayer,
this.$.coverageLayerLeft,
this.$.coverageLayerRight,
];
if (this.layers) {
layers.push(...this.layers);
}
this._layers = layers;
}
getLineElByChild(node) {
while (node) {
if (node instanceof Element) {
if (node.classList.contains('lineNum')) {
return node;
}
if (node.classList.contains('section')) {
return null;
}
}
node = node.previousSibling || node.parentElement;
}
return null;
}
getLineNumberByChild(node) {
const lineEl = this.getLineElByChild(node);
return lineEl ?
parseInt(lineEl.getAttribute('data-value'), 10) :
null;
}
getContentByLine(lineNumber, opt_side, opt_root) {
return this._builder.getContentByLine(lineNumber, opt_side, opt_root);
}
getContentByLineEl(lineEl) {
const root = Polymer.dom(lineEl.parentElement);
const side = this.getSideByLineEl(lineEl);
const line = lineEl.getAttribute('data-value');
return this.getContentByLine(line, side, root);
}
getLineElByNumber(lineNumber, opt_side) {
const sideSelector = opt_side ? ('.' + opt_side) : '';
return this.diffElement.querySelector(
'.lineNum[data-value="' + lineNumber + '"]' + sideSelector);
}
getContentsByLineRange(startLine, endLine, opt_side) {
const result = [];
this._builder.findLinesByRange(startLine, endLine, opt_side, null,
result);
return result;
}
getSideByLineEl(lineEl) {
return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
}
emitGroup(group, sectionEl) {
this._builder.emitGroup(group, sectionEl);
}
showContext(newGroups, sectionEl) {
const groups = this._builder.groups;
const contextIndex = groups.findIndex(group =>
group.element === sectionEl
);
groups.splice(contextIndex, 1, ...newGroups);
for (const newGroup of newGroups) {
this._builder.emitGroup(newGroup, sectionEl);
}
sectionEl.parentNode.removeChild(sectionEl);
this.async(() => this.fire('render-content'), 1);
}
cancel() {
this.$.processor.cancel();
if (this._cancelableRenderPromise) {
this._cancelableRenderPromise.cancel();
this._cancelableRenderPromise = null;
}
}
_handlePreferenceError(pref) {
const message = `The value of the '${pref}' user preference is ` +
`invalid. Fix in diff preferences`;
this.dispatchEvent(new CustomEvent('show-alert', {
detail: {
message,
}, bubbles: true, composed: true}));
throw Error(`Invalid preference value: ${pref}`);
}
_getDiffBuilder(diff, prefs) {
if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
this._handlePreferenceError('tab size');
return;
}
if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
this._handlePreferenceError('diff width');
return;
}
let builder = null;
if (this.isImageDiff) {
builder = new GrDiffBuilderImage(diff, prefs, this.diffElement,
this.baseImage, this.revisionImage);
} else if (diff.binary) {
// If the diff is binary, but not an image.
return new GrDiffBuilderBinary(diff, prefs, this.diffElement);
} else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
builder = new GrDiffBuilderSideBySide(diff, prefs, this.diffElement,
this._layers);
} else if (this.viewMode === DiffViewMode.UNIFIED) {
builder = new GrDiffBuilderUnified(diff, prefs, this.diffElement,
this._layers);
}
if (!builder) {
throw Error('Unsupported diff view mode: ' + this.viewMode);
}
return builder;
}
_clearDiffContent() {
this.diffElement.innerHTML = null;
}
_groupsChanged(changeRecord) {
if (!changeRecord) { return; }
for (const splice of changeRecord.indexSplices) {
let group;
for (let i = 0; i < splice.addedCount; i++) {
group = splice.object[splice.index + i];
this._builder.groups.push(group);
this._builder.emitGroup(group);
}
}
}
_createIntralineLayer() {
return {
// Take a DIV.contentText element and a line object with intraline
// differences to highlight and apply them to the element as
// annotations.
annotate(contentEl, lineNumberEl, line) {
const HL_CLASS = 'style-scope gr-diff intraline';
for (const highlight of line.highlights) {
// 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 (highlight.startIndex === highlight.endIndex) { continue; }
// If endIndex isn't present, continue to the end of the line.
const endIndex = highlight.endIndex === undefined ?
line.text.length :
highlight.endIndex;
GrAnnotation.annotateElement(
contentEl,
highlight.startIndex,
endIndex - highlight.startIndex,
HL_CLASS);
}
},
};
}
_createTabIndicatorLayer() {
const show = () => this._showTabs;
return {
annotate(contentEl, lineNumberEl, line) {
// If visible tabs are disabled, do nothing.
if (!show()) { return; }
// Find and annotate the locations of tabs.
const split = line.text.split('\t');
if (!split) { return; }
for (let i = 0, pos = 0; i < split.length - 1; i++) {
// Skip forward by the length of the content
pos += split[i].length;
GrAnnotation.annotateElement(contentEl, pos, 1,
'style-scope gr-diff tab-indicator');
// Skip forward by one tab character.
pos++;
}
},
};
}
_createTrailingWhitespaceLayer() {
const show = function() {
return this._showTrailingWhitespace;
}.bind(this);
return {
annotate(contentEl, lineNumberEl, line) {
if (!show()) { return; }
const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
if (match) {
// Normalize string positions in case there is unicode before or
// within the match.
const index = GrAnnotation.getStringLength(
line.text.substr(0, match.index));
const length = GrAnnotation.getStringLength(match[0]);
GrAnnotation.annotateElement(contentEl, index, length,
'style-scope gr-diff trailing-whitespace');
}
},
};
}
setBlame(blame) {
if (!this._builder || !blame) { return; }
this._builder.setBlame(blame);
}
}
customElements.define(GrDiffBuilderElement.is, GrDiffBuilderElement);
})();