diff options
Diffstat (limited to 'polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts')
-rw-r--r-- | polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts | 1104 |
1 files changed, 1104 insertions, 0 deletions
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts new file mode 100644 index 0000000000..34c2a33301 --- /dev/null +++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts @@ -0,0 +1,1104 @@ +/** + * @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 '../../../elements/shared/gr-button/gr-button'; +import '../../../elements/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, + getResponsiveMode, + isResponsive, +} from './gr-diff-utils'; +import {getHiddenScroll} from '../../../scripts/hiddenscroll'; +import {customElement, observe, property} from '@polymer/decorators'; +import {BlameInfo, CommentRange, ImageInfo} 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, + DisplayLine, +} from '../../../api/diff'; +import {isSafari, toggleClass} from '../../../utils/dom-util'; +import {assertIsDefined} from '../../../utils/common-util'; +import {debounce, DelayedTask} from '../../../utils/async-util'; +import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection'; + +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 GrDiff { + $: { + 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: 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, observer: '_lineOfInterestObserver'}) + lineOfInterest?: DisplayLine; + + /** + * 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; + + private diffSelection = new GrDiffSelection(); + + private highlights = new GrDiffHighlight(); + + 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(); + this.diffSelection.cleanup(); + this.highlights.cleanup(); + super.disconnectedCallback(); + } + + getLineNumEls(side: Side): HTMLElement[] { + return this.$.diffBuilder.getLineNumEls(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) + ); + }); + } + + _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.splice('_commentRanges', i, 1); + } + + if (addedCommentRanges && addedCommentRanges.length) { + this.push('_commentRanges', ...addedCommentRanges); + } + if (this.highlightRange) { + this.push('_commentRanges', { + side: Side.RIGHT, + range: this.highlightRange, + 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.side; + keyLocations[side][this.lineOfInterest.lineNum] = 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 []; + + // 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'); + } + + _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(' '); + } + + _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, + }, + }) + ); + } + + /** + * 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 = contentEl.querySelector('.thread-group'); + 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); + } + + _lineOfInterestObserver() { + 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); + } + + _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'; + + // each sign col has 1ch width. + const signColsWidth = + sideBySide && 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' 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} + ${signColsWidth} + ${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 (renderPrefs.show_sign_col) { + this.classList.add('with-sign-col'); + } + 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(); + } + if (this.diff) { + this.diffSelection.init(this.diff, this.$.diffTable); + this.highlights.init(this.$.diffTable, this.$.diffBuilder); + } + } + + /** + * 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. + */ + _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 `fireEvent` 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. + fireEvent(this, 'render-required'); + 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(); + this.$.diffBuilder.prefs = this._getBypassPrefs(this.prefs); + this.$.diffBuilder.renderPrefs = this.renderPrefs; + this.$.diffBuilder.render(keyLocations).then(() => { + fireEvent(this, 'render'); + }); + } + + _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'); + 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 (isSafari() && 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_LEFT); + } + if (warnRight) { + messages.push(NO_NEWLINE_RIGHT); + } + 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; + } +} |