blob: 3929330cbe092c529b783ca2a6f3c96b35b0bb83 [file] [log] [blame]
/**
* @license
* Copyright 2015 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../styles/shared-styles';
import '../../../elements/shared/gr-button/gr-button';
import '../../../elements/shared/gr-icon/gr-icon';
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 {LineNumber} from './gr-diff-line';
import {
getLine,
getLineElByChild,
getLineNumber,
getRange,
getSide,
GrDiffThreadElement,
isLongCommentRange,
isThreadEl,
rangesEqual,
getResponsiveMode,
isResponsive,
getDiffLength,
} from './gr-diff-utils';
import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {
CreateRangeCommentEventDetail,
GrDiffHighlight,
} from '../gr-diff-highlight/gr-diff-highlight';
import {
GrDiffBuilderElement,
getLineNumberCellWidth,
} from '../gr-diff-builder/gr-diff-builder-element';
import {CoverageRange, DiffLayer} 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 {fire, fireAlert} from '../../../utils/event-util';
import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
import {getContentEditableRange} from '../../../utils/safari-selection-util';
import {AbortStop} from '../../../api/core';
import {
CreateCommentEventDetail as CreateCommentEventDetailApi,
RenderPreferences,
GrDiff as GrDiffApi,
DisplayLine,
} from '../../../api/diff';
import {isSafari, toggleClass} from '../../../utils/dom-util';
import {assertIsDefined} from '../../../utils/common-util';
import {
debounceP,
DelayedPromise,
DELAYED_CANCELLATION,
} from '../../../utils/async-util';
import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
import {customElement, property, query, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {html, LitElement, nothing, PropertyValues} from 'lit';
import {when} from 'lit/directives/when.js';
import {grSyntaxTheme} from '../gr-syntax-themes/gr-syntax-theme';
import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
import {classMap} from 'lit/directives/class-map.js';
import {iconStyles} from '../../../styles/gr-icon-styles';
import {expandFileMode} from '../../../utils/file-util';
import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
import {provide} from '../../../models/dependency';
import {grDiffStyles} from './gr-diff-styles';
const NO_NEWLINE_LEFT = 'No newline at end of left file.';
const NO_NEWLINE_RIGHT = 'No newline at end of right 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 CreateCommentEventDetail extends CreateCommentEventDetailApi {
path: string;
}
@customElement('gr-diff')
export class GrDiff extends LitElement implements GrDiffApi {
/**
* 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
*/
@query('#diffTable')
diffTable?: HTMLTableElement;
@property({type: Boolean})
noAutoRender = false;
@property({type: String})
path?: string;
@property({type: Object})
prefs?: DiffPreferencesInfo;
@property({type: Object})
renderPrefs: RenderPreferences = {};
@property({type: Boolean})
isImageDiff?: boolean;
@property({type: Boolean, reflect: true})
override hidden = false;
@property({type: Boolean})
noRenderOnPrefsChange?: boolean;
// Private but used in tests.
@state()
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})
lineWrapping = false;
@property({type: String})
viewMode = DiffViewMode.SIDE_BY_SIDE;
@property({type: Object})
lineOfInterest?: DisplayLine;
/**
* True when diff is changed, until the content is done rendering.
* Use getter/setter loading instead of this.
*/
private _loading = true;
get loading() {
return this._loading;
}
set loading(loading: boolean) {
if (this._loading === loading) return;
const oldLoading = this._loading;
this._loading = loading;
fire(this, 'loading-changed', {value: this._loading});
this.requestUpdate('loading', oldLoading);
}
@property({type: Boolean})
loggedIn = false;
@property({type: Object})
diff?: DiffInfo;
@state()
private 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.
*
* Private but used in tests.
*/
@state()
safetyBypass: number | null = null;
// Private but used in tests.
@state()
showWarning?: boolean;
@property({type: String})
errorMessage: string | null = null;
@property({type: Array})
blame: BlameInfo[] | null = null;
@property({type: Boolean})
showNewlineWarningLeft = false;
@property({type: Boolean})
showNewlineWarningRight = false;
@property({type: Boolean})
useNewImageDiffUi = false;
// Private but used in tests.
@state()
diffLength?: number;
/**
* Observes comment nodes added or removed at any point.
* Can be used to unregister upon detachment.
*/
private nodeObserver?: MutationObserver;
@property({type: Array})
layers?: DiffLayer[];
// Private but used in tests.
renderDiffTableTask?: DelayedPromise<void>;
// Private but used in tests.
diffSelection = new GrDiffSelection();
// Private but used in tests.
highlights = new GrDiffHighlight();
// Private but used in tests.
diffBuilder = new GrDiffBuilderElement();
private diffModel = new DiffModel(undefined);
static override get styles() {
return [
iconStyles,
sharedStyles,
grSyntaxTheme,
grRangedCommentTheme,
grDiffStyles,
];
}
constructor() {
super();
provide(this, diffModelToken, () => this.diffModel);
this.addEventListener(
'create-range-comment',
(e: CustomEvent<CreateRangeCommentEventDetail>) =>
this.handleCreateRangeComment(e)
);
this.addEventListener('render-content', () => this.handleRenderContent());
this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
this.dispatchSelectedLine(e.detail.lineNum, e.detail.side);
});
}
override connectedCallback() {
super.connectedCallback();
if (this.loggedIn) {
this.addSelectionListeners();
}
if (this.diff && this.diffTable) {
this.diffSelection.init(this.diff, this.diffTable);
}
if (this.diffTable && this.diffBuilder) {
this.highlights.init(this.diffTable, this.diffBuilder);
}
this.diffBuilder.init();
}
override disconnectedCallback() {
this.removeSelectionListeners();
this.renderDiffTableTask?.cancel();
this.diffSelection.cleanup();
this.highlights.cleanup();
this.diffBuilder.cleanup();
super.disconnectedCallback();
}
protected override willUpdate(changedProperties: PropertyValues<this>): void {
if (
changedProperties.has('path') ||
changedProperties.has('lineWrapping') ||
changedProperties.has('viewMode') ||
changedProperties.has('useNewImageDiffUi') ||
changedProperties.has('prefs')
) {
this.prefsChanged();
}
if (changedProperties.has('blame')) {
this.blameChanged();
}
if (changedProperties.has('renderPrefs')) {
this.renderPrefsChanged();
}
if (changedProperties.has('loggedIn')) {
if (this.loggedIn && this.isConnected) {
this.addSelectionListeners();
} else {
this.removeSelectionListeners();
}
}
if (changedProperties.has('coverageRanges')) {
this.diffBuilder.updateCoverageRanges(this.coverageRanges);
}
if (changedProperties.has('lineOfInterest')) {
this.lineOfInterestChanged();
}
}
protected override updated(changedProperties: PropertyValues<this>): void {
if (changedProperties.has('diff')) {
// diffChanged relies on diffTable ahving been rendered.
this.diffChanged();
}
}
override render() {
return html`
${this.renderHeader()} ${this.renderContainer()}
${this.renderNewlineWarning()} ${this.renderLoadingError()}
${this.renderSizeWarning()}
`;
}
private renderHeader() {
const diffheaderItems = this.computeDiffHeaderItems();
if (diffheaderItems.length === 0) return nothing;
return html`
<div id="diffHeader">
${diffheaderItems.map(item => html`<div>${item}</div>`)}
</div>
`;
}
private renderContainer() {
const cssClasses = {
diffContainer: true,
unified: this.viewMode === DiffViewMode.UNIFIED,
sideBySide: this.viewMode === DiffViewMode.SIDE_BY_SIDE,
canComment: this.loggedIn,
};
return html`
<div class=${classMap(cssClasses)} @click=${this.handleTap}>
<table
id="diffTable"
class=${this.diffTableClass}
?contenteditable=${this.isContentEditable}
></table>
${when(
this.showNoChangeMessage(),
() => html`
<div class="whitespace-change-only-message">
This file only contains whitespace changes. Modify the whitespace
setting to see the changes.
</div>
`
)}
</div>
`;
}
private renderNewlineWarning() {
const newlineWarning = this.computeNewlineWarning();
if (!newlineWarning) return nothing;
return html`<div class="newlineWarning">${newlineWarning}</div>`;
}
private renderLoadingError() {
if (!this.errorMessage) return nothing;
return html`<div id="loadingError">${this.errorMessage}</div>`;
}
private renderSizeWarning() {
if (!this.showWarning) return nothing;
// TODO: Update comment about 'Whole file' as it's not in settings.
return html`
<div id="sizeWarning">
<p>
Prevented render because "Whole file" is enabled and this diff is very
large (about ${this.diffLength} lines).
</p>
<gr-button @click=${this.collapseContext}>
Render with limited context
</gr-button>
<gr-button @click=${this.handleFullBypass}>
Render anyway (may be slow)
</gr-button>
</div>
`;
}
private addSelectionListeners() {
document.addEventListener('selectionchange', this.handleSelectionChange);
document.addEventListener('mouseup', this.handleMouseUp);
}
private removeSelectionListeners() {
document.removeEventListener('selectionchange', this.handleSelectionChange);
document.removeEventListener('mouseup', this.handleMouseUp);
}
getLineNumEls(side: Side): HTMLElement[] {
return this.diffBuilder.getLineNumEls(side);
}
// Private but used in tests.
showNoChangeMessage() {
return (
!this.loading &&
this.diff &&
!this.diff.binary &&
this.prefs &&
this.prefs.ignore_whitespace !== 'IGNORE_NONE' &&
this.diffLength === 0
);
}
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. */
private 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.shadowRoot?.getSelection
? this.shadowRoot.getSelection()
: isSafari()
? getContentEditableRange()
: document.getSelection();
}
private 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, 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.commentRanges.splice(i, 1);
}
if (addedCommentRanges?.length) {
this.commentRanges.push(...addedCommentRanges);
}
if (this.highlightRange) {
this.commentRanges.push({
side: Side.RIGHT,
range: this.highlightRange,
rootId: '',
});
}
this.diffBuilder.updateCommentRanges(this.commentRanges);
}
/**
* The key locations based on the comments and line of interests,
* where lines should not be collapsed.
*
*/
private computeKeyLocations() {
const keyLocations: KeyLocations = {left: {}, right: {}};
if (this.lineOfInterest) {
const side = this.lineOfInterest.side;
keyLocations[side][this.lineOfInterest.lineNum] = true;
}
const threadEls = [...this.childNodes].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.
private redispatchHoverEvents(
hoverEl: HTMLElement,
threadEl: GrDiffThreadElement
) {
hoverEl.addEventListener('mouseenter', () => {
fire(threadEl, 'comment-thread-mouseenter', {});
});
hoverEl.addEventListener('mouseleave', () => {
fire(threadEl, 'comment-thread-mouseleave', {});
});
}
/** Cancel any remaining diff builder rendering work. */
cancel() {
this.diffBuilder.cleanup();
this.renderDiffTableTask?.cancel();
}
getCursorStops(): Array<HTMLElement | AbortStop> {
if (this.hidden && this.noAutoRender) return [];
// Get rendered stops.
const stops: Array<HTMLElement | AbortStop> =
this.diffBuilder.getLineNumberRows();
// If we are still loading this diff, abort after the rendered stops to
// avoid skipping over to e.g. the next file.
if (this.loading) {
stops.push(new AbortStop());
}
return stops;
}
isRangeSelected() {
return !!this.highlights.selectedRange;
}
toggleLeftDiff() {
toggleClass(this, 'no-left');
}
private blameChanged() {
this.diffBuilder.setBlame(this.blame);
if (this.blame) {
this.classList.add('showBlame');
} else {
this.classList.remove('showBlame');
}
}
// Private but used in tests.
handleTap(e: Event) {
const el = e.target 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);
}
}
}
// Private but used in tests.
selectLine(el: Element) {
const lineNumber = Number(el.getAttribute('data-value'));
const side = el.classList.contains('left') ? Side.LEFT : Side.RIGHT;
this.dispatchSelectedLine(lineNumber, side);
}
private dispatchSelectedLine(number: LineNumber, side: Side) {
fire(this, 'line-selected', {
number,
side,
path: this.path,
});
}
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);
}
}
private handleCreateRangeComment(
e: CustomEvent<CreateRangeCommentEventDetail>
) {
const range = e.detail.range;
const side = e.detail.side;
this.createCommentForSelection(side, range);
}
// Private but used in tests.
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');
fire(this, 'create-comment', {
path: this.path,
side,
lineNum,
range,
});
}
private getCommentSideByLineAndContent(
lineEl: Element,
contentEl: Element
): Side {
return lineEl.classList.contains(Side.LEFT) ||
contentEl.classList.contains('remove')
? Side.LEFT
: Side.RIGHT;
}
private lineOfInterestChanged() {
if (this.loading) return;
if (!this.lineOfInterest) return;
const lineNum = this.lineOfInterest.lineNum;
if (typeof lineNum !== 'number') return;
this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
}
private cleanup() {
this.cancel();
this.blame = null;
this.safetyBypass = null;
this.showWarning = false;
this.clearDiffContent();
}
private prefsChanged() {
if (!this.prefs) return;
this.diffModel.updateState({diffPrefs: this.prefs});
this.blame = null;
this.updatePreferenceStyles();
if (this.diff && !this.noRenderOnPrefsChange) {
this.debounceRenderDiffTable();
}
}
private updatePreferenceStyles() {
assertIsDefined(this.prefs, 'prefs');
const lineLength =
this.path === COMMIT_MSG_PATH
? COMMIT_MSG_LINE_LENGTH
: this.prefs.line_length;
const sideBySide = this.viewMode === 'SIDE_BY_SIDE';
const responsiveMode = getResponsiveMode(this.prefs, this.renderPrefs);
const responsive = isResponsive(responsiveMode);
this.diffTableClass = responsive ? 'responsive' : '';
const lineLimit = `${lineLength}ch`;
this.style.setProperty(
'--line-limit-marker',
responsiveMode === 'FULL_RESPONSIVE' ? lineLimit : '-1px'
);
this.style.setProperty('--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(this.prefs)}px`;
// border-right in ".section" css definition (in gr-diff_html.ts)
const sectionRightBorder = '1px';
// each sign col has 1ch width.
const signColsWidth =
sideBySide && this.renderPrefs?.show_sign_col ? '2ch' : '0ch';
// 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't 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';
this.style.setProperty(
'--diff-max-width',
`calc(${contentWidth} + ${lineNumberWidth} + ${signColsWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`
);
} else {
this.style.setProperty('--diff-max-width', 'none');
}
if (this.prefs.font_size) {
this.style.setProperty('--font-size', `${this.prefs.font_size}px`);
}
}
private renderPrefsChanged() {
this.diffModel.updateState({renderPrefs: this.renderPrefs});
if (this.renderPrefs.hide_left_side) {
this.classList.add('no-left');
}
if (this.renderPrefs.disable_context_control_buttons) {
this.classList.add('disable-context-control-buttons');
}
if (this.renderPrefs.hide_line_length_indicator) {
this.classList.add('hide-line-length-indicator');
}
if (this.renderPrefs.show_sign_col) {
this.classList.add('with-sign-col');
}
if (this.prefs) {
this.updatePreferenceStyles();
}
this.diffBuilder.updateRenderPrefs(this.renderPrefs);
}
private diffChanged() {
this.loading = true;
this.cleanup();
if (this.diff) {
this.diffLength = this.getDiffLength(this.diff);
this.debounceRenderDiffTable();
assertIsDefined(this.diffTable, 'diffTable');
this.diffSelection.init(this.diff, this.diffTable);
this.highlights.init(this.diffTable, this.diffBuilder);
}
}
// Implemented so the test can stub it.
getDiffLength(diff?: DiffInfo) {
return getDiffLength(diff);
}
/**
* When called multiple times from the same task, will call
* _renderDiffTable only once, in the next task (scheduled via `setTimeout`).
*
* 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.
*/
private debounceRenderDiffTable() {
// at this point gr-diff might be considered as rendered from the outside
// (client), although it was not actually rendered. Clients need to know
// when it is safe to perform operations like cursor moves, for example,
// and if changing an input actually requires a reload of the diff table.
// Since `fire` is synchronous it allows clients to be aware when an
// async render is needed and that they can wait for a further `render`
// event to actually take further action.
fire(this, 'render-required', {});
this.renderDiffTableTask = debounceP(
this.renderDiffTableTask,
async () => await this.renderDiffTable()
);
this.renderDiffTableTask.catch((e: unknown) => {
if (e === DELAYED_CANCELLATION) return;
throw e;
});
}
// Private but used in tests.
async renderDiffTable() {
this.unobserveNodes();
if (!this.diff || !this.prefs) {
fire(this, 'render', {});
return;
}
if (
this.prefs.context === -1 &&
this.diffLength &&
this.diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
this.safetyBypass === null
) {
this.showWarning = true;
fire(this, 'render', {});
return;
}
this.showWarning = false;
const keyLocations = this.computeKeyLocations();
this.diffModel.setState({
diff: this.diff,
path: this.path,
renderPrefs: this.renderPrefs,
diffPrefs: this.prefs,
});
// TODO: Setting tons of public properties like this is obviously a code
// smell. We are introducing a diff model for managing all this
// data. Then diff builder will only need access to that model.
this.diffBuilder.prefs = this.getBypassPrefs();
this.diffBuilder.renderPrefs = this.renderPrefs;
this.diffBuilder.diff = this.diff;
this.diffBuilder.path = this.path;
this.diffBuilder.viewMode = this.viewMode;
this.diffBuilder.layers = this.layers ?? [];
this.diffBuilder.isImageDiff = this.isImageDiff;
this.diffBuilder.baseImage = this.baseImage ?? null;
this.diffBuilder.revisionImage = this.revisionImage ?? null;
this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
this.diffBuilder.diffElement = this.diffTable;
// `this.commentRanges` are probably empty here, because they will only be
// populated by the node observer, which starts observing *after* rendering.
this.diffBuilder.updateCommentRanges(this.commentRanges);
this.diffBuilder.updateCoverageRanges(this.coverageRanges);
await this.diffBuilder.render(keyLocations);
}
private handleRenderContent() {
this.querySelectorAll('gr-ranged-comment-hint').forEach(element =>
element.remove()
);
this.loading = false;
this.observeNodes();
// We are just converting 'render-content' into 'render' here. Maybe we
// should retire the 'render' event in favor of 'render-content'?
fire(this, 'render', {});
}
private observeNodes() {
// First stop observing old nodes.
this.unobserveNodes();
// Then introduce a Mutation observer that watches for children being added
// to gr-diff. If those children are `isThreadEl`, namely then they are
// processed.
this.nodeObserver = new MutationObserver(mutations => {
const addedThreadEls = extractAddedNodes(mutations).filter(isThreadEl);
const removedThreadEls =
extractRemovedNodes(mutations).filter(isThreadEl);
this.processNodes(addedThreadEls, removedThreadEls);
});
this.nodeObserver.observe(this, {childList: true});
// Make sure to process existing gr-comment-threads that already exist.
this.processNodes([...this.childNodes].filter(isThreadEl), []);
}
private processNodes(
addedThreadEls: GrDiffThreadElement[],
removedThreadEls: GrDiffThreadElement[]
) {
this.updateRanges(addedThreadEls, removedThreadEls);
addedThreadEls.forEach(threadEl =>
this.redispatchHoverEvents(threadEl, threadEl)
);
// 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.
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') {
this.insertPortedCommentsWithoutRangeMessage(contentEl);
}
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);
}
}
for (const threadEl of removedThreadEls) {
this.querySelector(
`gr-ranged-comment-hint[threadElRootId="${threadEl.rootId}"]`
)?.remove();
}
}
private unobserveNodes() {
if (this.nodeObserver) {
this.nodeObserver.disconnect();
this.nodeObserver = undefined;
}
// You only stop observing for comment thread elements when the diff is
// completely rendered from scratch. And then comment thread elements
// will be (re-)added *after* rendering is done. That is also when we
// re-start observing. So it is appropriate to thoroughly clean up
// everything that the observer is managing.
this.commentRanges = [];
}
private insertPortedCommentsWithoutRangeMessage(lostCell: Element) {
const existingMessage = lostCell.querySelector('div.lost-message');
if (existingMessage) return;
const div = document.createElement('div');
div.className = 'lost-message';
const icon = document.createElement('gr-icon');
icon.setAttribute('icon', 'info');
div.appendChild(icon);
const span = document.createElement('span');
span.innerText = 'Original comment position not found in this patchset';
div.appendChild(span);
lostCell.insertBefore(div, lostCell.firstChild);
}
/**
* Get the preferences object including the safety bypass context (if any).
*/
private getBypassPrefs() {
assertIsDefined(this.prefs, 'prefs');
if (this.safetyBypass !== null) {
return {...this.prefs, context: this.safetyBypass};
}
return this.prefs;
}
clearDiffContent() {
this.unobserveNodes();
if (!this.diffTable) return;
while (this.diffTable.hasChildNodes()) {
this.diffTable.removeChild(this.diffTable.lastChild!);
}
}
// Private but used in tests.
computeDiffHeaderItems() {
return (this.diff?.diff_header ?? [])
.filter(
item =>
!(
item.startsWith('diff --git ') ||
item.startsWith('index ') ||
item.startsWith('+++ ') ||
item.startsWith('--- ') ||
item === 'Binary files differ'
)
)
.map(expandFileMode);
}
private handleFullBypass() {
this.safetyBypass = FULL_CONTEXT;
this.debounceRenderDiffTable();
}
private 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();
}
toggleAllContext() {
if (!this.prefs) {
return;
}
if (this.getBypassPrefs().context < 0) {
this.collapseContext();
} else {
this.handleFullBypass();
}
}
private computeNewlineWarning(): string | undefined {
const messages = [];
if (this.showNewlineWarningLeft) {
messages.push(NO_NEWLINE_LEFT);
}
if (this.showNewlineWarningRight) {
messages.push(NO_NEWLINE_RIGHT);
}
if (!messages.length) {
return undefined;
}
return messages.join(' \u2014 '); // \u2014 - '—'
}
}
function extractAddedNodes(mutations: MutationRecord[]) {
return mutations.flatMap(mutation => [...mutation.addedNodes]);
}
function extractRemovedNodes(mutations: MutationRecord[]) {
return mutations.flatMap(mutation => [...mutation.removedNodes]);
}
declare global {
interface HTMLElementTagNameMap {
'gr-diff': GrDiff;
}
interface HTMLElementEventMap {
'comment-thread-mouseenter': CustomEvent<{}>;
'comment-thread-mouseleave': CustomEvent<{}>;
'loading-changed': ValueChangedEvent<boolean>;
'render-required': CustomEvent<{}>;
}
}