blob: 90b9cfef54decf966e0f13edbde85f6497f0e71a [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-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 '../gr-diff-builder/gr-diff-builder-image';
import '../gr-diff-builder/gr-diff-section';
import './gr-diff-element';
import '../gr-diff-builder/gr-diff-row';
import {
getLineNumber,
isThreadEl,
getResponsiveMode,
isResponsive,
getSideByLineEl,
compareComments,
getDataFromCommentThreadEl,
FullContext,
DiffContextExpandedEventDetail,
GrDiffCommentThread,
} from '../gr-diff/gr-diff-utils';
import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
import {CoverageRange, DiffLayer, isDefined} from '../../../types/types';
import {
CommentRangeLayer,
GrRangedCommentLayer,
} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
import {DiffViewMode, Side} from '../../../constants/constants';
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 {
RenderPreferences,
GrDiff as GrDiffApi,
DisplayLine,
LineNumber,
ContentLoadNeededEventDetail,
DiffContextExpandedExternalDetail,
} from '../../../api/diff';
import {isSafari} from '../../../utils/dom-util';
import {assertIsDefined} from '../../../utils/common-util';
import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
import {property, query, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {html, LitElement, PropertyValues} from 'lit';
import {grSyntaxTheme} from '../gr-syntax-themes/gr-syntax-theme';
import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
import {iconStyles} from '../../../styles/gr-icon-styles';
import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
import {provide} from '../../../models/dependency';
import {
grDiffBinaryStyles,
grDiffContextControlsSectionStyles,
grDiffElementStyles,
grDiffIgnoredWhitespaceStyles,
grDiffImageStyles,
grDiffMoveStyles,
grDiffRebaseStyles,
grDiffRowStyles,
grDiffSectionStyles,
grDiffSelectionStyles,
grDiffStyles,
grDiffTextStyles,
} from './gr-diff-styles';
import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
import {
GrAnnotationImpl,
getStringLength,
} from '../gr-diff-highlight/gr-annotation';
import {
GrDiffGroup,
GrDiffGroupType,
hideInContextControl,
} from './gr-diff-group';
import {GrDiffLine} from './gr-diff-line';
import {subscribe} from '../../../elements/lit/subscription-controller';
import {GrDiffSection} from '../gr-diff-builder/gr-diff-section';
import {GrDiffRow} from '../gr-diff-builder/gr-diff-row';
import {GrDiffElement} from './gr-diff-element';
const TRAILING_WHITESPACE_PATTERN = /\s+$/;
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 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
*/
/**
* Deprecated. Use `diffElement` instead.
*
* TODO: Migrate to new diff. Remove dependency on this property from external
* gr-diff users that instantiate TokenHighlightLayer.
*/
@query('gr-diff-element')
diffTable?: HTMLElement;
@query('gr-diff-element')
diffElement?: GrDiffElement;
@property({type: Boolean})
noAutoRender = false;
@property({type: String})
path?: string;
@property({type: Object})
prefs?: DiffPreferencesInfo;
@property({type: Object})
renderPrefs: RenderPreferences = {};
@property({type: Boolean, reflect: true})
override hidden = false;
@property({type: Boolean})
noRenderOnPrefsChange?: boolean;
// 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;
// TODO: Migrate users to using the same property in render preferences.
@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;
@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();
@property({type: String})
errorMessage: string | null = null;
@property({type: Array})
blame: BlameInfo[] | null = null;
// TODO: Migrate users to using the same property in render preferences.
@property({type: Boolean})
showNewlineWarningLeft = false;
// TODO: Migrate users to using the same property in render preferences.
@property({type: Boolean})
showNewlineWarningRight = false;
// TODO: Migrate users to using the same property in render preferences.
@property({type: Boolean})
useNewImageDiffUi = false;
// Private but used in tests.
@state()
diffLength?: number;
/** Observes comment nodes added or removed at any point. */
private nodeObserver?: MutationObserver;
// Private but used in tests.
diffSelection = new GrDiffSelection();
// Private but used in tests.
highlights = new GrDiffHighlight();
// Private but used in tests.
diffModel = new DiffModel(this);
/**
* Just the layers that are passed in from the outside. Will be joined with
* `layersInternal` and sent to the diff model.
*/
@property({type: Array})
layers: DiffLayer[] = [];
/**
* Just the internal default layers. See `layers` for the property that can
* be set from the outside.
*/
private layersInternal: DiffLayer[] = [];
private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
private rangeLayer = new GrRangedCommentLayer();
@state() groups: GrDiffGroup[] = [];
@state() private context = 3;
private readonly layerUpdateListener: (
start: LineNumber,
end: LineNumber,
side: Side
) => void;
static override get styles() {
return [
iconStyles,
sharedStyles,
grSyntaxTheme,
grRangedCommentTheme,
grDiffStyles,
grDiffElementStyles,
grDiffSectionStyles,
grDiffContextControlsSectionStyles,
grDiffRowStyles,
grDiffIgnoredWhitespaceStyles,
grDiffMoveStyles,
grDiffRebaseStyles,
grDiffSelectionStyles,
grDiffTextStyles,
grDiffImageStyles,
grDiffBinaryStyles,
];
}
constructor() {
super();
provide(this, diffModelToken, () => this.diffModel);
subscribe(
this,
() => this.diffModel.context$,
context => (this.context = context)
);
subscribe(
this,
() => this.diffModel.groups$,
groups => (this.groups = groups)
);
this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
this.diffModel.selectLine(e.detail.lineNum, e.detail.side);
});
this.addEventListener(
'diff-context-expanded-internal-new',
this.onDiffContextExpanded
);
this.layerUpdateListener = (
start: LineNumber,
end: LineNumber,
side: Side
) => this.requestRowUpdates(start, end, side);
this.layersInternalInit();
}
override connectedCallback() {
super.connectedCallback();
if (this.loggedIn) {
this.addSelectionListeners();
}
if (this.diff && this.diffElement) {
this.diffSelection.init(this.diff, this.diffElement);
}
if (this.diffElement) {
this.highlights.init(this.diffElement, this);
}
this.observeNodes();
}
override disconnectedCallback() {
if (this.nodeObserver) {
this.nodeObserver.disconnect();
this.nodeObserver = undefined;
}
this.removeSelectionListeners();
this.diffSelection.cleanup();
this.highlights.cleanup();
super.disconnectedCallback();
}
protected override willUpdate(changedProperties: PropertyValues<this>): void {
if (
changedProperties.has('diff') ||
changedProperties.has('path') ||
changedProperties.has('renderPrefs') ||
changedProperties.has('viewMode') ||
changedProperties.has('loggedIn') ||
changedProperties.has('useNewImageDiffUi') ||
changedProperties.has('showNewlineWarningLeft') ||
changedProperties.has('showNewlineWarningRight') ||
changedProperties.has('prefs') ||
changedProperties.has('lineOfInterest')
) {
if (this.diff && this.prefs) {
const renderPrefs = {...(this.renderPrefs ?? {})};
// TODO: Migrate users to using render preferences directly. Then removes these overrides.
if (renderPrefs.view_mode === undefined) {
renderPrefs.view_mode = this.viewMode;
}
if (renderPrefs.can_comment === undefined) {
renderPrefs.can_comment = this.loggedIn;
}
if (renderPrefs.use_new_image_diff_ui === undefined) {
renderPrefs.use_new_image_diff_ui = this.useNewImageDiffUi;
}
if (renderPrefs.show_newline_warning_left === undefined) {
renderPrefs.show_newline_warning_left = this.showNewlineWarningLeft;
}
if (renderPrefs.show_newline_warning_right === undefined) {
renderPrefs.show_newline_warning_right = this.showNewlineWarningRight;
}
this.diffModel.updateState({
diff: this.diff,
path: this.path,
renderPrefs,
diffPrefs: this.prefs,
lineOfInterest: this.lineOfInterest,
});
}
}
if (changedProperties.has('baseImage')) {
this.diffModel.updateState({baseImage: this.baseImage});
}
if (changedProperties.has('revisionImage')) {
this.diffModel.updateState({revisionImage: this.revisionImage});
}
if (
changedProperties.has('path') ||
changedProperties.has('lineWrapping') ||
changedProperties.has('viewMode') ||
changedProperties.has('useNewImageDiffUi') ||
changedProperties.has('prefs')
) {
this.prefsChanged();
}
if (changedProperties.has('layers')) {
this.layersChanged();
}
if (changedProperties.has('blame')) {
this.diffModel.updateState({blameInfo: this.blame ?? []});
}
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.updateCoverageRanges(this.coverageRanges);
}
if (changedProperties.has('lineOfInterest')) {
this.lineOfInterestChanged();
}
}
protected override async getUpdateComplete(): Promise<boolean> {
const result = await super.getUpdateComplete();
await this.diffElement?.updateComplete;
return result;
}
protected override updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has('diff')) {
// diffChanged relies on diffElement having been rendered.
this.diffChanged();
}
if (changedProperties.has('groups')) {
if (this.groups?.length > 0) {
this.loading = false;
}
}
}
override render() {
return html`<gr-diff-element></gr-diff-element>`;
}
private addSelectionListeners() {
document.addEventListener('selectionchange', this.handleSelectionChange);
document.addEventListener('mouseup', this.handleMouseUp);
}
private removeSelectionListeners() {
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. */
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 commentThreadRedispatcher = (
target: EventTarget | null,
eventName: 'comment-thread-mouseenter' | 'comment-thread-mouseleave'
) => {
if (!isThreadEl(target)) return;
const data = getDataFromCommentThreadEl(target);
if (data) fire(target, eventName, data);
};
private commentThreadEnterRedispatcher = (e: Event) => {
this.commentThreadRedispatcher(e.target, 'comment-thread-mouseenter');
};
private commentThreadLeaveRedispatcher = (e: Event) => {
this.commentThreadRedispatcher(e.target, 'comment-thread-mouseleave');
};
getCursorStops(): Array<HTMLElement | AbortStop> {
if (this.hidden && this.noAutoRender) return [];
// Get rendered stops.
const stops: Array<HTMLElement | AbortStop> = this.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;
}
// 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.diffModel.selectLine(lineNumber, side);
}
addDraftAtLine(lineNum: LineNumber, side: Side) {
this.diffModel.createCommentOnLine(lineNum, side);
}
createRangeComment() {
const selectedRange = this.highlights.selectedRange;
assertIsDefined(selectedRange, 'no range selected');
const {side, range} = selectedRange;
this.diffModel.createCommentOnRange(range, side);
}
private lineOfInterestChanged() {
if (this.loading) return;
if (!this.lineOfInterest) return;
const lineNum = this.lineOfInterest.lineNum;
if (typeof lineNum !== 'number') return;
this.unhideLine(lineNum, this.lineOfInterest.side);
}
private prefsChanged() {
if (!this.prefs) return;
this.updatePreferenceStyles();
if (!Number.isInteger(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
this.handlePreferenceError('tab size');
}
if (
!Number.isInteger(this.prefs.line_length) ||
this.prefs.line_length <= 0
) {
this.handlePreferenceError('diff width');
}
}
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);
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.classList.toggle(
'disable-context-control-buttons',
!!this.renderPrefs.disable_context_control_buttons
);
this.classList.toggle(
'hide-line-length-indicator',
!!this.renderPrefs.hide_line_length_indicator
);
this.classList.toggle('with-sign-col', !!this.renderPrefs.show_sign_col);
if (this.prefs) {
this.updatePreferenceStyles();
}
}
private diffChanged() {
this.loading = true;
if (this.diff && this.diffElement) {
this.diffSelection.init(this.diff, this.diffElement);
this.highlights.init(this.diffElement, this);
}
}
/**
* This must be called once, but only after diff lines are rendered. Otherwise
* `processNodes()` will fail to lookup the HTML elements that it wants to
* manipulate.
*
* TODO: Validate whether the above comment is still true. We don't look up
* elements anymore, and processing the nodes earlier might be beneficial
* performance wise.
*/
private observeNodes() {
if (this.nodeObserver) return;
// Watches children being added to gr-diff. We are expecting only comment
// widgets to be direct children.
this.nodeObserver = new MutationObserver(() => this.processNodes());
this.nodeObserver.observe(this, {childList: true});
// Process existing comment widgets before the first observed change.
this.processNodes();
}
private processNodes() {
const threadEls = [...this.childNodes].filter(isThreadEl);
const comments = threadEls
.map(getDataFromCommentThreadEl)
.filter(isDefined)
.sort(compareComments);
this.diffModel.updateState({comments});
this.updateRangeLayer(comments);
for (const el of threadEls) {
el.addEventListener('mouseenter', this.commentThreadEnterRedispatcher);
el.addEventListener('mouseleave', this.commentThreadLeaveRedispatcher);
}
}
private updateRangeLayer(threads: GrDiffCommentThread[]) {
const ranges: CommentRangeLayer[] = threads
.filter(t => !!t.range)
.map(t => {
return {range: t.range!, side: t.side, id: t.rootId};
});
if (this.highlightRange) {
ranges.push({side: Side.RIGHT, range: this.highlightRange, id: 'hl'});
}
this.rangeLayer.updateRanges(ranges);
}
// TODO: Migrate callers to just update prefs.context.
toggleAllContext() {
const current = this.diffModel.getState().showFullContext;
this.diffModel.updateState({
showFullContext:
current === FullContext.YES ? FullContext.NO : FullContext.YES,
});
}
private updateCoverageRanges(rs: CoverageRange[]) {
this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
}
private onDiffContextExpanded = (
e: CustomEvent<DiffContextExpandedEventDetail>
) => {
// Don't stop propagation. The host may listen for reporting or
// resizing.
this.diffModel.replaceGroup(e.detail.contextGroup, e.detail.groups);
};
private layersChanged() {
const layers = [...this.layersInternal, ...this.layers];
for (const layer of layers) {
layer.removeListener?.(this.layerUpdateListener);
layer.addListener?.(this.layerUpdateListener);
}
this.diffModel.updateState({layers});
}
private layersInternalInit() {
this.layersInternal = [
this.createTrailingWhitespaceLayer(),
this.createIntralineLayer(),
this.createTabIndicatorLayer(),
this.createSpecialCharacterIndicatorLayer(),
this.rangeLayer,
this.coverageLayerLeft,
this.coverageLayerRight,
];
this.layersChanged();
}
getContentTdByLineEl(lineEl?: Element): Element | undefined {
if (!lineEl) return undefined;
const line = getLineNumber(lineEl);
if (!line) return undefined;
const side = getSideByLineEl(lineEl);
return this.getContentTdByLine(line, side);
}
/**
* When the line is hidden behind a context expander, expand it.
*
* @param lineNum A line number to expand. Using number here because other
* special case line numbers are never hidden, so it does not make sense
* to expand them.
* @param side The side the line number refer to.
*/
unhideLine(lineNum: number, side: Side) {
assertIsDefined(this.prefs, 'prefs');
const group = this.findGroup(side, lineNum);
// Cannot unhide a line that is not part of the diff.
if (!group) return;
// If it's already visible, great!
if (group.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
const lineRange = group.lineRange[side];
const lineOffset = lineNum - lineRange.start_line;
const newGroups = [];
const groups = hideInContextControl(
group.contextGroups,
0,
lineOffset - 1 - this.context
);
// If there is a context group, it will be the first group because we
// start hiding from 0 offset
if (groups[0].type === GrDiffGroupType.CONTEXT_CONTROL) {
newGroups.push(groups.shift()!);
}
newGroups.push(
...hideInContextControl(
groups,
lineOffset + 1 + this.context,
// Both ends inclusive, so difference is the offset of the last line.
// But we need to pass the first line not to hide, which is the element
// after.
lineRange.end_line - lineRange.start_line + 1
)
);
this.diffModel.replaceGroup(group, newGroups);
}
// visible for testing
handlePreferenceError(pref: string): never {
const message =
`The value of the '${pref}' user preference is ` +
'invalid. Fix in diff preferences';
fireAlert(this, message);
throw Error(`Invalid preference value: ${pref}`);
}
// visible for testing
createIntralineLayer(): DiffLayer {
return {
// Take a DIV.contentText element and a line object with intraline
// differences to highlight and apply them to the element as
// annotations.
annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
const HL_CLASS = 'intraline';
for (const highlight of line.highlights) {
// The start and end indices could be the same if a highlight is
// meant to start at the end of a line and continue onto the
// next one. Ignore it.
if (highlight.startIndex === highlight.endIndex) {
continue;
}
// If endIndex isn't present, continue to the end of the line.
const endIndex =
highlight.endIndex === undefined
? getStringLength(line.text)
: highlight.endIndex;
GrAnnotationImpl.annotateElement(
contentEl,
highlight.startIndex,
endIndex - highlight.startIndex,
HL_CLASS
);
}
},
};
}
// visible for testing
createTabIndicatorLayer(): DiffLayer {
const show = () => this.prefs?.show_tabs;
return {
annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
if (!show()) return;
annotateSymbols(contentEl, line, '\t', 'tab-indicator');
},
};
}
private createSpecialCharacterIndicatorLayer(): DiffLayer {
return {
annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
// Find and annotate the locations of soft hyphen (\u00AD)
annotateSymbols(contentEl, line, '\u00AD', 'special-char-indicator');
// Find and annotate Stateful Unicode directional controls
annotateSymbols(
contentEl,
line,
/[\u202A-\u202E\u2066-\u2069]/,
'special-char-warning'
);
},
};
}
// visible for testing
createTrailingWhitespaceLayer(): DiffLayer {
const show = () => this.prefs?.show_whitespace_errors;
return {
annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
if (!show()) return;
const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
if (match) {
// Normalize string positions in case there is unicode before or
// within the match.
const index = getStringLength(line.text.substr(0, match.index));
const length = getStringLength(match[0]);
GrAnnotationImpl.annotateElement(
contentEl,
index,
length,
'trailing-whitespace'
);
}
},
};
}
getContentTdByLine(
lineNumber: LineNumber,
side?: Side
): HTMLTableCellElement | undefined {
if (!side) return undefined;
const row = this.findRow(side, lineNumber);
return row?.getContentCell(side);
}
getLineElByNumber(
lineNumber: LineNumber,
side?: Side
): HTMLTableCellElement | undefined {
if (!side) return undefined;
const row = this.findRow(side, lineNumber);
return row?.getLineNumberCell(side);
}
private findRow(side: Side, lineNumber: LineNumber): GrDiffRow | undefined {
const group = this.findGroup(side, lineNumber);
if (!group) return undefined;
const section = this.findSection(group);
if (!section) return undefined;
return section.findRow(side, lineNumber);
}
private getDiffRows() {
if (!this.diffElement) return [];
const sections = [...(this.diffElement.diffSections ?? [])];
return sections.map(s => s.getDiffRows()).flat();
}
getLineNumberRows(): HTMLTableRowElement[] {
const rows = this.getDiffRows();
return rows.map(r => r.getTableRow()).filter(isDefined);
}
getLineNumEls(side: Side): HTMLTableCellElement[] {
const rows = this.getDiffRows();
return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
}
/** This is used when layers initiate an update. */
private requestRowUpdates(start: LineNumber, end: LineNumber, side: Side) {
const groups = this.getGroupsByLineRange(start, end, side);
for (const group of groups) {
const section = this.findSection(group);
for (const row of section?.getDiffRows() ?? []) {
row.requestUpdate();
}
}
}
private findSection(group: GrDiffGroup): GrDiffSection | undefined {
if (!this.diffElement) return undefined;
const leftClass = `left-${group.startLine(Side.LEFT)}`;
const rightClass = `right-${group.startLine(Side.RIGHT)}`;
return (
this.diffElement.querySelector<GrDiffSection>(
`gr-diff-section.${leftClass}.${rightClass}`
) ?? undefined
);
}
findGroup(side: Side, line: LineNumber) {
return this.groups.find(group => group.containsLine(side, line));
}
// visible for testing
getGroupsByLineRange(
startLine: LineNumber,
endLine: LineNumber,
side: Side
): GrDiffGroup[] {
const startIndex = this.groups.findIndex(group =>
group.containsLine(side, startLine)
);
if (startIndex === -1) return [];
let endIndex = this.groups.findIndex(group =>
group.containsLine(side, endLine)
);
// Not all groups may have been processed yet (i.e. this.groups is still
// incomplete). In that case let's just return *all* groups until the end
// of the array.
if (endIndex === -1) endIndex = this.groups.length - 1;
// The filter preserves the legacy behavior to only return non-context
// groups
return this.groups
.slice(startIndex, endIndex + 1)
.filter(group => group.lines.length > 0);
}
}
function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
return prefs.font_size * 4;
}
function annotateSymbols(
contentEl: HTMLElement,
line: GrDiffLine,
separator: string | RegExp,
className: string
) {
const split = line.text.split(separator);
if (!split || split.length < 2) {
return;
}
for (let i = 0, pos = 0; i < split.length - 1; i++) {
// Skip forward by the length of the content
pos += split[i].length;
GrAnnotationImpl.annotateElement(contentEl, pos, 1, className);
pos++;
}
}
customElements.define('gr-diff', GrDiff);
declare global {
interface HTMLElementTagNameMap {
'gr-diff': GrDiff;
}
interface HTMLElementEventMap {
'comment-thread-mouseenter': CustomEvent<GrDiffCommentThread>;
'comment-thread-mouseleave': CustomEvent<GrDiffCommentThread>;
'loading-changed': ValueChangedEvent<boolean>;
'render-required': CustomEvent<{}>;
/**
* Fired when the diff begins rendering - both for full renders and for
* partial rerenders.
*/
'render-start': CustomEvent<{}>;
/**
* Fired when the diff finishes rendering text content - both for full
* renders and for partial rerenders.
*/
'render-content': CustomEvent<{}>;
'diff-context-expanded': CustomEvent<DiffContextExpandedExternalDetail>;
'diff-context-expanded-internal-new': CustomEvent<DiffContextExpandedEventDetail>;
'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>;
}
}