blob: 7efd2f89466636cc130c0a21ee801f356ab62b65 [file] [log] [blame]
/**
* @license
* Copyright (C) 2015 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 '../../../styles/shared-styles';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-icons/gr-icons';
import '../gr-diff-builder/gr-diff-builder-element';
import '../gr-diff-highlight/gr-diff-highlight';
import '../gr-diff-selection/gr-diff-selection';
import '../gr-syntax-themes/gr-syntax-theme';
import '../gr-ranged-comment-themes/gr-ranged-comment-theme';
import '../gr-ranged-comment-hint/gr-ranged-comment-hint';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
import {htmlTemplate} from './gr-diff_html';
import {LineNumber} from './gr-diff-line';
import {
getLine,
getLineElByChild,
getLineNumber,
getRange,
getSide,
GrDiffThreadElement,
isLongCommentRange,
isThreadEl,
rangesEqual,
} from './gr-diff-utils';
import {getHiddenScroll} from '../../../scripts/hiddenscroll';
import {customElement, observe, property} from '@polymer/decorators';
import {
BlameInfo,
CommentRange,
ImageInfo,
NumericChangeId,
} from '../../../types/common';
import {
DiffInfo,
DiffPreferencesInfo,
DiffPreferencesInfoKey,
} from '../../../types/diff';
import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
import {
GrDiffBuilderElement,
getLineNumberCellWidth,
} from '../gr-diff-builder/gr-diff-builder-element';
import {
CoverageRange,
DiffLayer,
PolymerDomWrapper,
} from '../../../types/types';
import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
import {
createDefaultDiffPrefs,
DiffViewMode,
Side,
} from '../../../constants/constants';
import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
import {fireAlert, fireEvent} from '../../../utils/event-util';
import {MovedLinkClickedEvent} from '../../../types/events';
import {getContentEditableRange} from '../../../utils/safari-selection-util';
import {AbortStop} from '../../../api/core';
import {
CreateCommentEventDetail as CreateCommentEventDetailApi,
RenderPreferences,
GrDiff as GrDiffApi,
} from '../../../api/diff';
import {isSafari, toggleClass} from '../../../utils/dom-util';
import {assertIsDefined} from '../../../utils/common-util';
import {debounce, DelayedTask} from '../../../utils/async-util';
import {
DiffContextExpandedEventDetail,
getResponsiveMode,
isResponsive,
} from '../gr-diff-builder/gr-diff-builder';
const NO_NEWLINE_BASE = 'No newline at end of base file.';
const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
const LARGE_DIFF_THRESHOLD_LINES = 10000;
const FULL_CONTEXT = -1;
const COMMIT_MSG_PATH = '/COMMIT_MSG';
/**
* 72 is the unofficial length standard for git commit messages.
* Derived from the fact that git log/show appends 4 ws in the beginning of
* each line when displaying commit messages. To center the commit message
* in an 80 char terminal a 4 ws border is added to the rightmost side:
* 4 + 72 + 4
*/
const COMMIT_MSG_LINE_LENGTH = 72;
export interface LineOfInterest {
number: number;
leftSide: boolean;
}
export interface GrDiff {
$: {
highlights: GrDiffHighlight;
diffBuilder: GrDiffBuilderElement;
diffTable: HTMLTableElement;
};
}
export interface CreateCommentEventDetail extends CreateCommentEventDetailApi {
path: string;
}
@customElement('gr-diff')
export class GrDiff extends PolymerElement implements GrDiffApi {
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 created
*
* @event create-comment
*/
/**
* Fired when rendering, including syntax highlighting, is done. Also fired
* when no rendering can be done because required preferences are not set.
*
* @event render
*/
/**
* Fired for interaction reporting when a diff context is expanded.
* Contains an event.detail with numLines about the number of lines that
* were expanded.
*
* @event diff-context-expanded
*/
@property({type: String})
changeNum?: NumericChangeId;
@property({type: Boolean})
noAutoRender = false;
@property({type: String, observer: '_pathObserver'})
path?: string;
@property({type: Object, observer: '_prefsObserver'})
prefs?: DiffPreferencesInfo;
@property({type: Object, observer: '_renderPrefsChanged'})
renderPrefs?: RenderPreferences;
@property({type: Boolean})
displayLine = false;
@property({type: Boolean})
isImageDiff?: boolean;
@property({type: Boolean, reflectToAttribute: true})
override hidden = false;
@property({type: Boolean})
noRenderOnPrefsChange?: boolean;
@property({type: Array})
_commentRanges: CommentRangeLayer[] = [];
// explicitly highlight a range if it is not associated with any comment
@property({type: Object})
highlightRange?: CommentRange;
@property({type: Array})
coverageRanges: CoverageRange[] = [];
@property({type: Boolean, observer: '_lineWrappingObserver'})
lineWrapping = false;
@property({type: String, observer: '_viewModeObserver'})
viewMode = DiffViewMode.SIDE_BY_SIDE;
@property({type: Object})
lineOfInterest?: LineOfInterest;
/**
* True when diff is changed, until the content is done rendering.
*
* This is readOnly, meaning one can listen for the loading-changed event, but
* not write to it from the outside. Code in this class should use the
* "private" _setLoading method.
*/
@property({type: Boolean, notify: true, readOnly: true})
loading!: boolean;
// Polymer generated when setting readOnly above.
_setLoading!: (loading: boolean) => void;
@property({type: Boolean})
loggedIn = false;
@property({type: Object, observer: '_diffChanged'})
diff?: DiffInfo;
@property({type: Array, computed: '_computeDiffHeaderItems(diff.*)'})
_diffHeaderItems: string[] = [];
@property({type: String})
_diffTableClass = '';
@property({type: Object})
baseImage?: ImageInfo;
@property({type: Object})
revisionImage?: ImageInfo;
/**
* In order to allow multi-select in Safari browsers, a workaround is required
* to trigger 'beforeinput' events to get a list of static ranges. This is
* obtained by making the content of the diff table "contentEditable".
*/
@property({type: Boolean})
override isContentEditable = isSafari();
/**
* 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.
*/
@property({type: Number})
_safetyBypass: number | null = null;
@property({type: Boolean})
_showWarning?: boolean;
@property({type: String})
errorMessage: string | null = null;
@property({type: Object, observer: '_blameChanged'})
blame: BlameInfo[] | null = null;
@property({type: Number})
parentIndex?: number;
@property({type: Boolean})
showNewlineWarningLeft = false;
@property({type: Boolean})
showNewlineWarningRight = false;
@property({type: String, observer: '_useNewImageDiffUiObserver'})
useNewImageDiffUi = false;
@property({
type: String,
computed:
'_computeNewlineWarning(' +
'showNewlineWarningLeft, showNewlineWarningRight)',
})
_newlineWarning: string | null = null;
@property({type: Number})
_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.
*/
@property({type: Object})
_incrementalNodeObserver?: FlattenedNodesObserver;
/**
* Observes comment nodes added or removed at any point.
* Can be used to unregister upon detachment.
*/
@property({type: Object})
_nodeObserver?: FlattenedNodesObserver;
@property({type: Array})
layers?: DiffLayer[];
@property({type: Boolean})
isAttached = false;
private renderDiffTableTask?: DelayedTask;
constructor() {
super();
this._setLoading(true);
this.addEventListener('create-range-comment', (e: Event) =>
this._handleCreateRangeComment(e as CustomEvent)
);
this.addEventListener('render-content', () => this._handleRenderContent());
this.addEventListener('moved-link-clicked', e => this._movedLinkClicked(e));
}
override connectedCallback() {
super.connectedCallback();
this._observeNodes();
this.isAttached = true;
}
override disconnectedCallback() {
this.isAttached = false;
this.renderDiffTableTask?.cancel();
this._unobserveIncrementalNodes();
this._unobserveNodes();
super.disconnectedCallback();
}
getLineNumEls(side: Side): HTMLElement[] {
return Array.from(
this.root?.querySelectorAll<HTMLElement>(`.lineNum.${side}`) ?? []
);
}
showNoChangeMessage(
loading?: boolean,
prefs?: DiffPreferencesInfo,
diffLength?: number,
diff?: DiffInfo
) {
return (
!loading &&
diff &&
!diff.binary &&
prefs &&
prefs.ignore_whitespace !== 'IGNORE_NONE' &&
diffLength === 0
);
}
@observe('loggedIn', 'isAttached')
_enableSelectionObserver(loggedIn: boolean, isAttached: boolean) {
if (loggedIn && isAttached) {
document.addEventListener('selectionchange', this.handleSelectionChange);
document.addEventListener('mouseup', this.handleMouseUp);
} else {
document.removeEventListener(
'selectionchange',
this.handleSelectionChange
);
document.removeEventListener('mouseup', this.handleMouseUp);
}
}
private readonly handleSelectionChange = () => {
// Because of shadow DOM selections, we handle the selectionchange here,
// and pass the shadow DOM selection into gr-diff-highlight, where the
// corresponding range is determined and normalized.
const selection = this._getShadowOrDocumentSelection();
this.$.highlights.handleSelectionChange(selection, false);
};
private readonly handleMouseUp = () => {
// To handle double-click outside of text creating comments, we check on
// mouse-up if there's a selection that just covers a line change. We
// can't do that on selection change since the user may still be dragging.
const selection = this._getShadowOrDocumentSelection();
this.$.highlights.handleSelectionChange(selection, true);
};
/** Gets the current selection, preferring the shadow DOM selection. */
_getShadowOrDocumentSelection() {
// When using native shadow DOM, the selection returned by
// document.getSelection() cannot reference the actual DOM elements making
// up the diff in Safari because they are in the shadow DOM of the gr-diff
// element. This takes the shadow DOM selection if one exists.
return this.root instanceof ShadowRoot && this.root.getSelection
? this.root.getSelection()
: isSafari()
? getContentEditableRange()
: document.getSelection();
}
_observeNodes() {
this._nodeObserver = (dom(this) as PolymerDomWrapper).observeNodes(info => {
const addedThreadEls = info.addedNodes.filter(isThreadEl);
const removedThreadEls = info.removedNodes.filter(isThreadEl);
this._updateRanges(addedThreadEls, removedThreadEls);
addedThreadEls.forEach(threadEl =>
this._redispatchHoverEvents(threadEl, threadEl)
);
});
}
// TODO(brohlfs): Rewrite gr-diff to be agnostic of GrCommentThread, because
// other users of gr-diff may use different comment widgets.
_updateRanges(
addedThreadEls: GrDiffThreadElement[],
removedThreadEls: GrDiffThreadElement[]
) {
function commentRangeFromThreadEl(
threadEl: GrDiffThreadElement
): CommentRangeLayer | undefined {
const side = getSide(threadEl);
if (!side) return undefined;
const range = getRange(threadEl);
if (!range) return undefined;
return {side, range, hovering: false, rootId: threadEl.rootId};
}
// TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
const addedCommentRanges = addedThreadEls
.map(commentRangeFromThreadEl)
.filter(range => !!range) as CommentRangeLayer[];
const removedCommentRanges = removedThreadEls
.map(commentRangeFromThreadEl)
.filter(range => !!range) as CommentRangeLayer[];
for (const removedCommentRange of removedCommentRanges) {
const i = this._commentRanges.findIndex(
cr =>
cr.side === removedCommentRange.side &&
rangesEqual(cr.range, removedCommentRange.range)
);
this.splice('_commentRanges', i, 1);
}
if (addedCommentRanges && addedCommentRanges.length) {
this.push('_commentRanges', ...addedCommentRanges);
}
if (this.highlightRange) {
this.push('_commentRanges', {
side: Side.RIGHT,
range: this.highlightRange,
hovering: true,
rootId: '',
});
}
}
/**
* The key locations based on the comments and line of interests,
* where lines should not be collapsed.
*
*/
_computeKeyLocations() {
const keyLocations: KeyLocations = {left: {}, right: {}};
if (this.lineOfInterest) {
const side = this.lineOfInterest.leftSide ? Side.LEFT : Side.RIGHT;
keyLocations[side][this.lineOfInterest.number] = true;
}
const threadEls = (dom(this) as PolymerDomWrapper)
.getEffectiveChildNodes()
.filter(isThreadEl);
for (const threadEl of threadEls) {
const side = getSide(threadEl);
if (!side) continue;
const lineNum = getLine(threadEl);
const commentRange = getRange(threadEl);
keyLocations[side][lineNum] = true;
// Add start_line as well if exists,
// the being and end of the range should not be collapsed.
if (commentRange?.start_line) {
keyLocations[side][commentRange.start_line] = true;
}
}
return keyLocations;
}
// Dispatch events that are handled by the gr-diff-highlight.
_redispatchHoverEvents(hoverEl: HTMLElement, threadEl: GrDiffThreadElement) {
hoverEl.addEventListener('mouseenter', () => {
fireEvent(threadEl, 'comment-thread-mouseenter');
});
hoverEl.addEventListener('mouseleave', () => {
fireEvent(threadEl, 'comment-thread-mouseleave');
});
}
/** Cancel any remaining diff builder rendering work. */
cancel() {
this.$.diffBuilder.cancel();
this.renderDiffTableTask?.cancel();
}
getCursorStops(): Array<HTMLElement | AbortStop> {
if (this.hidden && this.noAutoRender) return [];
if (this.loading) {
return [new AbortStop()];
}
return Array.from(
this.root?.querySelectorAll<HTMLElement>(
':not(.contextControl) > .diff-row'
) || []
).filter(tr => tr.querySelector('button'));
}
isRangeSelected() {
return !!this.$.highlights.selectedRange;
}
toggleLeftDiff() {
toggleClass(this, 'no-left');
}
_blameChanged(newValue?: BlameInfo[] | null) {
if (newValue === undefined) return;
this.$.diffBuilder.setBlame(newValue);
if (newValue) {
this.classList.add('showBlame');
} else {
this.classList.remove('showBlame');
}
}
_computeContainerClass(
loggedIn: boolean,
viewMode: DiffViewMode,
displayLine: boolean
) {
const classes = ['diffContainer'];
if (viewMode === DiffViewMode.UNIFIED) classes.push('unified');
if (viewMode === DiffViewMode.SIDE_BY_SIDE) classes.push('sideBySide');
if (getHiddenScroll()) classes.push('hiddenscroll');
if (loggedIn) classes.push('canComment');
if (displayLine) classes.push('displayLine');
return classes.join(' ');
}
_handleDiffContextExpanded(e: CustomEvent<DiffContextExpandedEventDetail>) {
// Don't stop propagation. The host may listen for reporting or resizing.
this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
}
_handleTap(e: CustomEvent) {
const el = (dom(e) as EventApi).localTarget as Element;
if (
el.getAttribute('data-value') !== 'LOST' &&
(el.classList.contains('lineNum') ||
el.classList.contains('lineNumButton'))
) {
this.addDraftAtLine(el);
} else if (
el.tagName === 'HL' ||
el.classList.contains('content') ||
el.classList.contains('contentText')
) {
const target = getLineElByChild(el);
if (target) {
this._selectLine(target);
}
}
}
_selectLine(el: Element) {
const lineNumber = Number(el.getAttribute('data-value'));
const side = el.classList.contains('left') ? Side.LEFT : Side.RIGHT;
this._dispatchSelectedLine(lineNumber, side);
}
_dispatchSelectedLine(number: LineNumber, side: Side) {
this.dispatchEvent(
new CustomEvent('line-selected', {
detail: {
number,
side,
path: this.path,
},
composed: true,
bubbles: true,
})
);
}
_movedLinkClicked(e: MovedLinkClickedEvent) {
this._dispatchSelectedLine(e.detail.lineNum, e.detail.side);
}
addDraftAtLine(el: Element) {
this._selectLine(el);
const lineNum = getLineNumber(el);
if (lineNum === null) {
fireAlert(this, 'Invalid line number');
return;
}
this._createComment(el, lineNum);
}
createRangeComment() {
if (!this.isRangeSelected()) {
throw Error('Selection is needed for new range comment');
}
const selectedRange = this.$.highlights.selectedRange;
if (!selectedRange) throw Error('selected range not set');
const {side, range} = selectedRange;
this._createCommentForSelection(side, range);
}
_createCommentForSelection(side: Side, range: CommentRange) {
const lineNum = range.end_line;
const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
if (lineEl) {
this._createComment(lineEl, lineNum, side, range);
}
}
_handleCreateRangeComment(e: CustomEvent) {
const range = e.detail.range;
const side = e.detail.side;
this._createCommentForSelection(side, range);
}
_createComment(
lineEl: Element,
lineNum: LineNumber,
side?: Side,
range?: CommentRange
) {
const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
if (!contentEl) throw new Error('content el not found for line el');
side = side ?? this._getCommentSideByLineAndContent(lineEl, contentEl);
assertIsDefined(this.path, 'path');
this.dispatchEvent(
new CustomEvent<CreateCommentEventDetail>('create-comment', {
bubbles: true,
composed: true,
detail: {
path: this.path,
side,
lineNum,
range,
},
})
);
}
_getThreadGroupForLine(contentEl: Element) {
return contentEl.querySelector('.thread-group');
}
/**
* Gets or creates a comment thread group for a specific line and side on a
* diff.
*/
_getOrCreateThreadGroup(contentEl: Element, commentSide: Side) {
// 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;
}
_getCommentSideByLineAndContent(lineEl: Element, contentEl: Element): Side {
return lineEl.classList.contains(Side.LEFT) ||
contentEl.classList.contains('remove')
? Side.LEFT
: Side.RIGHT;
}
_prefsObserver(newPrefs: DiffPreferencesInfo, oldPrefs: DiffPreferencesInfo) {
if (!this._prefsEqual(newPrefs, oldPrefs)) {
this._prefsChanged(newPrefs);
}
}
_prefsEqual(prefs1: DiffPreferencesInfo, prefs2: DiffPreferencesInfo) {
if (prefs1 === prefs2) {
return true;
}
if (!prefs1 || !prefs2) {
return false;
}
// Scan the preference objects one level deep to see if they differ.
const keys1 = Object.keys(prefs1) as DiffPreferencesInfoKey[];
const keys2 = Object.keys(prefs2) as DiffPreferencesInfoKey[];
return (
keys1.length === keys2.length &&
keys1.every(key => prefs1[key] === prefs2[key]) &&
keys2.every(key => prefs1[key] === prefs2[key])
);
}
_pathObserver() {
// Call _prefsChanged(), because line-limit style value depends on path.
this._prefsChanged(this.prefs);
}
_viewModeObserver() {
this._prefsChanged(this.prefs);
}
_cleanup() {
this.cancel();
this.blame = null;
this._safetyBypass = null;
this._showWarning = false;
this.clearDiffContent();
}
_lineWrappingObserver() {
this._prefsChanged(this.prefs);
}
_useNewImageDiffUiObserver() {
this._prefsChanged(this.prefs);
}
_prefsChanged(prefs?: DiffPreferencesInfo) {
if (!prefs) return;
this.blame = null;
this._updatePreferenceStyles(prefs, this.renderPrefs);
if (this.diff && !this.noRenderOnPrefsChange) {
this._debounceRenderDiffTable();
}
}
_updatePreferenceStyles(
prefs: DiffPreferencesInfo,
renderPrefs?: RenderPreferences
) {
const lineLength =
this.path === COMMIT_MSG_PATH
? COMMIT_MSG_LINE_LENGTH
: prefs.line_length;
const sideBySide = this.viewMode === 'SIDE_BY_SIDE';
const stylesToUpdate: {[key: string]: string} = {};
const responsiveMode = getResponsiveMode(prefs, renderPrefs);
const responsive = isResponsive(responsiveMode);
this._diffTableClass = responsive ? 'responsive' : '';
const lineLimit = `${lineLength}ch`;
stylesToUpdate['--line-limit-marker'] =
responsiveMode === 'FULL_RESPONSIVE' ? lineLimit : '-1px';
stylesToUpdate['--content-width'] = responsive ? 'none' : lineLimit;
if (responsiveMode === 'SHRINK_ONLY') {
// Calculating ideal (initial) width for the whole table including
// width of each table column (content and line number columns) and
// border. We also add a 1px correction as some values are calculated
// in 'ch'.
// We might have 1 to 2 columns for content depending if side-by-side
// or unified mode
const contentWidth = `${sideBySide ? 2 : 1} * ${lineLimit}`;
// We always have 2 columns for line number
const lineNumberWidth = `2 * ${getLineNumberCellWidth(prefs)}px`;
// border-right in ".section" css definition (in gr-diff_html.ts)
const sectionRightBorder = '1px';
// As some of these calculations are done using 'ch' we end up
// having <1px difference between ideal and calculated size for each side
// leading to lines using the max columns (e.g. 80) to wrap (decided
// exclusively by the browser).This happens even in monospace fonts.
// Empirically adding 2px as correction to be sure wrapping won't happen in these
// cases so it doesn' block further experimentation with the SHRINK_MODE.
// This was previously set to 1px but due to to a more aggressive
// text wrapping (via word-break: break-all; - check .contextText)
// we need to be even more lenient in some cases.
// If we find another way to avoid this correction we will change it.
const dontWrapCorrection = '2px';
stylesToUpdate[
'--diff-max-width'
] = `calc(${contentWidth} + ${lineNumberWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`;
} else {
stylesToUpdate['--diff-max-width'] = 'none';
}
if (prefs.font_size) {
stylesToUpdate['--font-size'] = `${prefs.font_size}px`;
}
this.updateStyles(stylesToUpdate);
}
_renderPrefsChanged(renderPrefs?: RenderPreferences) {
if (!renderPrefs) return;
if (renderPrefs.hide_left_side) {
this.classList.add('no-left');
}
if (renderPrefs.disable_context_control_buttons) {
this.classList.add('disable-context-control-buttons');
}
if (renderPrefs.hide_line_length_indicator) {
this.classList.add('hide-line-length-indicator');
}
if (this.prefs) {
this._updatePreferenceStyles(this.prefs, renderPrefs);
}
this.$.diffBuilder.updateRenderPrefs(renderPrefs);
}
_diffChanged(newValue?: DiffInfo) {
this._setLoading(true);
this._cleanup();
if (newValue) {
this._diffLength = this.getDiffLength(newValue);
this._debounceRenderDiffTable();
}
}
/**
* When called multiple times from the same microtask, will call
* _renderDiffTable only once, in the next microtask, unless it is cancelled
* before that microtask runs.
*
* This should be used instead of calling _renderDiffTable directly to
* render the diff in response to an input change, because there may be
* multiple inputs changing in the same microtask, but we only want to
* render once.
*/
_debounceRenderDiffTable() {
this.renderDiffTableTask = debounce(this.renderDiffTableTask, () =>
this._renderDiffTable()
);
}
_renderDiffTable() {
if (!this.prefs) {
fireEvent(this, 'render');
return;
}
if (
this.prefs.context === -1 &&
this._diffLength &&
this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
this._safetyBypass === null
) {
this._showWarning = true;
fireEvent(this, 'render');
return;
}
this._showWarning = false;
const keyLocations = this._computeKeyLocations();
const bypassPrefs = this._getBypassPrefs(this.prefs);
this.$.diffBuilder
.render(keyLocations, bypassPrefs, this.renderPrefs)
.then(() => {
this.dispatchEvent(
new CustomEvent('render', {
bubbles: true,
composed: true,
detail: {contentRendered: true},
})
);
});
}
_handleRenderContent() {
this.querySelectorAll('gr-ranged-comment-hint').forEach(element =>
element.remove()
);
this._setLoading(false);
this._unobserveIncrementalNodes();
this._incrementalNodeObserver = (
dom(this) as PolymerDomWrapper
).observeNodes(info => {
const addedThreadEls = info.addedNodes.filter(isThreadEl);
// Removed nodes do not need to be handled because all this code does is
// adding a slot for the added thread elements, and the extra slots do
// not hurt. It's probably a bigger performance cost to remove them than
// to keep them around. Medium term we can even consider to add one slot
// for each line from the start.
let lastEl;
for (const threadEl of addedThreadEls) {
const lineNum = getLine(threadEl);
const commentSide = getSide(threadEl);
const range = getRange(threadEl);
if (!commentSide) continue;
const lineEl = this.$.diffBuilder.getLineElByNumber(
lineNum,
commentSide
);
// When the line the comment refers to does not exist, log an error
// but don't crash. This can happen e.g. if the API does not fully
// validate e.g. (robot) comments
if (!lineEl) {
console.error(
'thread attached to line ',
commentSide,
lineNum,
' which does not exist.'
);
continue;
}
const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
if (!contentEl) continue;
if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
contentEl.appendChild(this._portedCommentsWithoutRangeMessage());
}
const threadGroupEl = this._getOrCreateThreadGroup(
contentEl,
commentSide
);
const slotAtt = threadEl.getAttribute('slot');
if (range && isLongCommentRange(range) && slotAtt) {
const longRangeCommentHint = document.createElement(
'gr-ranged-comment-hint'
);
longRangeCommentHint.range = range;
longRangeCommentHint.setAttribute('threadElRootId', threadEl.rootId);
longRangeCommentHint.setAttribute('slot', slotAtt);
this.insertBefore(longRangeCommentHint, threadEl);
this._redispatchHoverEvents(longRangeCommentHint, threadEl);
}
// 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') as HTMLSlotElement;
if (slotAtt) slot.name = slotAtt;
threadGroupEl.appendChild(slot);
lastEl = threadEl;
}
// Safari is not binding newly created comment-thread
// with the slot somehow, replace itself will rebind it
// @see Issue 11182
if (lastEl && lastEl.replaceWith) {
lastEl.replaceWith(lastEl);
}
const removedThreadEls = info.removedNodes.filter(isThreadEl);
for (const threadEl of removedThreadEls) {
this.querySelector(
`gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
)?.remove();
}
});
}
_portedCommentsWithoutRangeMessage() {
const div = document.createElement('div');
const icon = document.createElement('iron-icon');
icon.setAttribute('icon', 'gr-icons:info-outline');
div.appendChild(icon);
const span = document.createElement('span');
span.innerText = 'Original comment position not found in this patchset';
div.appendChild(span);
return div;
}
_unobserveIncrementalNodes() {
if (this._incrementalNodeObserver) {
(dom(this) as PolymerDomWrapper).unobserveNodes(
this._incrementalNodeObserver
);
}
}
_unobserveNodes() {
if (this._nodeObserver) {
(dom(this) as PolymerDomWrapper).unobserveNodes(this._nodeObserver);
}
}
/**
* Get the preferences object including the safety bypass context (if any).
*/
_getBypassPrefs(prefs: DiffPreferencesInfo) {
if (this._safetyBypass !== null) {
return {...prefs, context: this._safetyBypass};
}
return prefs;
}
clearDiffContent() {
this._unobserveIncrementalNodes();
while (this.$.diffTable.hasChildNodes()) {
this.$.diffTable.removeChild(this.$.diffTable.lastChild!);
}
}
_computeDiffHeaderItems(
diffInfoRecord: PolymerDeepPropertyChange<DiffInfo, DiffInfo>
) {
const diffInfo = diffInfoRecord.base;
if (!diffInfo || !diffInfo.diff_header) {
return [];
}
return diffInfo.diff_header.filter(
item =>
!(
item.startsWith('diff --git ') ||
item.startsWith('index ') ||
item.startsWith('+++ ') ||
item.startsWith('--- ') ||
item === 'Binary files differ'
)
);
}
_computeDiffHeaderHidden(items: string[]) {
return items.length === 0;
}
_handleFullBypass() {
this._safetyBypass = FULL_CONTEXT;
this._debounceRenderDiffTable();
}
_collapseContext() {
// Uses the default context amount if the preference is for the entire file.
this._safetyBypass =
this.prefs?.context && this.prefs.context >= 0
? null
: createDefaultDiffPrefs().context;
this._debounceRenderDiffTable();
}
_computeWarningClass(showWarning?: boolean) {
return showWarning ? 'warn' : '';
}
_computeErrorClass(errorMessage?: string | null) {
return errorMessage ? 'showError' : '';
}
toggleAllContext() {
if (!this.prefs) {
return;
}
if (this._getBypassPrefs(this.prefs).context < 0) {
this._collapseContext();
} else {
this._handleFullBypass();
}
}
_computeNewlineWarning(warnLeft: boolean, warnRight: boolean) {
const messages = [];
if (warnLeft) {
messages.push(NO_NEWLINE_BASE);
}
if (warnRight) {
messages.push(NO_NEWLINE_REVISION);
}
if (!messages.length) {
return null;
}
return messages.join(' \u2014 '); // \u2014 - '—'
}
_computeNewlineWarningClass(warning: boolean, loading: boolean) {
if (loading || !warning) {
return 'newlineWarning hidden';
}
return 'newlineWarning';
}
/**
* Get the approximate length of the diff as the sum of the maximum
* length of the chunks.
*/
getDiffLength(diff?: DiffInfo) {
if (!diff) return 0;
return diff.content.reduce((sum, sec) => {
if (sec.ab) {
return sum + sec.ab.length;
} else {
return (
sum + Math.max(sec.a ? sec.a.length : 0, sec.b ? sec.b.length : 0)
);
}
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-diff': GrDiff;
}
}