blob: 2ed69b604f1e986aae1488c172c408fc4a579d94 [file] [log] [blame]
/**
* @license
* Copyright (C) 2018 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.
*/
import '../../../scripts/bundled-polymer.js';
import '../../core/gr-reporting/gr-reporting.js';
import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
import '../../shared/gr-comment-thread/gr-comment-thread.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
import '../gr-diff/gr-diff.js';
import '../gr-syntax-layer/gr-syntax-layer.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-diff-host_html.js';
import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder.js';
import {util} from '../../../scripts/util.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {DiffSide, rangesEqual} from '../gr-diff/gr-diff-utils.js';
const MSG_EMPTY_BLAME = 'No blame information for this diff.';
const EVENT_AGAINST_PARENT = 'diff-against-parent';
const EVENT_ZERO_REBASE = 'rebase-percent-zero';
const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
const DiffViewMode = {
SIDE_BY_SIDE: 'SIDE_BY_SIDE',
UNIFIED: 'UNIFIED_DIFF',
};
/** @enum {string} */
const TimingLabel = {
TOTAL: 'Diff Total Render',
CONTENT: 'Diff Content Render',
SYNTAX: 'Diff Syntax Render',
};
// Disable syntax highlighting if the overall diff is too large.
const SYNTAX_MAX_DIFF_LENGTH = 20000;
// If any line of the diff is more than the character limit, then disable
// syntax highlighting for the entire file.
const SYNTAX_MAX_LINE_LENGTH = 500;
// 120 lines is good enough threshold for full-sized window viewport
const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
const WHITESPACE_IGNORE_NONE = 'IGNORE_NONE';
/**
* @param {Object} diff
* @return {boolean}
*/
function isImageDiff(diff) {
if (!diff) { return false; }
const isA = diff.meta_a &&
diff.meta_a.content_type.startsWith('image/');
const isB = diff.meta_b &&
diff.meta_b.content_type.startsWith('image/');
return !!(diff.binary && (isA || isB));
}
/**
* Wrapper around gr-diff.
*
* Webcomponent fetching diffs and related data from restAPI and passing them
* to the presentational gr-diff for rendering.
*
* @extends Polymer.Element
*/
class GrDiffHost extends mixinBehaviors( [
PatchSetBehavior,
], GestureEventListeners(
LegacyElementMixin(
PolymerElement))) {
static get template() { return htmlTemplate; }
static get is() { return 'gr-diff-host'; }
/**
* 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 saved or discarded
*
* @event diff-comments-modified
*/
static get properties() {
return {
changeNum: String,
noAutoRender: {
type: Boolean,
value: false,
},
/** @type {?} */
patchRange: Object,
path: String,
prefs: {
type: Object,
},
projectName: String,
displayLine: {
type: Boolean,
value: false,
},
isImageDiff: {
type: Boolean,
computed: '_computeIsImageDiff(diff)',
notify: true,
},
commitRange: Object,
filesWeblinks: {
type: Object,
value() {
return {};
},
notify: true,
},
hidden: {
type: Boolean,
reflectToAttribute: true,
},
noRenderOnPrefsChange: {
type: Boolean,
value: false,
},
comments: {
type: Object,
observer: '_commentsChanged',
},
lineWrapping: {
type: Boolean,
value: false,
},
viewMode: {
type: String,
value: DiffViewMode.SIDE_BY_SIDE,
},
/**
* Special line number which should not be collapsed into a shared region.
*
* @type {{
* number: number,
* leftSide: {boolean}
* }|null}
*/
lineOfInterest: Object,
/**
* If the diff fails to load, show the failure message in the diff rather
* than bubbling the error up to the whole page. This is useful for when
* loading inline diffs because one diff failing need not mark the whole
* page with a failure.
*/
showLoadFailure: Boolean,
isBlameLoaded: {
type: Boolean,
notify: true,
computed: '_computeIsBlameLoaded(_blame)',
},
_loggedIn: {
type: Boolean,
value: false,
},
_loading: {
type: Boolean,
value: false,
},
/** @type {?string} */
_errorMessage: {
type: String,
value: null,
},
/** @type {?Object} */
_baseImage: Object,
/** @type {?Object} */
_revisionImage: Object,
/**
* This is a DiffInfo object.
*/
diff: {
type: Object,
notify: true,
},
/** @type {?Object} */
_blame: {
type: Object,
value: null,
},
/**
* @type {!Array<!Gerrit.CoverageRange>}
*/
_coverageRanges: {
type: Array,
value: () => [],
},
_loadedWhitespaceLevel: String,
_parentIndex: {
type: Number,
computed: '_computeParentIndex(patchRange.*)',
},
_syntaxHighlightingEnabled: {
type: Boolean,
computed:
'_isSyntaxHighlightingEnabled(prefs.*, diff)',
},
_layers: {
type: Array,
value: [],
},
};
}
static get observers() {
return [
'_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
' noRenderOnPrefsChange)',
'_syntaxHighlightingChanged(noRenderOnPrefsChange, prefs.*)',
];
}
/** @override */
created() {
super.created();
this.addEventListener(
// These are named inconsistently for a reason:
// The create-comment event is fired to indicate that we should
// create a comment.
// The comment-* events are just notifying that the comments did already
// change in some way, and that we should update any models we may want
// to keep in sync.
'create-comment',
e => this._handleCreateComment(e));
this.addEventListener('comment-discard',
e => this._handleCommentDiscard(e));
this.addEventListener('comment-update',
e => this._handleCommentUpdate(e));
this.addEventListener('comment-save',
e => this._handleCommentSave(e));
this.addEventListener('render-start',
() => this._handleRenderStart());
this.addEventListener('render-content',
() => this._handleRenderContent());
this.addEventListener('normalize-range',
event => this._handleNormalizeRange(event));
this.addEventListener('diff-context-expanded',
event => this._handleDiffContextExpanded(event));
}
/** @override */
ready() {
super.ready();
if (this._canReload()) {
this.reload();
}
}
/** @override */
attached() {
super.attached();
this._getLoggedIn().then(loggedIn => {
this._loggedIn = loggedIn;
});
}
/**
* @param {boolean=} shouldReportMetric indicate a new Diff Page. This is a
* signal to report metrics event that started on location change.
* @return {!Promise}
**/
reload(shouldReportMetric) {
this._loading = true;
this._errorMessage = null;
const whitespaceLevel = this._getIgnoreWhitespace();
const layers = [this.$.syntaxLayer];
// Get layers from plugins (if any).
for (const pluginLayer of this.$.jsAPI.getDiffLayers(
this.path, this.changeNum, this.patchNum)) {
layers.push(pluginLayer);
}
this._layers = layers;
if (shouldReportMetric) {
// We listen on render viewport only on DiffPage (on paramsChanged)
this._listenToViewportRender();
}
this._coverageRanges = [];
this._getCoverageData();
const diffRequest = this._getDiff()
.then(diff => {
this._loadedWhitespaceLevel = whitespaceLevel;
this._reportDiff(diff);
return diff;
})
.catch(e => {
this._handleGetDiffError(e);
return null;
});
const assetRequest = diffRequest.then(diff => {
// If the diff is null, then it's failed to load.
if (!diff) { return null; }
return this._loadDiffAssets(diff);
});
// Not waiting for coverage ranges intentionally as
// plugin loading should not block the content rendering
return Promise.all([diffRequest, assetRequest])
.then(results => {
const diff = results[0];
if (!diff) {
return Promise.resolve();
}
this.filesWeblinks = this._getFilesWeblinks(diff);
return new Promise(resolve => {
const callback = event => {
const needsSyntaxHighlighting = event.detail &&
event.detail.contentRendered;
if (needsSyntaxHighlighting) {
this.$.reporting.time(TimingLabel.SYNTAX);
this.$.syntaxLayer.process().then(() => {
this.$.reporting.timeEnd(TimingLabel.SYNTAX);
this.$.reporting.timeEnd(TimingLabel.TOTAL);
resolve();
});
} else {
this.$.reporting.timeEnd(TimingLabel.TOTAL);
resolve();
}
this.removeEventListener('render', callback);
if (shouldReportMetric) {
// We report diffViewContentDisplayed only on reload caused
// by params changed - expected only on Diff Page.
this.$.reporting.diffViewContentDisplayed();
}
};
this.addEventListener('render', callback);
this.diff = diff;
});
})
.catch(err => {
console.warn('Error encountered loading diff:', err);
})
.then(() => { this._loading = false; });
}
_getCoverageData() {
const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
this.$.jsAPI.getCoverageAnnotationApi().
then(coverageAnnotationApi => {
if (!coverageAnnotationApi) return;
const provider = coverageAnnotationApi.getCoverageProvider();
return provider(changeNum, path, basePatchNum, patchNum)
.then(coverageRanges => {
if (!coverageRanges ||
changeNum !== this.changeNum ||
path !== this.path ||
basePatchNum !== this.patchRange.basePatchNum ||
patchNum !== this.patchRange.patchNum) {
return;
}
const existingCoverageRanges = this._coverageRanges;
this._coverageRanges = coverageRanges;
// Notify with existing coverage ranges
// in case there is some existing coverage data that needs to be removed
existingCoverageRanges.forEach(range => {
coverageAnnotationApi.notify(
path,
range.code_range.start_line,
range.code_range.end_line,
range.side);
});
// Notify with new coverage data
coverageRanges.forEach(range => {
coverageAnnotationApi.notify(
path,
range.code_range.start_line,
range.code_range.end_line,
range.side);
});
});
})
.catch(err => {
console.warn('Loading coverage ranges failed: ', err);
});
}
_getFilesWeblinks(diff) {
if (!this.commitRange) {
return {};
}
return {
meta_a: GerritNav.getFileWebLinks(
this.projectName, this.commitRange.baseCommit, this.path,
{weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
meta_b: GerritNav.getFileWebLinks(
this.projectName, this.commitRange.commit, this.path,
{weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
};
}
/** Cancel any remaining diff builder rendering work. */
cancel() {
this.$.diff.cancel();
}
/** @return {!Array<!HTMLElement>} */
getCursorStops() {
return this.$.diff.getCursorStops();
}
/** @return {boolean} */
isRangeSelected() {
return this.$.diff.isRangeSelected();
}
createRangeComment() {
return this.$.diff.createRangeComment();
}
toggleLeftDiff() {
this.$.diff.toggleLeftDiff();
}
/**
* Load and display blame information for the base of the diff.
*
* @return {Promise} A promise that resolves when blame finishes rendering.
*/
loadBlame() {
return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
this.path, true)
.then(blame => {
if (!blame.length) {
this.dispatchEvent(new CustomEvent('show-alert', {
detail: {message: MSG_EMPTY_BLAME},
composed: true, bubbles: true,
}));
return Promise.reject(MSG_EMPTY_BLAME);
}
this._blame = blame;
});
}
/** Unload blame information for the diff. */
clearBlame() {
this._blame = null;
}
/**
* The thread elements in this diff, in no particular order.
*
* @return {!Array<!HTMLElement>}
*/
getThreadEls() {
return Array.from(
dom(this.$.diff).querySelectorAll('.comment-thread'));
}
/** @param {HTMLElement} el */
addDraftAtLine(el) {
this.$.diff.addDraftAtLine(el);
}
clearDiffContent() {
this.$.diff.clearDiffContent();
}
expandAllContext() {
this.$.diff.expandAllContext();
}
/** @return {!Promise} */
_getLoggedIn() {
return this.$.restAPI.getLoggedIn();
}
/** @return {boolean}} */
_canReload() {
return !!this.changeNum && !!this.patchRange && !!this.path &&
!this.noAutoRender;
}
/** @return {!Promise<!Object>} */
_getDiff() {
// Wrap the diff request in a new promise so that the error handler
// rejects the promise, allowing the error to be handled in the .catch.
return new Promise((resolve, reject) => {
this.$.restAPI.getDiff(
this.changeNum,
this.patchRange.basePatchNum,
this.patchRange.patchNum,
this.path,
this._getIgnoreWhitespace(),
reject)
.then(resolve);
});
}
_handleGetDiffError(response) {
// Loading the diff may respond with 409 if the file is too large. In this
// case, use a toast error..
if (response.status === 409) {
this.dispatchEvent(new CustomEvent('server-error', {
detail: {response},
composed: true, bubbles: true,
}));
return;
}
if (this.showLoadFailure) {
this._errorMessage = [
'Encountered error when loading the diff:',
response.status,
response.statusText,
].join(' ');
return;
}
this.dispatchEvent(new CustomEvent('page-error', {
detail: {response},
composed: true, bubbles: true,
}));
}
/**
* Report info about the diff response.
*/
_reportDiff(diff) {
if (!diff || !diff.content) {
return;
}
// Count the delta lines stemming from normal deltas, and from
// due_to_rebase deltas.
let nonRebaseDelta = 0;
let rebaseDelta = 0;
diff.content.forEach(chunk => {
if (chunk.ab) { return; }
const deltaSize = Math.max(
chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
if (chunk.due_to_rebase) {
rebaseDelta += deltaSize;
} else {
nonRebaseDelta += deltaSize;
}
});
// Find the percent of the delta from due_to_rebase chunks rounded to two
// digits. Diffs with no delta are considered 0%.
const totalDelta = rebaseDelta + nonRebaseDelta;
const percentRebaseDelta = !totalDelta ? 0 :
Math.round(100 * rebaseDelta / totalDelta);
// Report the due_to_rebase percentage in the "diff" category when
// applicable.
if (this.patchRange.basePatchNum === 'PARENT') {
this.$.reporting.reportInteraction(EVENT_AGAINST_PARENT);
} else if (percentRebaseDelta === 0) {
this.$.reporting.reportInteraction(EVENT_ZERO_REBASE);
} else {
this.$.reporting.reportInteraction(EVENT_NONZERO_REBASE,
{percentRebaseDelta});
}
}
/**
* @param {Object} diff
* @return {!Promise}
*/
_loadDiffAssets(diff) {
if (isImageDiff(diff)) {
return this._getImages(diff).then(images => {
this._baseImage = images.baseImage;
this._revisionImage = images.revisionImage;
});
} else {
this._baseImage = null;
this._revisionImage = null;
return Promise.resolve();
}
}
/**
* @param {Object} diff
* @return {boolean}
*/
_computeIsImageDiff(diff) {
return isImageDiff(diff);
}
_commentsChanged(newComments) {
const allComments = [];
for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
// This is needed by the threading.
for (const comment of newComments[side]) {
comment.__commentSide = side;
}
allComments.push(...newComments[side]);
}
// Currently, the only way this is ever changed here is when the initial
// comments are loaded, so it's okay performance wise to clear the threads
// and recreate them. If this changes in future, we might want to reuse
// some DOM nodes here.
this._clearThreads();
const threads = this._createThreads(allComments);
for (const thread of threads) {
const threadEl = this._createThreadElement(thread);
this._attachThreadElement(threadEl);
}
}
_sortComments(comments) {
return comments.slice(0).sort((a, b) => {
if (b.__draft && !a.__draft ) { return -1; }
if (a.__draft && !b.__draft ) { return 1; }
return util.parseDate(a.updated) - util.parseDate(b.updated);
});
}
/**
* @param {!Array<!Object>} comments
* @return {!Array<!Object>} Threads for the given comments.
*/
_createThreads(comments) {
const sortedComments = this._sortComments(comments);
const threads = [];
for (const comment of sortedComments) {
// If the comment is in reply to another comment, find that comment's
// thread and append to it.
if (comment.in_reply_to) {
const thread = threads.find(thread =>
thread.comments.some(c => c.id === comment.in_reply_to));
if (thread) {
thread.comments.push(comment);
continue;
}
}
// Otherwise, this comment starts its own thread.
const newThread = {
start_datetime: comment.updated,
comments: [comment],
commentSide: comment.__commentSide,
patchNum: comment.patch_set,
rootId: comment.id || comment.__draftID,
lineNum: comment.line,
isOnParent: comment.side === 'PARENT',
};
if (comment.range) {
newThread.range = Object.assign({}, comment.range);
}
threads.push(newThread);
}
return threads;
}
/**
* @param {Object} blame
* @return {boolean}
*/
_computeIsBlameLoaded(blame) {
return !!blame;
}
/**
* @param {Object} diff
* @return {!Promise}
*/
_getImages(diff) {
return this.$.restAPI.getImagesForDiff(this.changeNum, diff,
this.patchRange);
}
/** @param {CustomEvent} e */
_handleCreateComment(e) {
const {lineNum, side, patchNum, isOnParent, range} = e.detail;
const threadEl = this._getOrCreateThread(patchNum, lineNum, side, range,
isOnParent);
threadEl.addOrEditDraft(lineNum, range);
this.$.reporting.recordDraftInteraction();
}
/**
* Gets or creates a comment thread at a given location.
* May provide a range, to get/create a range comment.
*
* @param {string} patchNum
* @param {?number} lineNum
* @param {string} commentSide
* @param {Gerrit.Range|undefined} range
* @param {boolean} isOnParent
* @return {!Object}
*/
_getOrCreateThread(patchNum, lineNum, commentSide, range, isOnParent) {
let threadEl = this._getThreadEl(lineNum, commentSide, range);
if (!threadEl) {
threadEl = this._createThreadElement({
comments: [],
commentSide,
patchNum,
lineNum,
range,
isOnParent,
});
this._attachThreadElement(threadEl);
}
return threadEl;
}
_attachThreadElement(threadEl) {
dom(this.$.diff).appendChild(threadEl);
}
_clearThreads() {
for (const threadEl of this.getThreadEls()) {
const parent = dom(threadEl).parentNode;
dom(parent).removeChild(threadEl);
}
}
_createThreadElement(thread) {
const threadEl = document.createElement('gr-comment-thread');
threadEl.className = 'comment-thread';
threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
threadEl.comments = thread.comments;
threadEl.commentSide = thread.commentSide;
threadEl.isOnParent = !!thread.isOnParent;
threadEl.parentIndex = this._parentIndex;
threadEl.changeNum = this.changeNum;
threadEl.patchNum = thread.patchNum;
threadEl.lineNum = thread.lineNum;
const rootIdChangedListener = changeEvent => {
thread.rootId = changeEvent.detail.value;
};
threadEl.addEventListener('root-id-changed', rootIdChangedListener);
threadEl.path = this.path;
threadEl.projectName = this.projectName;
threadEl.range = thread.range;
const threadDiscardListener = e => {
const threadEl = /** @type {!Node} */ (e.currentTarget);
const parent = dom(threadEl).parentNode;
dom(parent).removeChild(threadEl);
threadEl.removeEventListener('root-id-changed', rootIdChangedListener);
threadEl.removeEventListener('thread-discard', threadDiscardListener);
};
threadEl.addEventListener('thread-discard', threadDiscardListener);
return threadEl;
}
/**
* Gets a comment thread element at a given location.
* May provide a range, to get a range comment.
*
* @param {?number} lineNum
* @param {string} commentSide
* @param {!Gerrit.Range=} range
* @return {?Node}
*/
_getThreadEl(lineNum, commentSide, range = undefined) {
let line;
if (commentSide === GrDiffBuilder.Side.LEFT) {
line = {beforeNumber: lineNum};
} else if (commentSide === GrDiffBuilder.Side.RIGHT) {
line = {afterNumber: lineNum};
} else {
throw new Error(`Unknown side: ${commentSide}`);
}
function matchesRange(threadEl) {
const threadRange = /** @type {!Gerrit.Range} */(
JSON.parse(threadEl.getAttribute('range')));
return rangesEqual(threadRange, range);
}
const filteredThreadEls = this._filterThreadElsForLocation(
this.getThreadEls(), line, commentSide).filter(matchesRange);
return filteredThreadEls.length ? filteredThreadEls[0] : null;
}
/**
* @param {!Array<!HTMLElement>} threadEls
* @param {!{beforeNumber: (number|string|undefined|null),
* afterNumber: (number|string|undefined|null)}}
* lineInfo
* @param {!DiffSide=} side The side (LEFT, RIGHT) for
* which to return the threads.
* @return {!Array<!HTMLElement>} The thread elements matching the given
* location.
*/
_filterThreadElsForLocation(threadEls, lineInfo, side) {
function matchesLeftLine(threadEl) {
return threadEl.getAttribute('comment-side') ==
DiffSide.LEFT &&
threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
}
function matchesRightLine(threadEl) {
return threadEl.getAttribute('comment-side') ==
DiffSide.RIGHT &&
threadEl.getAttribute('line-num') == lineInfo.afterNumber;
}
function matchesFileComment(threadEl) {
return threadEl.getAttribute('comment-side') == side &&
// line/range comments have 1-based line set, if line is falsy it's
// a file comment
!threadEl.getAttribute('line-num');
}
// Select the appropriate matchers for the desired side and line
// If side is BOTH, we want both the left and right matcher.
const matchers = [];
if (side !== DiffSide.RIGHT) {
matchers.push(matchesLeftLine);
}
if (side !== DiffSide.LEFT) {
matchers.push(matchesRightLine);
}
if (lineInfo.afterNumber === 'FILE' ||
lineInfo.beforeNumber === 'FILE') {
matchers.push(matchesFileComment);
}
return threadEls.filter(threadEl =>
matchers.some(matcher => matcher(threadEl)));
}
_getIgnoreWhitespace() {
if (!this.prefs || !this.prefs.ignore_whitespace) {
return WHITESPACE_IGNORE_NONE;
}
return this.prefs.ignore_whitespace;
}
_whitespaceChanged(
preferredWhitespaceLevel, loadedWhitespaceLevel,
noRenderOnPrefsChange) {
// Polymer 2: check for undefined
if ([
preferredWhitespaceLevel,
loadedWhitespaceLevel,
noRenderOnPrefsChange,
].some(arg => arg === undefined)) {
return;
}
if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
!noRenderOnPrefsChange) {
this.reload();
}
}
_syntaxHighlightingChanged(noRenderOnPrefsChange, prefsChangeRecord) {
// Polymer 2: check for undefined
if ([
noRenderOnPrefsChange,
prefsChangeRecord,
].some(arg => arg === undefined)) {
return;
}
if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') {
return;
}
if (!noRenderOnPrefsChange) {
this.reload();
}
}
/**
* @param {Object} patchRangeRecord
* @return {number|null}
*/
_computeParentIndex(patchRangeRecord) {
return this.isMergeParent(patchRangeRecord.base.basePatchNum) ?
this.getParentIndex(patchRangeRecord.base.basePatchNum) : null;
}
_handleCommentSave(e) {
const comment = e.detail.comment;
const side = e.detail.comment.__commentSide;
const idx = this._findDraftIndex(comment, side);
this.set(['comments', side, idx], comment);
this._handleCommentSaveOrDiscard();
}
_handleCommentDiscard(e) {
const comment = e.detail.comment;
this._removeComment(comment);
this._handleCommentSaveOrDiscard();
}
/**
* Closure annotation for Polymer.prototype.push is off. Submitted PR:
* https://github.com/Polymer/polymer/pull/4776
* but for not supressing annotations.
*
* @suppress {checkTypes}
*/
_handleCommentUpdate(e) {
const comment = e.detail.comment;
const side = e.detail.comment.__commentSide;
let idx = this._findCommentIndex(comment, side);
if (idx === -1) {
idx = this._findDraftIndex(comment, side);
}
if (idx !== -1) { // Update draft or comment.
this.set(['comments', side, idx], comment);
} else { // Create new draft.
this.push(['comments', side], comment);
}
}
_handleCommentSaveOrDiscard() {
this.dispatchEvent(new CustomEvent(
'diff-comments-modified', {bubbles: true, composed: true}));
}
_removeComment(comment) {
const side = comment.__commentSide;
this._removeCommentFromSide(comment, side);
}
_removeCommentFromSide(comment, side) {
let idx = this._findCommentIndex(comment, side);
if (idx === -1) {
idx = this._findDraftIndex(comment, side);
}
if (idx !== -1) {
this.splice('comments.' + side, idx, 1);
}
}
/** @return {number} */
_findCommentIndex(comment, side) {
if (!comment.id || !this.comments[side]) {
return -1;
}
return this.comments[side].findIndex(item => item.id === comment.id);
}
/** @return {number} */
_findDraftIndex(comment, side) {
if (!comment.__draftID || !this.comments[side]) {
return -1;
}
return this.comments[side].findIndex(
item => item.__draftID === comment.__draftID);
}
_isSyntaxHighlightingEnabled(preferenceChangeRecord, diff) {
if (!preferenceChangeRecord ||
!preferenceChangeRecord.base ||
!preferenceChangeRecord.base.syntax_highlighting ||
!diff) {
return false;
}
return !this._anyLineTooLong(diff) &&
this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH;
}
/**
* @return {boolean} whether any of the lines in diff are longer
* than SYNTAX_MAX_LINE_LENGTH.
*/
_anyLineTooLong(diff) {
if (!diff) return false;
return diff.content.some(section => {
const lines = section.ab ?
section.ab :
(section.a || []).concat(section.b || []);
return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
});
}
_listenToViewportRender() {
const renderUpdateListener = start => {
if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
this.$.reporting.diffViewDisplayed();
this.$.syntaxLayer.removeListener(renderUpdateListener);
}
};
this.$.syntaxLayer.addListener(renderUpdateListener);
}
_handleRenderStart() {
this.$.reporting.time(TimingLabel.TOTAL);
this.$.reporting.time(TimingLabel.CONTENT);
}
_handleRenderContent() {
this.$.reporting.timeEnd(TimingLabel.CONTENT);
}
_handleNormalizeRange(event) {
this.$.reporting.reportInteraction('normalize-range',
{
side: event.detail.side,
lineNum: event.detail.lineNum,
});
}
_handleDiffContextExpanded(event) {
this.$.reporting.reportInteraction(
'diff-context-expanded', {numLines: event.detail.numLines}
);
}
/**
* 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 || !chunk.a.length)) ||
(!leftSide && (!chunk.b || !chunk.b.length))));
// 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] === '';
}
_showNewlineWarningLeft(diff) {
return this._hasTrailingNewlines(diff, true) === false;
}
_showNewlineWarningRight(diff) {
return this._hasTrailingNewlines(diff, false) === false;
}
}
customElements.define(GrDiffHost.is, GrDiffHost);