The loading state is only used in gr-diff, so it makes sense to be also set in gr-diff. This change slightly changes the behavior of loading to flip to false when the content is rendered, not only after syntax highlighting is done. That is not a problem though, because the two places that read it only care about content, too. Change-Id: I2b4bda1ff8f336bce4a7a763d1479b2474a2beb7
1211 lines
34 KiB
TypeScript
1211 lines
34 KiB
TypeScript
/**
|
|
* @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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
|
|
import '../../shared/gr-comment-thread/gr-comment-thread';
|
|
import '../../shared/gr-js-api-interface/gr-js-api-interface';
|
|
import '../gr-diff/gr-diff';
|
|
import '../gr-syntax-layer/gr-syntax-layer';
|
|
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
|
|
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
|
|
import {PolymerElement} from '@polymer/polymer/polymer-element';
|
|
import {htmlTemplate} from './gr-diff-host_html';
|
|
import {GerritNav} from '../../core/gr-navigation/gr-navigation';
|
|
import {rangesEqual} from '../gr-diff/gr-diff-utils';
|
|
import {appContext} from '../../../services/app-context';
|
|
import {
|
|
getParentIndex,
|
|
isMergeParent,
|
|
isNumber,
|
|
} from '../../../utils/patch-set-util';
|
|
import {
|
|
Comment,
|
|
isDraft,
|
|
PatchSetFile,
|
|
sortComments,
|
|
UIComment,
|
|
} from '../../../utils/comment-util';
|
|
import {TwoSidesComments} from '../gr-comment-api/gr-comment-api';
|
|
import {customElement, observe, property} from '@polymer/decorators';
|
|
import {
|
|
CommitRange,
|
|
CoverageRange,
|
|
DiffLayer,
|
|
DiffLayerListener,
|
|
} from '../../../types/types';
|
|
import {
|
|
Base64ImageFile,
|
|
BlameInfo,
|
|
CommentRange,
|
|
DiffInfo,
|
|
DiffPreferencesInfo,
|
|
NumericChangeId,
|
|
PatchRange,
|
|
PatchSetNum,
|
|
RepoName,
|
|
} from '../../../types/common';
|
|
import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
|
|
import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
|
|
import {GrDiff, LineOfInterest} from '../gr-diff/gr-diff';
|
|
import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer';
|
|
import {
|
|
DiffViewMode,
|
|
IgnoreWhitespaceType,
|
|
Side,
|
|
} from '../../../constants/constants';
|
|
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
|
|
import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
|
|
import {LineNumber} from '../gr-diff/gr-diff-line';
|
|
import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
|
|
|
|
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 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;
|
|
|
|
function isImageDiff(diff?: DiffInfo) {
|
|
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));
|
|
}
|
|
|
|
interface LineInfo {
|
|
beforeNumber?: LineNumber;
|
|
afterNumber?: LineNumber;
|
|
}
|
|
|
|
// TODO(TS): Consolidate this with the CommentThread interface of comment-api.
|
|
// What is being used here is just a local object for collecting all the data
|
|
// that is needed to create a GrCommentThread component, see
|
|
// _createThreadElement().
|
|
interface CommentThread {
|
|
comments: UIComment[];
|
|
// In the context of a diff each thread must have a side!
|
|
commentSide: Side;
|
|
patchNum?: PatchSetNum;
|
|
lineNum?: LineNumber;
|
|
isOnParent?: boolean;
|
|
range?: CommentRange;
|
|
}
|
|
|
|
export interface GrDiffHost {
|
|
$: {
|
|
restAPI: RestApiService & Element;
|
|
jsAPI: JsApiService & Element;
|
|
syntaxLayer: GrSyntaxLayer & Element;
|
|
diff: GrDiff;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Wrapper around gr-diff.
|
|
*
|
|
* Webcomponent fetching diffs and related data from restAPI and passing them
|
|
* to the presentational gr-diff for rendering. <gr-diff-host> is a Gerrit
|
|
* specific component, while <gr-diff> is a re-usable component.
|
|
*/
|
|
@customElement('gr-diff-host')
|
|
export class GrDiffHost extends GestureEventListeners(
|
|
LegacyElementMixin(PolymerElement)
|
|
) {
|
|
static get template() {
|
|
return htmlTemplate;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
|
|
@property({type: Number})
|
|
changeNum?: NumericChangeId;
|
|
|
|
@property({type: Boolean})
|
|
noAutoRender = false;
|
|
|
|
@property({type: Object})
|
|
patchRange?: PatchRange;
|
|
|
|
@property({type: Object})
|
|
file?: PatchSetFile;
|
|
|
|
@property({type: String})
|
|
path?: string;
|
|
|
|
@property({type: Object})
|
|
prefs?: DiffPreferencesInfo;
|
|
|
|
@property({type: String})
|
|
projectName?: RepoName;
|
|
|
|
@property({type: Boolean})
|
|
displayLine = false;
|
|
|
|
@property({
|
|
type: Boolean,
|
|
computed: '_computeIsImageDiff(diff)',
|
|
notify: true,
|
|
})
|
|
isImageDiff?: boolean;
|
|
|
|
@property({type: Object})
|
|
commitRange?: CommitRange;
|
|
|
|
@property({type: Object, notify: true})
|
|
filesWeblinks: FilesWebLinks | {} = {};
|
|
|
|
@property({type: Boolean, reflectToAttribute: true})
|
|
hidden = false;
|
|
|
|
@property({type: Boolean})
|
|
noRenderOnPrefsChange = false;
|
|
|
|
@property({type: Object, observer: '_commentsChanged'})
|
|
comments?: TwoSidesComments;
|
|
|
|
@property({type: Boolean})
|
|
lineWrapping = false;
|
|
|
|
@property({type: String})
|
|
viewMode = DiffViewMode.SIDE_BY_SIDE;
|
|
|
|
@property({type: Object})
|
|
lineOfInterest?: LineOfInterest;
|
|
|
|
@property({type: Boolean})
|
|
showLoadFailure?: boolean;
|
|
|
|
@property({
|
|
type: Boolean,
|
|
notify: true,
|
|
computed: '_computeIsBlameLoaded(_blame)',
|
|
})
|
|
isBlameLoaded?: boolean;
|
|
|
|
@property({type: Boolean})
|
|
_loggedIn = false;
|
|
|
|
@property({type: String})
|
|
_errorMessage: string | null = null;
|
|
|
|
@property({type: Object})
|
|
_baseImage: Base64ImageFile | null = null;
|
|
|
|
@property({type: Object})
|
|
_revisionImage: Base64ImageFile | null = null;
|
|
|
|
@property({type: Object, notify: true})
|
|
diff?: DiffInfo;
|
|
|
|
@property({type: Object})
|
|
_fetchDiffPromise: Promise<DiffInfo> | null = null;
|
|
|
|
@property({type: Object})
|
|
_blame: BlameInfo[] | null = null;
|
|
|
|
@property({type: Array})
|
|
_coverageRanges: CoverageRange[] = [];
|
|
|
|
@property({type: String})
|
|
_loadedWhitespaceLevel?: IgnoreWhitespaceType;
|
|
|
|
@property({type: Number, computed: '_computeParentIndex(patchRange.*)'})
|
|
_parentIndex: number | null = null;
|
|
|
|
@property({
|
|
type: Boolean,
|
|
computed: '_isSyntaxHighlightingEnabled(prefs.*, diff)',
|
|
})
|
|
_syntaxHighlightingEnabled?: boolean;
|
|
|
|
@property({type: Array})
|
|
_layers: DiffLayer[] = [];
|
|
|
|
private readonly reporting = appContext.reportingService;
|
|
|
|
/** @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;
|
|
});
|
|
}
|
|
|
|
/** @override */
|
|
detached() {
|
|
super.detached();
|
|
this.clear();
|
|
}
|
|
|
|
/**
|
|
* @param shouldReportMetric indicate a new Diff Page. This is a
|
|
* signal to report metrics event that started on location change.
|
|
* @return
|
|
*/
|
|
async reload(shouldReportMetric?: boolean) {
|
|
this.clear();
|
|
if (!this.path) throw new Error('Missing required "path" property.');
|
|
if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
|
|
this.diff = undefined;
|
|
this._errorMessage = null;
|
|
const whitespaceLevel = this._getIgnoreWhitespace();
|
|
|
|
this._layers = this._getLayers(this.path, this.changeNum);
|
|
|
|
if (shouldReportMetric) {
|
|
// We listen on render viewport only on DiffPage (on paramsChanged)
|
|
this._listenToViewportRender();
|
|
}
|
|
|
|
this._coverageRanges = [];
|
|
this._getCoverageData();
|
|
|
|
try {
|
|
const diff = await this._getDiff();
|
|
this._loadedWhitespaceLevel = whitespaceLevel;
|
|
this._reportDiff(diff);
|
|
|
|
await this._loadDiffAssets(diff);
|
|
|
|
// Not waiting for coverage ranges intentionally as
|
|
// plugin loading should not block the content rendering
|
|
|
|
this.filesWeblinks = this._getFilesWeblinks(diff);
|
|
this.diff = diff;
|
|
const event = await this._onRenderOnce();
|
|
if (shouldReportMetric) {
|
|
// We report diffViewContentDisplayed only on reload caused
|
|
// by params changed - expected only on Diff Page.
|
|
this.reporting.diffViewContentDisplayed();
|
|
}
|
|
const needsSyntaxHighlighting = !!event.detail?.contentRendered;
|
|
if (needsSyntaxHighlighting) {
|
|
this.reporting.time(TimingLabel.SYNTAX);
|
|
try {
|
|
await this.$.syntaxLayer.process();
|
|
} finally {
|
|
this.reporting.timeEnd(TimingLabel.SYNTAX);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof Response) {
|
|
this._handleGetDiffError(e);
|
|
} else {
|
|
console.warn('Error encountered loading diff:', e);
|
|
}
|
|
} finally {
|
|
this.reporting.timeEnd(TimingLabel.TOTAL);
|
|
}
|
|
}
|
|
|
|
private _getLayers(path: string, changeNum: NumericChangeId): DiffLayer[] {
|
|
// Get layers from plugins (if any).
|
|
return [this.$.syntaxLayer, ...this.$.jsAPI.getDiffLayers(path, changeNum)];
|
|
}
|
|
|
|
private _onRenderOnce(): Promise<CustomEvent> {
|
|
return new Promise<CustomEvent>(resolve => {
|
|
const callback = (event: CustomEvent) => {
|
|
this.removeEventListener('render', callback);
|
|
resolve(event);
|
|
};
|
|
this.addEventListener('render', callback);
|
|
});
|
|
}
|
|
|
|
clear() {
|
|
if (this.path) this.$.jsAPI.disposeDiffLayers(this.path);
|
|
this._layers = [];
|
|
}
|
|
|
|
_getCoverageData() {
|
|
if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
|
|
if (!this.path) throw new Error('Missing required "path" prop.');
|
|
if (!this.patchRange) throw new Error('Missing required "patchRange".');
|
|
const changeNum = this.changeNum;
|
|
const path = this.path;
|
|
// Coverage providers do not provide data for EDIT and PARENT patch sets.
|
|
const basePatchNum = isNumber(this.patchRange.basePatchNum)
|
|
? this.patchRange.basePatchNum
|
|
: undefined;
|
|
const patchNum = isNumber(this.patchRange.patchNum)
|
|
? this.patchRange.patchNum
|
|
: undefined;
|
|
this.$.jsAPI
|
|
.getCoverageAnnotationApi()
|
|
.then(coverageAnnotationApi => {
|
|
if (!coverageAnnotationApi) return;
|
|
const provider = coverageAnnotationApi.getCoverageProvider();
|
|
if (!provider) return;
|
|
return provider(changeNum, path, basePatchNum, patchNum).then(
|
|
coverageRanges => {
|
|
if (!this.patchRange) throw new Error('Missing "patchRange".');
|
|
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: DiffInfo) {
|
|
if (!this.projectName || !this.commitRange || !this.path) 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();
|
|
this.$.syntaxLayer.cancel();
|
|
}
|
|
|
|
getCursorStops() {
|
|
return this.$.diff.getCursorStops();
|
|
}
|
|
|
|
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.
|
|
*/
|
|
loadBlame(): Promise<BlameInfo[]> {
|
|
if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
|
|
if (!this.patchRange) throw new Error('Missing required "patchRange".');
|
|
if (!this.path) throw new Error('Missing required "path" property.');
|
|
return this.$.restAPI
|
|
.getBlame(this.changeNum, this.patchRange.patchNum, this.path, true)
|
|
.then(blame => {
|
|
if (!blame || !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;
|
|
return blame;
|
|
});
|
|
}
|
|
|
|
clearBlame() {
|
|
this._blame = null;
|
|
}
|
|
|
|
getThreadEls(): GrCommentThread[] {
|
|
return Array.from(this.$.diff.querySelectorAll('.comment-thread'));
|
|
}
|
|
|
|
addDraftAtLine(el: Element) {
|
|
this.$.diff.addDraftAtLine(el);
|
|
}
|
|
|
|
clearDiffContent() {
|
|
this.$.diff.clearDiffContent();
|
|
}
|
|
|
|
expandAllContext() {
|
|
this.$.diff.expandAllContext();
|
|
}
|
|
|
|
_getLoggedIn() {
|
|
return this.$.restAPI.getLoggedIn();
|
|
}
|
|
|
|
_canReload() {
|
|
return (
|
|
!!this.changeNum && !!this.patchRange && !!this.path && !this.noAutoRender
|
|
);
|
|
}
|
|
|
|
// TODO(milutin): Use rest-api with fetchCacheURL instead of this.
|
|
prefetchDiff() {
|
|
if (
|
|
!!this.changeNum &&
|
|
!!this.patchRange &&
|
|
!!this.path &&
|
|
this._fetchDiffPromise === null
|
|
) {
|
|
this._fetchDiffPromise = this._getDiff();
|
|
}
|
|
}
|
|
|
|
_getDiff(): Promise<DiffInfo> {
|
|
if (this._fetchDiffPromise !== null) {
|
|
const fetchDiffPromise = this._fetchDiffPromise;
|
|
this._fetchDiffPromise = null;
|
|
return fetchDiffPromise;
|
|
}
|
|
// 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) => {
|
|
if (!this.changeNum) throw new Error('Missing required "changeNum".');
|
|
if (!this.patchRange) throw new Error('Missing required "patchRange".');
|
|
if (!this.path) throw new Error('Missing required "path" property.');
|
|
this.$.restAPI
|
|
.getDiff(
|
|
this.changeNum,
|
|
this.patchRange.basePatchNum,
|
|
this.patchRange.patchNum,
|
|
this.path,
|
|
this._getIgnoreWhitespace(),
|
|
reject
|
|
)
|
|
.then(resolve);
|
|
});
|
|
}
|
|
|
|
_handleGetDiffError(response: 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?: DiffInfo) {
|
|
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) throw new Error('Missing required "patchRange".');
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
_loadDiffAssets(diff?: DiffInfo) {
|
|
if (isImageDiff(diff)) {
|
|
// diff! is justified, because isImageDiff() returns false otherwise
|
|
return this._getImages(diff!).then(images => {
|
|
this._baseImage = images.baseImage;
|
|
this._revisionImage = images.revisionImage;
|
|
});
|
|
} else {
|
|
this._baseImage = null;
|
|
this._revisionImage = null;
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
|
|
_computeIsImageDiff(diff?: DiffInfo) {
|
|
return isImageDiff(diff);
|
|
}
|
|
|
|
_commentsChanged(newComments: TwoSidesComments) {
|
|
const allComments = [];
|
|
for (const side of [Side.LEFT, 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);
|
|
}
|
|
}
|
|
|
|
_createThreads(comments: UIComment[]): CommentThread[] {
|
|
const sortedComments = 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.
|
|
if (!comment.__commentSide) throw new Error('Missing "__commentSide".');
|
|
const newThread: CommentThread = {
|
|
comments: [comment],
|
|
commentSide: comment.__commentSide,
|
|
patchNum: comment.patch_set,
|
|
lineNum: comment.line,
|
|
isOnParent: comment.side === 'PARENT',
|
|
};
|
|
if (comment.range) {
|
|
newThread.range = {...comment.range};
|
|
}
|
|
threads.push(newThread);
|
|
}
|
|
return threads;
|
|
}
|
|
|
|
_computeIsBlameLoaded(blame: BlameInfo[] | null) {
|
|
return !!blame;
|
|
}
|
|
|
|
_getImages(diff: DiffInfo) {
|
|
if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
|
|
if (!this.patchRange) throw new Error('Missing required "patchRange".');
|
|
return this.$.restAPI.getImagesForDiff(
|
|
this.changeNum,
|
|
diff,
|
|
this.patchRange
|
|
);
|
|
}
|
|
|
|
_handleCreateComment(e: CustomEvent) {
|
|
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.
|
|
*/
|
|
_getOrCreateThread(
|
|
patchNum: PatchSetNum,
|
|
lineNum: LineNumber | undefined,
|
|
commentSide: Side,
|
|
range?: CommentRange,
|
|
isOnParent?: boolean
|
|
): GrCommentThread {
|
|
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: Element) {
|
|
this.$.diff.appendChild(threadEl);
|
|
}
|
|
|
|
_clearThreads() {
|
|
for (const threadEl of this.getThreadEls()) {
|
|
const parent = threadEl.parentNode;
|
|
if (parent) parent.removeChild(threadEl);
|
|
}
|
|
}
|
|
|
|
_createThreadElement(thread: CommentThread) {
|
|
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;
|
|
// Use path before renmaing when comment added on the left when comparing
|
|
// two patch sets (not against base)
|
|
if (
|
|
this.file &&
|
|
this.file.basePath &&
|
|
thread.commentSide === Side.LEFT &&
|
|
!thread.isOnParent
|
|
) {
|
|
threadEl.path = this.file.basePath;
|
|
} else {
|
|
threadEl.path = this.path;
|
|
}
|
|
threadEl.changeNum = this.changeNum;
|
|
threadEl.patchNum = thread.patchNum;
|
|
threadEl.showPatchset = false;
|
|
// GrCommentThread does not understand 'FILE', but requires undefined.
|
|
threadEl.lineNum = thread.lineNum !== 'FILE' ? thread.lineNum : undefined;
|
|
threadEl.projectName = this.projectName;
|
|
threadEl.range = thread.range;
|
|
const threadDiscardListener = (e: Event) => {
|
|
const threadEl = e.currentTarget as Element;
|
|
const parent = threadEl.parentNode;
|
|
if (parent) parent.removeChild(threadEl);
|
|
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.
|
|
*/
|
|
_getThreadEl(
|
|
lineNum: LineNumber | undefined,
|
|
commentSide: Side,
|
|
range?: CommentRange
|
|
): GrCommentThread | null {
|
|
let line: LineInfo;
|
|
if (commentSide === Side.LEFT) {
|
|
line = {beforeNumber: lineNum};
|
|
} else if (commentSide === Side.RIGHT) {
|
|
line = {afterNumber: lineNum};
|
|
} else {
|
|
throw new Error(`Unknown side: ${commentSide}`);
|
|
}
|
|
function matchesRange(threadEl: GrCommentThread) {
|
|
const rangeAtt = threadEl.getAttribute('range');
|
|
const threadRange = rangeAtt
|
|
? (JSON.parse(rangeAtt) as CommentRange)
|
|
: undefined;
|
|
return rangesEqual(threadRange, range);
|
|
}
|
|
|
|
const filteredThreadEls = this._filterThreadElsForLocation(
|
|
this.getThreadEls(),
|
|
line,
|
|
commentSide
|
|
).filter(matchesRange);
|
|
return filteredThreadEls.length ? filteredThreadEls[0] : null;
|
|
}
|
|
|
|
_filterThreadElsForLocation(
|
|
threadEls: GrCommentThread[],
|
|
lineInfo: LineInfo,
|
|
side: Side
|
|
) {
|
|
function matchesLeftLine(threadEl: GrCommentThread) {
|
|
return (
|
|
threadEl.getAttribute('comment-side') === Side.LEFT &&
|
|
threadEl.getAttribute('line-num') === String(lineInfo.beforeNumber)
|
|
);
|
|
}
|
|
function matchesRightLine(threadEl: GrCommentThread) {
|
|
return (
|
|
threadEl.getAttribute('comment-side') === Side.RIGHT &&
|
|
threadEl.getAttribute('line-num') === String(lineInfo.afterNumber)
|
|
);
|
|
}
|
|
function matchesFileComment(threadEl: GrCommentThread) {
|
|
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: ((thread: GrCommentThread) => boolean)[] = [];
|
|
if (side !== Side.RIGHT) {
|
|
matchers.push(matchesLeftLine);
|
|
}
|
|
if (side !== Side.LEFT) {
|
|
matchers.push(matchesRightLine);
|
|
}
|
|
if (lineInfo.afterNumber === 'FILE' || lineInfo.beforeNumber === 'FILE') {
|
|
matchers.push(matchesFileComment);
|
|
}
|
|
return threadEls.filter(threadEl =>
|
|
matchers.some(matcher => matcher(threadEl))
|
|
);
|
|
}
|
|
|
|
_getIgnoreWhitespace(): IgnoreWhitespaceType {
|
|
if (!this.prefs || !this.prefs.ignore_whitespace) {
|
|
return IgnoreWhitespaceType.IGNORE_NONE;
|
|
}
|
|
return this.prefs.ignore_whitespace;
|
|
}
|
|
|
|
@observe(
|
|
'prefs.ignore_whitespace',
|
|
'_loadedWhitespaceLevel',
|
|
'noRenderOnPrefsChange'
|
|
)
|
|
_whitespaceChanged(
|
|
preferredWhitespaceLevel?: IgnoreWhitespaceType,
|
|
loadedWhitespaceLevel?: IgnoreWhitespaceType,
|
|
noRenderOnPrefsChange?: boolean
|
|
) {
|
|
if (preferredWhitespaceLevel === undefined) return;
|
|
if (loadedWhitespaceLevel === undefined) return;
|
|
if (noRenderOnPrefsChange === undefined) return;
|
|
|
|
this._fetchDiffPromise = null;
|
|
if (
|
|
preferredWhitespaceLevel !== loadedWhitespaceLevel &&
|
|
!noRenderOnPrefsChange
|
|
) {
|
|
this.reload();
|
|
}
|
|
}
|
|
|
|
@observe('noRenderOnPrefsChange', 'prefs.*')
|
|
_syntaxHighlightingChanged(
|
|
noRenderOnPrefsChange?: boolean,
|
|
prefsChangeRecord?: PolymerDeepPropertyChange<
|
|
DiffPreferencesInfo,
|
|
DiffPreferencesInfo
|
|
>
|
|
) {
|
|
if (noRenderOnPrefsChange === undefined) return;
|
|
if (prefsChangeRecord === undefined) return;
|
|
if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') return;
|
|
|
|
if (!noRenderOnPrefsChange) this.reload();
|
|
}
|
|
|
|
_computeParentIndex(
|
|
patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
|
|
) {
|
|
if (!patchRangeRecord.base) return null;
|
|
return isMergeParent(patchRangeRecord.base.basePatchNum)
|
|
? getParentIndex(patchRangeRecord.base.basePatchNum)
|
|
: null;
|
|
}
|
|
|
|
_handleCommentSave(e: CustomEvent) {
|
|
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: CustomEvent) {
|
|
const comment = e.detail.comment;
|
|
this._removeComment(comment);
|
|
this._handleCommentSaveOrDiscard();
|
|
}
|
|
|
|
_handleCommentUpdate(e: CustomEvent) {
|
|
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: UIComment) {
|
|
const side = comment.__commentSide;
|
|
if (!side) throw new Error('Missing required "side" in comment.');
|
|
this._removeCommentFromSide(comment, side);
|
|
}
|
|
|
|
_removeCommentFromSide(comment: Comment, side: Side) {
|
|
let idx = this._findCommentIndex(comment, side);
|
|
if (idx === -1) {
|
|
idx = this._findDraftIndex(comment, side);
|
|
}
|
|
if (idx !== -1) {
|
|
this.splice('comments.' + side, idx, 1);
|
|
}
|
|
}
|
|
|
|
_findCommentIndex(comment: Comment, side: Side) {
|
|
if (!comment.id || !this.comments || !this.comments[side]) {
|
|
return -1;
|
|
}
|
|
return this.comments[side].findIndex(item => item.id === comment.id);
|
|
}
|
|
|
|
_findDraftIndex(comment: Comment, side: Side) {
|
|
if (
|
|
!isDraft(comment) ||
|
|
!comment.__draftID ||
|
|
!this.comments ||
|
|
!this.comments[side]
|
|
) {
|
|
return -1;
|
|
}
|
|
return this.comments[side].findIndex(
|
|
item => isDraft(item) && item.__draftID === comment.__draftID
|
|
);
|
|
}
|
|
|
|
_isSyntaxHighlightingEnabled(
|
|
preferenceChangeRecord?: PolymerDeepPropertyChange<
|
|
DiffPreferencesInfo,
|
|
DiffPreferencesInfo
|
|
>,
|
|
diff?: DiffInfo
|
|
) {
|
|
if (
|
|
!preferenceChangeRecord ||
|
|
!preferenceChangeRecord.base ||
|
|
!preferenceChangeRecord.base.syntax_highlighting ||
|
|
!diff
|
|
) {
|
|
return false;
|
|
}
|
|
return (
|
|
!this._anyLineTooLong(diff) &&
|
|
this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return whether any of the lines in diff are longer
|
|
* than SYNTAX_MAX_LINE_LENGTH.
|
|
*/
|
|
_anyLineTooLong(diff?: DiffInfo) {
|
|
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: DiffLayerListener = 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: CustomEvent) {
|
|
this.reporting.reportInteraction('normalize-range', {
|
|
side: event.detail.side,
|
|
lineNum: event.detail.lineNum,
|
|
});
|
|
}
|
|
|
|
_handleDiffContextExpanded(event: CustomEvent) {
|
|
this.reporting.reportInteraction('diff-context-expanded', {
|
|
numLines: event.detail.numLines,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find the last chunk for the given side.
|
|
*
|
|
* @param leftSide true if checking the base of the diff,
|
|
* false if testing the revision.
|
|
* @return returns the chunk object or null if there was
|
|
* no chunk for that side.
|
|
*/
|
|
_lastChunkForSide(diff: DiffInfo | undefined, leftSide: boolean) {
|
|
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 leftSide true if checking the base of the diff,
|
|
* false if testing the revision.
|
|
* @return 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: DiffInfo | undefined, leftSide: boolean) {
|
|
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;
|
|
}
|
|
if (!lines) return null;
|
|
return lines[lines.length - 1] === '';
|
|
}
|
|
|
|
_showNewlineWarningLeft(diff?: DiffInfo) {
|
|
return this._hasTrailingNewlines(diff, true) === false;
|
|
}
|
|
|
|
_showNewlineWarningRight(diff?: DiffInfo) {
|
|
return this._hasTrailingNewlines(diff, false) === false;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'gr-diff-host': GrDiffHost;
|
|
}
|
|
}
|
|
|
|
// TODO(TS): Be more specific than CustomEvent, which has detail:any.
|
|
declare global {
|
|
interface HTMLElementEventMap {
|
|
render: CustomEvent;
|
|
'normalize-range': CustomEvent;
|
|
'diff-context-expanded': CustomEvent;
|
|
'create-comment': CustomEvent;
|
|
'comment-discard': CustomEvent;
|
|
'comment-update': CustomEvent;
|
|
'comment-save': CustomEvent;
|
|
'root-id-changed': CustomEvent;
|
|
}
|
|
}
|