|  | /** | 
|  | * @license | 
|  | * Copyright 2022 Google LLC | 
|  | * SPDX-License-Identifier: Apache-2.0 | 
|  | */ | 
|  | import {html, LitElement, nothing, TemplateResult} from 'lit'; | 
|  | import {customElement, 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, | 
|  | } 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 './gr-diff-text'; | 
|  | import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line'; | 
|  | import {diffClasses, isResponsive} from '../gr-diff/gr-diff-utils'; | 
|  |  | 
|  | @customElement('gr-diff-row') | 
|  | 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; | 
|  |  | 
|  | @property({type: Object}) | 
|  | right?: GrDiffLine; | 
|  |  | 
|  | @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; | 
|  |  | 
|  | /** | 
|  | * 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; | 
|  |  | 
|  | /** | 
|  | * 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> | 
|  | `; | 
|  | 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) | 
|  | )} | 
|  | @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 <= 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)} | 
|  | @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.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 renderThreadGroup(side: Side) { | 
|  | const lineNumber = this.lineNumber(side); | 
|  | if (!lineNumber) return nothing; | 
|  | return html`<div class="thread-group" data-side=${side}> | 
|  | <slot name="${side}-${lineNumber}"></slot> | 
|  | ${this.renderSecondSlot()} | 
|  | </div>`; | 
|  | } | 
|  |  | 
|  | private renderSecondSlot() { | 
|  | if (!this.unifiedDiff) return nothing; | 
|  | if (this.line(Side.LEFT)?.type !== GrDiffLineType.BOTH) return nothing; | 
|  | return html`<slot | 
|  | name="${Side.LEFT}-${this.lineNumber(Side.LEFT)}" | 
|  | ></slot>`; | 
|  | } | 
|  |  | 
|  | 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; | 
|  | } | 
|  |  | 
|  | private lineNumber(side: Side) { | 
|  | return this.line(side)?.lineNumber(side); | 
|  | } | 
|  |  | 
|  | private line(side: Side) { | 
|  | return side === Side.LEFT ? this.left : this.right; | 
|  | } | 
|  |  | 
|  | 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 (lineNumber === 'FILE' || lineNumber === 'LOST') 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>`; | 
|  | } | 
|  | } | 
|  |  | 
|  | declare global { | 
|  | interface HTMLElementTagNameMap { | 
|  | 'gr-diff-row': GrDiffRow; | 
|  | } | 
|  | } |