| /** | |
| * @license | |
| * Copyright 2022 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import {html, LitElement, nothing, PropertyValues, TemplateResult} from 'lit'; | |
| import {property, state} from 'lit/decorators.js'; | |
| import {ifDefined} from 'lit/directives/if-defined.js'; | |
| import {createRef, Ref, ref} from 'lit/directives/ref.js'; | |
| import { | |
| DiffResponsiveMode, | |
| Side, | |
| LineNumber, | |
| DiffLayer, | |
| GrDiffLineType, | |
| LOST, | |
| FILE, | |
| } from '../../../api/diff'; | |
| import {BlameInfo} from '../../../types/common'; | |
| import {assertIsDefined} from '../../../utils/common-util'; | |
| import {fire} from '../../../utils/event-util'; | |
| import {getBaseUrl} from '../../../utils/url-util'; | |
| import {otherSide} from '../../../utils/diff-util'; | |
| import './gr-diff-text'; | |
| import { | |
| diffClasses, | |
| GrDiffCommentThread, | |
| isLongCommentRange, | |
| isNewDiff, | |
| isResponsive, | |
| } from '../gr-diff/gr-diff-utils'; | |
| import {resolve} from '../../../models/dependency'; | |
| import {diffModelToken} from '../gr-diff-model/gr-diff-model'; | |
| import {when} from 'lit/directives/when.js'; | |
| import {isDefined} from '../../../types/types'; | |
| import {BehaviorSubject, combineLatest} from 'rxjs'; | |
| import '../../../elements/shared/gr-hovercard/gr-hovercard'; | |
| import {GrDiffLine} from '../gr-diff/gr-diff-line'; | |
| import {distinctUntilChanged, map} from 'rxjs/operators'; | |
| import {deepEqual} from '../../../utils/deep-util'; | |
| import {subscribe} from '../../../elements/lit/subscription-controller'; | |
| export class GrDiffRow extends LitElement { | |
| contentLeftRef: Ref<LitElement> = createRef(); | |
| contentRightRef: Ref<LitElement> = createRef(); | |
| contentCellLeftRef: Ref<HTMLTableCellElement> = createRef(); | |
| contentCellRightRef: Ref<HTMLTableCellElement> = createRef(); | |
| lineNumberLeftRef: Ref<HTMLTableCellElement> = createRef(); | |
| lineNumberRightRef: Ref<HTMLTableCellElement> = createRef(); | |
| blameCellRef: Ref<HTMLTableCellElement> = createRef(); | |
| tableRowRef: Ref<HTMLTableRowElement> = createRef(); | |
| @property({type: Object}) | |
| left?: GrDiffLine; | |
| private left$ = new BehaviorSubject<GrDiffLine | undefined>(undefined); | |
| @property({type: Object}) | |
| right?: GrDiffLine; | |
| private right$ = new BehaviorSubject<GrDiffLine | undefined>(undefined); | |
| @property({type: Object}) | |
| blameInfo?: BlameInfo; | |
| @property({type: Object}) | |
| responsiveMode?: DiffResponsiveMode; | |
| /** | |
| * true: side-by-side diff | |
| * false: unified diff | |
| */ | |
| @property({type: Boolean}) | |
| unifiedDiff = false; | |
| @property({type: Number}) | |
| tabSize = 2; | |
| @property({type: Number}) | |
| lineLength = 80; | |
| @property({type: Boolean}) | |
| hideFileCommentButton = false; | |
| @property({type: Object}) | |
| layers: DiffLayer[] = []; | |
| /** | |
| * Semantic DOM diff testing does not work with just table fragments, so when | |
| * running such tests the render() method has to wrap the DOM in a proper | |
| * <table> element. | |
| */ | |
| @state() addTableWrapperForTesting = false; | |
| @state() leftComments: GrDiffCommentThread[] = []; | |
| @state() rightComments: GrDiffCommentThread[] = []; | |
| /** | |
| * Keeps track of whether diff layers have already been applied to the diff | |
| * row. That happens after the DOM has been created in the `updated()` | |
| * lifecycle callback. | |
| * | |
| * Once layers are applied, the diff row requires two rendering passes for an | |
| * update: 1. Remove all <gr-diff-text> elements and their layer manipulated | |
| * DOMs. 2. Add fresh <gr-diff-text> elements and let layers re-apply in | |
| * `updated()`. | |
| */ | |
| private layersApplied = false; | |
| private readonly getDiffModel = resolve(this, diffModelToken); | |
| constructor() { | |
| super(); | |
| subscribe( | |
| this, | |
| () => | |
| combineLatest([this.left$, this.getDiffModel().comments$]).pipe( | |
| map(([left, comments]) => | |
| comments.filter( | |
| c => | |
| c.line === left?.lineNumber(Side.LEFT) && c.side === Side.LEFT | |
| ) | |
| ), | |
| distinctUntilChanged(deepEqual) | |
| ), | |
| leftComments => (this.leftComments = leftComments) | |
| ); | |
| subscribe( | |
| this, | |
| () => | |
| combineLatest([this.right$, this.getDiffModel().comments$]).pipe( | |
| map(([right, comments]) => | |
| comments.filter( | |
| c => | |
| c.line === right?.lineNumber(Side.RIGHT) && | |
| c.side === Side.RIGHT | |
| ) | |
| ), | |
| distinctUntilChanged(deepEqual) | |
| ), | |
| rightComments => (this.rightComments = rightComments) | |
| ); | |
| } | |
| override willUpdate(changedProperties: PropertyValues) { | |
| if (changedProperties.has('left')) this.left$.next(this.left); | |
| if (changedProperties.has('right')) this.right$.next(this.right); | |
| } | |
| /** | |
| * The browser API for handling selection does not (yet) work for selection | |
| * across multiple shadow DOM elements. So we are rendering gr-diff components | |
| * into the light DOM instead of the shadow DOM by overriding this method, | |
| * which was the recommended workaround by the lit team. | |
| * See also https://github.com/WICG/webcomponents/issues/79. | |
| */ | |
| override createRenderRoot() { | |
| return this; | |
| } | |
| override updated() { | |
| if (this.layersApplied) { | |
| // <gr-diff-text> elements have been removed during rendering. Let's start | |
| // another rendering cycle with freshly created <gr-diff-text> elements. | |
| this.updateComplete.then(() => { | |
| this.layersApplied = false; | |
| this.requestUpdate(); | |
| }); | |
| } else { | |
| this.updateLayers(Side.LEFT); | |
| this.updateLayers(Side.RIGHT); | |
| } | |
| } | |
| /** | |
| * The diff layers API is designed to let layers manipulate the DOM. So we | |
| * have to apply them after the rendering cycle is done (`updated()`). But | |
| * when re-rendering a row that already has layers applied, then we have to | |
| * first wipe away <gr-diff-text>. This is achieved by | |
| * `this.layersApplied = true`. | |
| */ | |
| private async updateLayers(side: Side) { | |
| const line = this.line(side); | |
| const contentEl = this.contentRef(side).value; | |
| const lineNumberEl = this.lineNumberRef(side).value; | |
| if (!line || !contentEl || !lineNumberEl) return; | |
| // We have to wait for the <gr-diff-text> child component to finish | |
| // rendering before we can apply layers, which will re-write the HTML. | |
| await contentEl?.updateComplete; | |
| for (const layer of this.layers) { | |
| if (typeof layer.annotate === 'function') { | |
| layer.annotate(contentEl, lineNumberEl, line, side); | |
| } | |
| } | |
| // At this point we consider layers applied. So as soon as <gr-diff-row> | |
| // enters a new rendering cycle <gr-diff-text> elements will be removed. | |
| this.layersApplied = true; | |
| } | |
| override render() { | |
| if (!this.left || !this.right) return; | |
| const classes = this.unifiedDiff ? ['unified'] : ['side-by-side']; | |
| const unifiedType = this.unifiedType(); | |
| if (this.unifiedDiff && unifiedType) classes.push(unifiedType); | |
| const row = html` | |
| <tr | |
| ${ref(this.tableRowRef)} | |
| class=${diffClasses('diff-row', ...classes)} | |
| left-type=${ifDefined(this.getType(Side.LEFT))} | |
| right-type=${ifDefined(this.getType(Side.RIGHT))} | |
| tabindex="-1" | |
| aria-labelledby=${this.ariaLabelIds()} | |
| > | |
| ${this.renderBlameCell()} ${this.renderLineNumberCell(Side.LEFT)} | |
| ${this.renderSignCell(Side.LEFT)} ${this.renderContentCell(Side.LEFT)} | |
| ${this.renderLineNumberCell(Side.RIGHT)} | |
| ${this.renderSignCell(Side.RIGHT)} ${this.renderContentCell(Side.RIGHT)} | |
| </tr> | |
| ${this.renderPostLineSlot(Side.LEFT)} | |
| ${this.renderPostLineSlot(Side.RIGHT)} | |
| `; | |
| if (this.addTableWrapperForTesting) { | |
| return html`<table> | |
| ${row} | |
| </table>`; | |
| } | |
| return row; | |
| } | |
| private ariaLabelIds() { | |
| const ids: string[] = []; | |
| ids.push(this.lineNumberId(Side.LEFT)); | |
| if (!this.unifiedDiff) ids.push(this.contentId(Side.LEFT)); | |
| ids.push(this.lineNumberId(Side.RIGHT)); | |
| if (!this.unifiedDiff) ids.push(this.contentId(Side.RIGHT)); | |
| if (this.unifiedDiff) ids.push(this.contentId(this.unifiedSide())); | |
| return ids.filter(id => !!id).join(' '); | |
| } | |
| private lineNumberId(side: Side): string { | |
| const lineNumber = this.lineNumber(side); | |
| if (!lineNumber) return ''; | |
| return `${side}-button-${lineNumber}`; | |
| } | |
| private unifiedSide() { | |
| const isLeft = this.line(Side.RIGHT)?.type === GrDiffLineType.BLANK; | |
| return isLeft ? Side.LEFT : Side.RIGHT; | |
| } | |
| private contentId(side: Side): string { | |
| const lineNumber = this.lineNumber(side); | |
| if (!lineNumber) return ''; | |
| return `${side}-content-${lineNumber}`; | |
| } | |
| getTableRow(): HTMLTableRowElement | undefined { | |
| return this.tableRowRef.value; | |
| } | |
| getLineNumberCell(side: Side): HTMLTableCellElement | undefined { | |
| return this.lineNumberRef(side).value; | |
| } | |
| getContentCell(side: Side) { | |
| return this.contentCellRef(side)?.value; | |
| } | |
| getBlameCell() { | |
| return this.blameCellRef.value; | |
| } | |
| private renderBlameCell() { | |
| // td.blame has `white-space: pre`, so prettier must not add spaces. | |
| // prettier-ignore | |
| return html` | |
| <td | |
| ${ref(this.blameCellRef)} | |
| class=${diffClasses('blame')} | |
| data-line-number=${this.left?.beforeNumber ?? 0} | |
| >${this.renderBlameElement()}</td> | |
| `; | |
| } | |
| private renderBlameElement() { | |
| const lineNum = this.left?.beforeNumber; | |
| const commit = this.blameInfo; | |
| if (!lineNum || !commit) return; | |
| const isStartOfRange = commit.ranges.some(r => r.start === lineNum); | |
| const extras: string[] = []; | |
| if (isStartOfRange) extras.push('startOfRange'); | |
| const date = new Date(commit.time * 1000).toLocaleDateString(); | |
| const shortName = commit.author.split(' ')[0]; | |
| const url = `${getBaseUrl()}/q/${commit.id}`; | |
| // td.blame has `white-space: pre`, so prettier must not add spaces. | |
| // prettier-ignore | |
| return html`<span class=${diffClasses(...extras)} | |
| ><a href=${url} class=${diffClasses('blameDate')}>${date}</a | |
| ><span class=${diffClasses('blameAuthor')}> ${shortName}</span | |
| ><gr-hovercard class=${diffClasses()}> | |
| <span class=${diffClasses('blameHoverCard')}> | |
| Commit ${commit.id}<br /> | |
| Author: ${commit.author}<br /> | |
| Date: ${date}<br /> | |
| <br /> | |
| ${commit.commit_msg} | |
| </span> | |
| </gr-hovercard | |
| ></span>`; | |
| } | |
| private renderLineNumberCell(side: Side): TemplateResult { | |
| const line = this.line(side); | |
| const lineNumber = this.lineNumber(side); | |
| const isBlank = line?.type === GrDiffLineType.BLANK; | |
| if (!line || !lineNumber || isBlank || this.layersApplied) { | |
| const blankClass = isBlank && !this.unifiedDiff ? 'blankLineNum' : ''; | |
| return html`<td | |
| ${ref(this.lineNumberRef(side))} | |
| class=${diffClasses(side, blankClass)} | |
| ></td>`; | |
| } | |
| return html`<td | |
| ${ref(this.lineNumberRef(side))} | |
| class=${diffClasses(side, 'lineNum')} | |
| data-value=${lineNumber} | |
| > | |
| ${this.renderLineNumberButton(line, lineNumber, side)} | |
| </td>`; | |
| } | |
| private renderLineNumberButton( | |
| line: GrDiffLine, | |
| lineNumber: LineNumber, | |
| side: Side | |
| ) { | |
| if (this.hideFileCommentButton && lineNumber === FILE) return; | |
| if (lineNumber === LOST) return; | |
| // .lineNumButton has `white-space: pre`, so prettier must not add spaces. | |
| // prettier-ignore | |
| return html` | |
| <button | |
| id=${this.lineNumberId(side)} | |
| class=${diffClasses('lineNumButton', side)} | |
| tabindex="-1" | |
| data-value=${lineNumber} | |
| aria-label=${ifDefined( | |
| this.computeLineNumberAriaLabel(line, lineNumber) | |
| )} | |
| @click=${() => this.getDiffModel().createCommentOnLine(lineNumber, side)} | |
| @mouseenter=${() => | |
| fire(this, 'line-mouse-enter', {lineNum: lineNumber, side})} | |
| @mouseleave=${() => | |
| fire(this, 'line-mouse-leave', {lineNum: lineNumber, side})} | |
| >${lineNumber === FILE ? 'FILE' : lineNumber.toString()}</button> | |
| `; | |
| } | |
| private computeLineNumberAriaLabel(line: GrDiffLine, lineNumber: LineNumber) { | |
| if (lineNumber === FILE) return 'Add file comment'; | |
| // Add aria-labels for valid line numbers. | |
| // For unified diff, this method will be called with number set to 0 for | |
| // the empty line number column for added/removed lines. This should not | |
| // be announced to the screenreader. | |
| if ( | |
| lineNumber === LOST || | |
| (typeof lineNumber === 'number' && lineNumber <= 0) | |
| ) | |
| return undefined; | |
| switch (line.type) { | |
| case GrDiffLineType.REMOVE: | |
| return `${lineNumber} removed`; | |
| case GrDiffLineType.ADD: | |
| return `${lineNumber} added`; | |
| case GrDiffLineType.BOTH: | |
| case GrDiffLineType.BLANK: | |
| return `${lineNumber} unmodified`; | |
| } | |
| } | |
| private renderContentCell(side: Side) { | |
| let line = this.line(side); | |
| if (this.unifiedDiff) { | |
| if (side === Side.LEFT) return nothing; | |
| if (line?.type === GrDiffLineType.BLANK) { | |
| side = Side.LEFT; | |
| line = this.line(Side.LEFT); | |
| } | |
| } | |
| const lineNumber = this.lineNumber(side); | |
| assertIsDefined(line, 'line'); | |
| const extras: string[] = [line.type, side]; | |
| if (line.type !== GrDiffLineType.BLANK) extras.push('content'); | |
| if (!line.hasIntralineInfo) extras.push('no-intraline-info'); | |
| if (line.beforeNumber === FILE) extras.push('file'); | |
| if (line.beforeNumber === LOST) extras.push('lost'); | |
| // .content has `white-space: pre`, so prettier must not add spaces. | |
| // prettier-ignore | |
| return html` | |
| <td | |
| ${ref(this.contentCellRef(side))} | |
| class=${diffClasses(...extras)} | |
| @click=${() => { | |
| if (lineNumber) { | |
| this.getDiffModel().selectLine(lineNumber, side); | |
| } | |
| }} | |
| @mouseenter=${() => { | |
| if (lineNumber) | |
| fire(this, 'line-mouse-enter', {lineNum: lineNumber, side}); | |
| }} | |
| @mouseleave=${() => { | |
| if (lineNumber) | |
| fire(this, 'line-mouse-leave', {lineNum: lineNumber, side}); | |
| }} | |
| >${this.renderText(side)}${this.renderLostMessage(side)}${this.renderThreadGroup(side)}</td> | |
| `; | |
| } | |
| private renderSignCell(side: Side) { | |
| if (this.unifiedDiff) return nothing; | |
| const line = this.line(side); | |
| assertIsDefined(line, 'line'); | |
| const isBlank = line.type === GrDiffLineType.BLANK; | |
| const isAdd = line.type === GrDiffLineType.ADD && side === Side.RIGHT; | |
| const isRemove = line.type === GrDiffLineType.REMOVE && side === Side.LEFT; | |
| const extras: string[] = ['sign', side]; | |
| if (isBlank) extras.push('blank'); | |
| if (isAdd) extras.push('add'); | |
| if (isRemove) extras.push('remove'); | |
| if (!line.hasIntralineInfo) extras.push('no-intraline-info'); | |
| const sign = isAdd ? '+' : isRemove ? '-' : ''; | |
| return html`<td class=${diffClasses(...extras)}>${sign}</td>`; | |
| } | |
| private renderLostMessage(side: Side) { | |
| if (this.lineNumber(side) !== LOST) return nothing; | |
| if (this.getComments(side).length === 0) return nothing; | |
| // .content has `white-space: pre`, so prettier must not add spaces. | |
| // prettier-ignore | |
| return html`<div class="lost-message" | |
| ><gr-icon icon="info"></gr-icon | |
| ><span>Original comment position not found in this patchset</span | |
| ></div>`; | |
| } | |
| private renderThreadGroup(side: Side) { | |
| if (!this.lineNumber(side)) return nothing; | |
| if ( | |
| this.getComments(side).length === 0 && | |
| (!this.unifiedDiff || this.getComments(otherSide(side)).length === 0) | |
| ) { | |
| return nothing; | |
| } | |
| return html`<div class="thread-group" data-side=${side}> | |
| ${this.renderSlot(side)} | |
| ${when(this.unifiedDiff, () => this.renderSlot(otherSide(side)))} | |
| </div>`; | |
| } | |
| private renderSlot(side: Side) { | |
| const line = this.lineNumber(side); | |
| if (!line) return nothing; | |
| if (this.getComments(side).length === 0) return nothing; | |
| return html` | |
| ${this.renderRangedCommentHints(side)} | |
| <slot name="${side}-${line}"></slot> | |
| `; | |
| } | |
| private renderRangedCommentHints(side: Side) { | |
| const ranges = this.getComments(side) | |
| .map(c => c.range) | |
| .filter(isDefined) | |
| .filter(isLongCommentRange); | |
| return ranges.map( | |
| range => | |
| html` | |
| <gr-ranged-comment-hint .range=${range}></gr-ranged-comment-hint> | |
| ` | |
| ); | |
| } | |
| private contentRef(side: Side) { | |
| return side === Side.LEFT ? this.contentLeftRef : this.contentRightRef; | |
| } | |
| private contentCellRef(side: Side) { | |
| return side === Side.LEFT | |
| ? this.contentCellLeftRef | |
| : this.contentCellRightRef; | |
| } | |
| private lineNumberRef(side: Side) { | |
| return side === Side.LEFT | |
| ? this.lineNumberLeftRef | |
| : this.lineNumberRightRef; | |
| } | |
| lineNumber(side: Side) { | |
| return this.line(side)?.lineNumber(side); | |
| } | |
| line(side: Side) { | |
| return side === Side.LEFT ? this.left : this.right; | |
| } | |
| private getComments(side: Side) { | |
| return side === Side.LEFT ? this.leftComments : this.rightComments; | |
| } | |
| private getType(side?: Side): string | undefined { | |
| if (this.unifiedDiff) return undefined; | |
| if (side === Side.LEFT) return this.left?.type; | |
| if (side === Side.RIGHT) return this.right?.type; | |
| return undefined; | |
| } | |
| private unifiedType() { | |
| return this.left?.type === GrDiffLineType.BLANK | |
| ? this.right?.type | |
| : this.left?.type; | |
| } | |
| /** | |
| * Returns a 'div' element containing the supplied |text| as its innerText, | |
| * with '\t' characters expanded to a width determined by |tabSize|, and the | |
| * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is | |
| * desired. | |
| */ | |
| private renderText(side: Side) { | |
| const line = this.line(side); | |
| const lineNumber = this.lineNumber(side); | |
| if (typeof lineNumber !== 'number') return; | |
| // Note that `this.layersApplied` will wipe away the <gr-diff-text>, and | |
| // another rendering cycle will be initiated in `updated()`. | |
| // prettier-ignore | |
| const textElement = line?.text && !this.layersApplied | |
| ? html`<gr-diff-text | |
| ${ref(this.contentRef(side))} | |
| .text=${line?.text} | |
| .tabSize=${this.tabSize} | |
| .lineLimit=${this.lineLength} | |
| .isResponsive=${isResponsive(this.responsiveMode)} | |
| ></gr-diff-text>` : ''; | |
| // .content has `white-space: pre`, so prettier must not add spaces. | |
| // prettier-ignore | |
| return html`<div | |
| class=${diffClasses('contentText')} | |
| data-side=${ifDefined(side)} | |
| id=${this.contentId(side)} | |
| >${textElement}</div>`; | |
| } | |
| private renderPostLineSlot(side: Side) { | |
| const lineNumber = this.lineNumber(side); | |
| return lineNumber && Number.isInteger(lineNumber) | |
| ? html`<slot name="post-${side}-line-${lineNumber}"></slot>` | |
| : nothing; | |
| } | |
| } | |
| // TODO(newdiff-cleanup): Remove once newdiff migration is completed. | |
| if (isNewDiff()) { | |
| customElements.define('gr-diff-row', GrDiffRow); | |
| } | |
| declare global { | |
| interface HTMLElementTagNameMap { | |
| // TODO(newdiff-cleanup): Replace once newdiff migration is completed. | |
| 'gr-diff-row': LitElement; | |
| } | |
| } |