/** * @license * Copyright (C) 2016 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 '../gr-coverage-layer/gr-coverage-layer'; import '../gr-diff-processor/gr-diff-processor'; import '../../shared/gr-hovercard/gr-hovercard'; import '../gr-ranged-comment-layer/gr-ranged-comment-layer'; import './gr-diff-builder-side-by-side'; import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners'; import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin'; import {PolymerElement} from '@polymer/polymer/polymer-element'; import {htmlTemplate} from './gr-diff-builder-element_html'; import {GrAnnotation} from '../gr-diff-highlight/gr-annotation'; import {GrDiffBuilder} from './gr-diff-builder'; import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side'; import {GrDiffBuilderImage} from './gr-diff-builder-image'; import {GrDiffBuilderUnified} from './gr-diff-builder-unified'; import {GrDiffBuilderBinary} from './gr-diff-builder-binary'; import {CancelablePromise, util} from '../../../scripts/util'; import {customElement, property, observe} from '@polymer/decorators'; import {BlameInfo, ImageInfo} from '../../../types/common'; import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff'; import {CoverageRange, DiffLayer} from '../../../types/types'; import { GrDiffProcessor, KeyLocations, } from '../gr-diff-processor/gr-diff-processor'; import { CommentRangeLayer, GrRangedCommentLayer, } from '../gr-ranged-comment-layer/gr-ranged-comment-layer'; import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer'; import {Side} from '../../../constants/constants'; import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line'; import {GrDiffGroup} from '../gr-diff/gr-diff-group'; import {PolymerSpliceChange} from '@polymer/polymer/interfaces'; import {getLineNumber} from '../gr-diff/gr-diff-utils'; import {fireAlert, fireEvent} from '../../../utils/event-util'; const DiffViewMode = { SIDE_BY_SIDE: 'SIDE_BY_SIDE', UNIFIED: 'UNIFIED_DIFF', }; const TRAILING_WHITESPACE_PATTERN = /\s+$/; // https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740 const COMMIT_MSG_PATH = '/COMMIT_MSG'; const COMMIT_MSG_LINE_LENGTH = 72; export interface GrDiffBuilderElement { $: { processor: GrDiffProcessor; rangeLayer: GrRangedCommentLayer; coverageLayerLeft: GrCoverageLayer; coverageLayerRight: GrCoverageLayer; }; } @customElement('gr-diff-builder') export class GrDiffBuilderElement extends GestureEventListeners( LegacyElementMixin(PolymerElement) ) { static get template() { return htmlTemplate; } /** * Fired when the diff begins rendering. * * @event render-start */ /** * Fired when the diff finishes rendering text content. * * @event render-content */ @property({type: Object}) diff?: DiffInfo; @property({type: String}) changeNum?: string; @property({type: String}) patchNum?: string; @property({type: String}) viewMode?: string; @property({type: Boolean}) isImageDiff?: boolean; @property({type: Object}) baseImage: ImageInfo | null = null; @property({type: Object}) revisionImage: ImageInfo | null = null; @property({type: Number}) parentIndex?: number; @property({type: String}) path?: string; @property({type: Object}) _builder?: GrDiffBuilder; @property({type: Array}) _groups: GrDiffGroup[] = []; /** * Layers passed in from the outside. */ @property({type: Array}) layers: DiffLayer[] = []; /** * All layers, both from the outside and the default ones. */ @property({type: Array}) _layers: DiffLayer[] = []; @property({type: Boolean}) _showTabs?: boolean; @property({type: Boolean}) _showTrailingWhitespace?: boolean; @property({type: Array}) commentRanges: CommentRangeLayer[] = []; @property({type: Array}) coverageRanges: CoverageRange[] = []; @property({type: Boolean}) useNewContextControls = false; @property({ type: Array, computed: '_computeLeftCoverageRanges(coverageRanges)', }) _leftCoverageRanges?: CoverageRange[]; @property({ type: Array, computed: '_computeRightCoverageRanges(coverageRanges)', }) _rightCoverageRanges?: CoverageRange[]; /** * The promise last returned from `render()` while the asynchronous * rendering is running - `null` otherwise. Provides a `cancel()` * method that rejects it with `{isCancelled: true}`. */ @property({type: Object}) _cancelableRenderPromise: CancelablePromise | null = null; /** @override */ detached() { super.detached(); if (this._builder) { this._builder.clear(); } } get diffElement() { return this.queryEffectiveChildren('#diffTable') as HTMLTableElement; } _computeLeftCoverageRanges(coverageRanges: CoverageRange[]) { return coverageRanges.filter(range => range && range.side === 'left'); } _computeRightCoverageRanges(coverageRanges: CoverageRange[]) { return coverageRanges.filter(range => range && range.side === 'right'); } render(keyLocations: KeyLocations, prefs: DiffPreferencesInfo) { // Setting up annotation layers must happen after plugins are // installed, and |render| satisfies the requirement, however, // |attached| doesn't because in the diff view page, the element is // attached before plugins are installed. this._setupAnnotationLayers(); this._showTabs = !!prefs.show_tabs; this._showTrailingWhitespace = !!prefs.show_whitespace_errors; // Stop the processor if it's running. this.cancel(); if (this._builder) { this._builder.clear(); } if (!this.diff) { throw Error('Cannot render a diff without DiffInfo.'); } this._builder = this._getDiffBuilder(this.diff, prefs); this.$.processor.context = prefs.context; this.$.processor.keyLocations = keyLocations; this._clearDiffContent(); this._builder.addColumns(this.diffElement, prefs.font_size); const isBinary = !!(this.isImageDiff || this.diff.binary); fireEvent(this, 'render-start'); this._cancelableRenderPromise = util.makeCancelable( this.$.processor.process(this.diff.content, isBinary).then(() => { if (this.isImageDiff) { (this._builder as GrDiffBuilderImage).renderDiff(); } fireEvent(this, 'render-content'); }) ); return ( this._cancelableRenderPromise .finally(() => { this._cancelableRenderPromise = null; }) // Mocca testing does not like uncaught rejections, so we catch // the cancels which are expected and should not throw errors in // tests. .catch(e => { if (!e.isCanceled) return Promise.reject(e); return; }) ); } _setupAnnotationLayers() { const layers: DiffLayer[] = [ this._createTrailingWhitespaceLayer(), this._createIntralineLayer(), this._createTabIndicatorLayer(), this.$.rangeLayer, this.$.coverageLayerLeft, this.$.coverageLayerRight, ]; if (this.layers) { layers.push(...this.layers); } this._layers = layers; } getLineElByChild(node?: Node): HTMLElement | null { while (node) { if (node instanceof Element) { if (node.classList.contains('lineNum')) { return node as HTMLElement; } if (node.classList.contains('section')) { return null; } } node = node.previousSibling ?? node.parentElement ?? undefined; } return null; } getLineNumberByChild(node: Node) { const lineEl = this.getLineElByChild(node); return getLineNumber(lineEl); } getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) { if (!this._builder) return null; return this._builder.getContentTdByLine(lineNumber, side, root); } _getDiffRowByChild(child: Element) { while (!child.classList.contains('diff-row') && child.parentElement) { child = child.parentElement; } return child; } getContentTdByLineEl(lineEl?: Element): Element | null { if (!lineEl) return null; const line = getLineNumber(lineEl); if (!line) return null; const side = this.getSideByLineEl(lineEl); // Performance optimization because we already have an element in the // correct row const row = this._getDiffRowByChild(lineEl); return this.getContentTdByLine(line, side, row); } getLineElByNumber(lineNumber: LineNumber, side?: Side) { const sideSelector = side ? '.' + side : ''; return this.diffElement.querySelector( `.lineNum[data-value="${lineNumber}"]${sideSelector}` ); } getSideByLineEl(lineEl: Element) { return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT; } emitGroup(group: GrDiffGroup, sectionEl: HTMLElement) { if (!this._builder) return; this._builder.emitGroup(group, sectionEl); } showContext(newGroups: GrDiffGroup[], sectionEl: HTMLElement) { if (!this._builder) return; const groups = this._builder.groups; const contextIndex = groups.findIndex(group => group.element === sectionEl); groups.splice(contextIndex, 1, ...newGroups); for (const newGroup of newGroups) { this._builder.emitGroup(newGroup, sectionEl); } if (sectionEl.parentNode) { sectionEl.parentNode.removeChild(sectionEl); } this.async(() => fireEvent(this, 'render-content'), 1); } cancel() { this.$.processor.cancel(); if (this._cancelableRenderPromise) { this._cancelableRenderPromise.cancel(); this._cancelableRenderPromise = null; } } _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}`); } _getDiffBuilder(diff: DiffInfo, prefs: DiffPreferencesInfo): GrDiffBuilder { if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) { this._handlePreferenceError('tab size'); } if (isNaN(prefs.line_length) || prefs.line_length <= 0) { this._handlePreferenceError('diff width'); } const localPrefs = {...prefs}; if (this.path === COMMIT_MSG_PATH) { // override line_length for commit msg the same way as // in gr-diff localPrefs.line_length = COMMIT_MSG_LINE_LENGTH; } let builder = null; if (this.isImageDiff) { builder = new GrDiffBuilderImage( diff, localPrefs, this.diffElement, this.baseImage, this.revisionImage ); } else if (diff.binary) { // If the diff is binary, but not an image. return new GrDiffBuilderBinary(diff, localPrefs, this.diffElement); } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) { builder = new GrDiffBuilderSideBySide( diff, localPrefs, this.diffElement, this._layers, this.useNewContextControls ); } else if (this.viewMode === DiffViewMode.UNIFIED) { builder = new GrDiffBuilderUnified( diff, localPrefs, this.diffElement, this._layers, this.useNewContextControls ); } if (!builder) { throw Error(`Unsupported diff view mode: ${this.viewMode}`); } return builder; } _clearDiffContent() { this.diffElement.innerHTML = ''; } @observe('_groups.splices') _groupsChanged(changeRecord: PolymerSpliceChange) { if (!changeRecord || !this._builder) { return; } for (const splice of changeRecord.indexSplices) { let group; for (let i = 0; i < splice.addedCount; i++) { group = splice.object[splice.index + i]; this._builder.groups.push(group); this._builder.emitGroup(group, null); } } } _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 = 'style-scope gr-diff 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 ? line.text.length : highlight.endIndex; GrAnnotation.annotateElement( contentEl, highlight.startIndex, endIndex - highlight.startIndex, HL_CLASS ); } }, }; } _createTabIndicatorLayer(): DiffLayer { const show = () => this._showTabs; return { annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) { // If visible tabs are disabled, do nothing. if (!show()) { return; } // Find and annotate the locations of tabs. const split = line.text.split('\t'); if (!split) { return; } for (let i = 0, pos = 0; i < split.length - 1; i++) { // Skip forward by the length of the content pos += split[i].length; GrAnnotation.annotateElement( contentEl, pos, 1, 'style-scope gr-diff tab-indicator' ); // Skip forward by one tab character. pos++; } }, }; } _createTrailingWhitespaceLayer(): DiffLayer { const show = () => { return this._showTrailingWhitespace; }; 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 = GrAnnotation.getStringLength( line.text.substr(0, match.index) ); const length = GrAnnotation.getStringLength(match[0]); GrAnnotation.annotateElement( contentEl, index, length, 'style-scope gr-diff trailing-whitespace' ); } }, }; } setBlame(blame: BlameInfo[] | null) { if (!this._builder) return; this._builder.setBlame(blame); } } declare global { interface HTMLElementTagNameMap { 'gr-diff-builder': GrDiffBuilderElement; } }