Files
gerrit/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
Ole Rehmsen a970d4e834 Imperative named Slots
The idea is to instead of imperatively appending slotted comments to
threadGroupEls (which results in them being removed from the light DOM
of the gr-diff element and thus causing all kinds of tracking
difficulties), to slot them into a named slot in the appropriate line.

Change-Id: I17e99b236240baab581f8c266bed3d400a302408
2018-11-19 20:59:57 +01:00

815 lines
24 KiB
JavaScript

/**
* @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() {
'use strict';
const ERR_COMMENT_ON_EDIT = 'You cannot comment on an edit.';
const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
'of an edit.';
const ERR_INVALID_LINE = 'Invalid line number: ';
const NO_NEWLINE_BASE = 'No newline at end of base file.';
const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
const DiffViewMode = {
SIDE_BY_SIDE: 'SIDE_BY_SIDE',
UNIFIED: 'UNIFIED_DIFF',
};
const DiffSide = {
LEFT: 'left',
RIGHT: 'right',
};
const Defs = {};
/**
* Special line number which should not be collapsed into a shared region.
*
* @typedef {{
* number: number,
* leftSide: boolean
* }}
*/
Defs.LineOfInterest;
const LARGE_DIFF_THRESHOLD_LINES = 10000;
const FULL_CONTEXT = -1;
const LIMITED_CONTEXT = 10;
/** @typedef {{start_line: number, start_character: number,
* end_line: number, end_character: number}} */
Gerrit.Range;
function isThreadEl(node) {
return node.nodeType === Node.ELEMENT_NODE &&
node.classList.contains('comment-thread');
}
/**
* Turn a slot element into the corresponding content element.
* Slots are only fully supported in Polymer 2 - in Polymer 1, they are
* replaced with content elements during template parsing. This conversion is
* not applied for imperatively created slot elements, so this method
* implements the same behavior as the template parsing for imperative slots.
*/
Gerrit.slotToContent = function(slot) {
const content = document.createElement('content');
content.name = slot.name;
content.setAttribute('select', `[slot='${slot.name}']`);
return content;
};
Polymer({
is: 'gr-diff',
/**
* Fired when the user selects a line.
* @event line-selected
*/
/**
* Fired if being logged in is required.
*
* @event show-auth-required
*/
/**
* Fired when a comment is created
*
* @event create-comment
*/
properties: {
changeNum: String,
noAutoRender: {
type: Boolean,
value: false,
},
/** @type {?} */
patchRange: Object,
path: String,
prefs: {
type: Object,
observer: '_prefsObserver',
},
projectName: String,
displayLine: {
type: Boolean,
value: false,
},
isImageDiff: {
type: Boolean,
},
commitRange: Object,
hidden: {
type: Boolean,
reflectToAttribute: true,
},
noRenderOnPrefsChange: Boolean,
/** @type {!Array<!Gerrit.HoveredRange>} */
_commentRanges: {
type: Array,
value: () => [],
},
lineWrapping: {
type: Boolean,
value: false,
observer: '_lineWrappingObserver',
},
viewMode: {
type: String,
value: DiffViewMode.SIDE_BY_SIDE,
observer: '_viewModeObserver',
},
/** @type ?Defs.LineOfInterest */
lineOfInterest: Object,
/**
* The key locations based on the comments and line of interests,
* where lines should not be collapsed.
*
* @type {{left: Object<(string|number), number>,
* right: Object<(string|number), number>}}
*/
_keyLocations: {
type: Object,
value: () => ({
left: {},
right: {},
}),
},
loading: {
type: Boolean,
value: false,
observer: '_loadingChanged',
},
loggedIn: {
type: Boolean,
value: false,
},
diff: {
type: Object,
observer: '_diffChanged',
},
_diffHeaderItems: {
type: Array,
value: [],
computed: '_computeDiffHeaderItems(diff.*)',
},
_diffTableClass: {
type: String,
value: '',
},
/** @type {?Object} */
baseImage: Object,
/** @type {?Object} */
revisionImage: Object,
/**
* Whether the safety check for large diffs when whole-file is set has
* been bypassed. If the value is null, then the safety has not been
* bypassed. If the value is a number, then that number represents the
* context preference to use when rendering the bypassed diff.
*
* @type (number|null)
*/
_safetyBypass: {
type: Number,
value: null,
},
_showWarning: Boolean,
/** @type {?string} */
errorMessage: {
type: String,
value: null,
},
/** @type {?Object} */
blame: {
type: Object,
value: null,
observer: '_blameChanged',
},
parentIndex: Number,
_newlineWarning: {
type: String,
computed: '_computeNewlineWarning(diff)',
},
_diffLength: Number,
/**
* Observes comment nodes added or removed after the initial render.
* Can be used to unregister when the entire diff is (re-)rendered or upon
* detachment.
* @type {?PolymerDomApi.ObserveHandle}
*/
_incrementalNodeObserver: Object,
/**
* Observes comment nodes added or removed at any point.
* Can be used to unregister upon detachment.
* @type {?PolymerDomApi.ObserveHandle}
*/
_nodeObserver: Object,
},
behaviors: [
Gerrit.PatchSetBehavior,
],
listeners: {
'create-range-comment': '_handleCreateRangeComment',
'render-content': '_handleRenderContent',
},
attached() {
this._observeNodes();
},
detached() {
this._unobserveIncrementalNodes();
this._unobserveNodes();
},
_observeNodes() {
this._nodeObserver = Polymer.dom(this).observeNodes(info => {
const addedThreadEls = info.addedNodes.filter(isThreadEl);
// In principle we should also handle removed nodes, but I have not
// figured out how to do that yet without also catching all the removals
// caused by further redistribution. Right now, comments are never
// removed by no longer slotting them in, so I decided to not handle
// this situation until it occurs.
this._updateRanges(addedThreadEls);
this._updateKeyLocations(addedThreadEls);
this._redispatchHoverEvents(addedThreadEls);
});
},
_updateRanges(addedThreadEls) {
function commentRangeFromThreadEl(threadEl) {
const side = threadEl.getAttribute('comment-side');
const range = JSON.parse(threadEl.getAttribute('range'));
return {side, range, hovering: false};
}
const addedCommentRanges = addedThreadEls
.map(commentRangeFromThreadEl)
.filter(({range}) => range);
this.push('_commentRanges', ...addedCommentRanges);
},
_updateKeyLocations(addedThreadEls) {
for (const threadEl of addedThreadEls) {
const commentSide = threadEl.getAttribute('comment-side');
const lineNum = threadEl.getAttribute('line-num') || GrDiffLine.FILE;
this._keyLocations[commentSide][lineNum] = true;
}
},
// Dispatch events that are handled by the gr-diff-highlight.
_redispatchHoverEvents(addedThreadEls) {
for (const threadEl of addedThreadEls) {
threadEl.addEventListener('mouseenter', () => {
threadEl.dispatchEvent(
new CustomEvent('comment-thread-mouseenter', {bubbles: true}));
});
threadEl.addEventListener('mouseleave', () => {
threadEl.dispatchEvent(
new CustomEvent('comment-thread-mouseleave', {bubbles: true}));
});
}
},
/** Cancel any remaining diff builder rendering work. */
cancel() {
this.$.diffBuilder.cancel();
},
/** @return {!Array<!HTMLElement>} */
getCursorStops() {
if (this.hidden && this.noAutoRender) {
return [];
}
return Polymer.dom(this.root).querySelectorAll('.diff-row');
},
/** @return {boolean} */
isRangeSelected() {
return this.$.highlights.isRangeSelected();
},
toggleLeftDiff() {
this.toggleClass('no-left');
},
_blameChanged(newValue) {
this.$.diffBuilder.setBlame(newValue);
if (newValue) {
this.classList.add('showBlame');
} else {
this.classList.remove('showBlame');
}
},
/** @return {string} */
_computeContainerClass(loggedIn, viewMode, displayLine) {
const classes = ['diffContainer'];
switch (viewMode) {
case DiffViewMode.UNIFIED:
classes.push('unified');
break;
case DiffViewMode.SIDE_BY_SIDE:
classes.push('sideBySide');
break;
default:
throw Error('Invalid view mode: ', viewMode);
}
if (Gerrit.hiddenscroll) {
classes.push('hiddenscroll');
}
if (loggedIn) {
classes.push('canComment');
}
if (displayLine) {
classes.push('displayLine');
}
return classes.join(' ');
},
_handleTap(e) {
const el = Polymer.dom(e).localTarget;
if (el.classList.contains('showContext')) {
this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
} else if (el.classList.contains('lineNum')) {
this.addDraftAtLine(el);
} else if (el.tagName === 'HL' ||
el.classList.contains('content') ||
el.classList.contains('contentText')) {
const target = this.$.diffBuilder.getLineElByChild(el);
if (target) { this._selectLine(target); }
}
},
_selectLine(el) {
this.fire('line-selected', {
side: el.classList.contains('left') ? DiffSide.LEFT : DiffSide.RIGHT,
number: el.getAttribute('data-value'),
path: this.path,
});
},
addDraftAtLine(el) {
this._selectLine(el);
if (!this._isValidElForComment(el)) { return; }
const value = el.getAttribute('data-value');
let lineNum;
if (value !== GrDiffLine.FILE) {
lineNum = parseInt(value, 10);
if (isNaN(lineNum)) {
this.fire('show-alert', {message: ERR_INVALID_LINE + value});
return;
}
}
this._createComment(el, lineNum);
},
_handleCreateRangeComment(e) {
const range = e.detail.range;
const side = e.detail.side;
const lineNum = range.end_line;
const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
if (this._isValidElForComment(lineEl)) {
this._createComment(lineEl, lineNum, side, range);
}
},
/** @return {boolean} */
_isValidElForComment(el) {
if (!this.loggedIn) {
this.fire('show-auth-required');
return false;
}
const patchNum = el.classList.contains(DiffSide.LEFT) ?
this.patchRange.basePatchNum :
this.patchRange.patchNum;
const isEdit = this.patchNumEquals(patchNum, this.EDIT_NAME);
const isEditBase = this.patchNumEquals(patchNum, this.PARENT_NAME) &&
this.patchNumEquals(this.patchRange.patchNum, this.EDIT_NAME);
if (isEdit) {
this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT});
return false;
} else if (isEditBase) {
this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT_BASE});
return false;
}
return true;
},
/**
* @param {!Object} lineEl
* @param {number=} lineNum
* @param {string=} side
* @param {!Object=} range
*/
_createComment(lineEl, lineNum=undefined, side=undefined, range=undefined) {
const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
const contentEl = contentText.parentElement;
side = side ||
this._getCommentSideByLineAndContent(lineEl, contentEl);
const patchForNewThreads = this._getPatchNumByLineAndContent(
lineEl, contentEl);
const isOnParent =
this._getIsParentCommentByLineAndContent(lineEl, contentEl);
this.dispatchEvent(new CustomEvent('create-comment', {
bubbles: true,
detail: {
lineNum,
side,
patchNum: patchForNewThreads,
isOnParent,
range,
},
}));
},
_getThreadGroupForLine(contentEl) {
return contentEl.querySelector('.thread-group');
},
/**
* Gets or creates a comment thread group for a specific line and side on a
* diff.
* @param {!Object} contentEl
* @param {!Gerrit.DiffSide} commentSide
* @return {!Node}
*/
_getOrCreateThreadGroup(contentEl, commentSide) {
// Check if thread group exists.
let threadGroupEl = this._getThreadGroupForLine(contentEl);
if (!threadGroupEl) {
threadGroupEl = document.createElement('div');
threadGroupEl.className = 'thread-group';
threadGroupEl.setAttribute('data-side', commentSide);
contentEl.appendChild(threadGroupEl);
}
return threadGroupEl;
},
/**
* The value to be used for the patch number of new comments created at the
* given line and content elements.
*
* In two cases of creating a comment on the left side, the patch number to
* be used should actually be right side of the patch range:
* - When the patch range is against the parent comment of a normal change.
* Such comments declare themmselves to be on the left using side=PARENT.
* - If the patch range is against the indexed parent of a merge change.
* Such comments declare themselves to be on the given parent by
* specifying the parent index via parent=i.
*
* @return {number}
*/
_getPatchNumByLineAndContent(lineEl, contentEl) {
let patchNum = this.patchRange.patchNum;
if ((lineEl.classList.contains(DiffSide.LEFT) ||
contentEl.classList.contains('remove')) &&
this.patchRange.basePatchNum !== 'PARENT' &&
!this.isMergeParent(this.patchRange.basePatchNum)) {
patchNum = this.patchRange.basePatchNum;
}
return patchNum;
},
/** @return {boolean} */
_getIsParentCommentByLineAndContent(lineEl, contentEl) {
if ((lineEl.classList.contains(DiffSide.LEFT) ||
contentEl.classList.contains('remove')) &&
(this.patchRange.basePatchNum === 'PARENT' ||
this.isMergeParent(this.patchRange.basePatchNum))) {
return true;
}
return false;
},
/** @return {string} */
_getCommentSideByLineAndContent(lineEl, contentEl) {
let side = 'right';
if (lineEl.classList.contains(DiffSide.LEFT) ||
contentEl.classList.contains('remove')) {
side = 'left';
}
return side;
},
_prefsObserver(newPrefs, oldPrefs) {
// Scan the preference objects one level deep to see if they differ.
let differ = !oldPrefs;
if (newPrefs && oldPrefs) {
for (const key in newPrefs) {
if (newPrefs[key] !== oldPrefs[key]) {
differ = true;
}
}
}
if (differ) {
this._prefsChanged(newPrefs);
}
},
_viewModeObserver() {
this._prefsChanged(this.prefs);
},
/** @param {boolean} newValue */
_loadingChanged(newValue) {
if (newValue) {
this.cancel();
this._blame = null;
this._safetyBypass = null;
this._showWarning = false;
this.clearDiffContent();
}
},
_lineWrappingObserver() {
this._prefsChanged(this.prefs);
},
_prefsChanged(prefs) {
if (!prefs) { return; }
this._blame = null;
const stylesToUpdate = {};
if (prefs.line_wrapping) {
this._diffTableClass = 'full-width';
if (this.viewMode === 'SIDE_BY_SIDE') {
stylesToUpdate['--content-width'] = 'none';
stylesToUpdate['--line-limit'] = prefs.line_length + 'ch';
}
} else {
this._diffTableClass = '';
stylesToUpdate['--content-width'] = prefs.line_length + 'ch';
}
if (prefs.font_size) {
stylesToUpdate['--font-size'] = prefs.font_size + 'px';
}
this.updateStyles(stylesToUpdate);
if (this.diff && !this.noRenderOnPrefsChange) {
this._renderDiffTable();
}
},
_diffChanged(newValue) {
if (newValue) {
this._diffLength = this.$.diffBuilder.getDiffLength();
this._renderDiffTable();
}
},
_renderDiffTable() {
this._unobserveIncrementalNodes();
if (!this.prefs) {
this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
return;
}
if (this.prefs.context === -1 &&
this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
this._safetyBypass === null) {
this._showWarning = true;
this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
return;
}
this._showWarning = false;
if (this.lineOfInterest) {
const side = this.lineOfInterest.leftSide ? 'left' : 'right';
this._keyLocations[side][this.lineOfInterest.number] = true;
}
this.$.diffBuilder.render(this._keyLocations, this._getBypassPrefs());
},
_handleRenderContent() {
this._incrementalNodeObserver = Polymer.dom(this).observeNodes(info => {
const addedThreadEls = info.addedNodes.filter(isThreadEl);
// In principle we should also handle removed nodes, but I have not
// figured out how to do that yet without also catching all the removals
// caused by further redistribution. Right now, comments are never
// removed by no longer slotting them in, so I decided to not handle
// this situation until it occurs.
for (const threadEl of addedThreadEls) {
const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
const commentSide = threadEl.getAttribute('comment-side');
const lineEl = this.$.diffBuilder.getLineElByNumber(
lineNumString, commentSide);
const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
const contentEl = contentText.parentElement;
const threadGroupEl = this._getOrCreateThreadGroup(
contentEl, commentSide);
// Create a slot for the thread and attach it to the thread group.
// The Polyfill has some bugs and this only works if the slot is
// attached to the group after the group is attached to the DOM.
// The thread group may already have a slot with the right name, but
// that is okay because the first matching slot is used and the rest
// are ignored.
const slot = document.createElement('slot');
slot.name = threadEl.slot;
Polymer.dom(threadGroupEl).appendChild(Gerrit.slotToContent(slot));
}
});
},
_unobserveIncrementalNodes() {
if (this._incrementalNodeObserver) {
Polymer.dom(this).unobserveNodes(this._incrementalNodeObserver);
}
},
_unobserveNodes() {
if (this._nodeObserver) {
Polymer.dom(this).unobserveNodes(this._nodeObserver);
}
},
/**
* Get the preferences object including the safety bypass context (if any).
*/
_getBypassPrefs() {
if (this._safetyBypass !== null) {
return Object.assign({}, this.prefs, {context: this._safetyBypass});
}
return this.prefs;
},
clearDiffContent() {
this._unobserveIncrementalNodes();
this.$.diffTable.innerHTML = null;
},
/** @return {!Array} */
_computeDiffHeaderItems(diffInfoRecord) {
const diffInfo = diffInfoRecord.base;
if (!diffInfo || !diffInfo.diff_header) { return []; }
return diffInfo.diff_header.filter(item => {
return !(item.startsWith('diff --git ') ||
item.startsWith('index ') ||
item.startsWith('+++ ') ||
item.startsWith('--- ') ||
item === 'Binary files differ');
});
},
/** @return {boolean} */
_computeDiffHeaderHidden(items) {
return items.length === 0;
},
_handleFullBypass() {
this._safetyBypass = FULL_CONTEXT;
this._renderDiffTable();
},
_handleLimitedBypass() {
this._safetyBypass = LIMITED_CONTEXT;
this._renderDiffTable();
},
/** @return {string} */
_computeWarningClass(showWarning) {
return showWarning ? 'warn' : '';
},
/**
* @param {string} errorMessage
* @return {string}
*/
_computeErrorClass(errorMessage) {
return errorMessage ? 'showError' : '';
},
expandAllContext() {
this._handleFullBypass();
},
/**
* Find the last chunk for the given side.
* @param {!Object} diff
* @param {boolean} leftSide true if checking the base of the diff,
* false if testing the revision.
* @return {Object|null} returns the chunk object or null if there was
* no chunk for that side.
*/
_lastChunkForSide(diff, leftSide) {
if (!diff.content.length) { return null; }
let chunkIndex = diff.content.length;
let chunk;
// Walk backwards until we find a chunk for the given side.
do {
chunkIndex--;
chunk = diff.content[chunkIndex];
} while (
// We haven't reached the beginning.
chunkIndex >= 0 &&
// The chunk doesn't have both sides.
!chunk.ab &&
// The chunk doesn't have the given side.
((leftSide && !chunk.a) || (!leftSide && !chunk.b)));
// If we reached the beginning of the diff and failed to find a chunk
// with the given side, return null.
if (chunkIndex === -1) { return null; }
return chunk;
},
/**
* Check whether the specified side of the diff has a trailing newline.
* @param {!Object} diff
* @param {boolean} leftSide true if checking the base of the diff,
* false if testing the revision.
* @return {boolean|null} Return true if the side has a trailing newline.
* Return false if it doesn't. Return null if not applicable (for
* example, if the diff has no content on the specified side).
*/
_hasTrailingNewlines(diff, leftSide) {
const chunk = this._lastChunkForSide(diff, leftSide);
if (!chunk) { return null; }
let lines;
if (chunk.ab) {
lines = chunk.ab;
} else {
lines = leftSide ? chunk.a : chunk.b;
}
return lines[lines.length - 1] === '';
},
/**
* @param {!Object} diff
* @return {string|null}
*/
_computeNewlineWarning(diff) {
const hasLeft = this._hasTrailingNewlines(diff, true);
const hasRight = this._hasTrailingNewlines(diff, false);
const messages = [];
if (hasLeft === false) {
messages.push(NO_NEWLINE_BASE);
}
if (hasRight === false) {
messages.push(NO_NEWLINE_REVISION);
}
if (!messages.length) { return null; }
return messages.join(' — ');
},
/**
* @param {string} warning
* @param {boolean} loading
* @return {string}
*/
_computeNewlineWarningClass(warning, loading) {
if (loading || !warning) { return 'newlineWarning hidden'; }
return 'newlineWarning';
},
});
})();