| /** | |
| * @license | |
| * Copyright 2016 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import {RenderPreferences} from '../../../api/diff'; | |
| import {fire} from '../../../utils/event-util'; | |
| import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line'; | |
| import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group'; | |
| import '../gr-context-controls/gr-context-controls'; | |
| import { | |
| GrContextControls, | |
| GrContextControlsShowConfig, | |
| } from '../gr-context-controls/gr-context-controls'; | |
| import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff'; | |
| import {DiffViewMode, Side} from '../../../constants/constants'; | |
| import {DiffLayer} from '../../../types/types'; | |
| import { | |
| createBlameElement, | |
| createElementDiff, | |
| createElementDiffWithText, | |
| formatText, | |
| getResponsiveMode, | |
| } from '../gr-diff/gr-diff-utils'; | |
| import {GrDiffBuilder} from './gr-diff-builder'; | |
| import {BlameInfo} from '../../../types/common'; | |
| function lineTdSelector(lineNumber: LineNumber, side?: Side): string { | |
| const sideSelector = side ? `.${side}` : ''; | |
| return `td.lineNum[data-value="${lineNumber}"]${sideSelector}`; | |
| } | |
| /** | |
| * Base class for builders that are creating the DOM elements programmatically | |
| * by calling `document.createElement()` and such. We are calling such builders | |
| * "legacy", because we want to create (Lit) component based diff elements. | |
| * | |
| * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces. | |
| */ | |
| export abstract class GrDiffBuilderLegacy extends GrDiffBuilder { | |
| constructor( | |
| diff: DiffInfo, | |
| prefs: DiffPreferencesInfo, | |
| outputEl: HTMLElement, | |
| layers: DiffLayer[] = [], | |
| renderPrefs?: RenderPreferences | |
| ) { | |
| super(diff, prefs, outputEl, layers, renderPrefs); | |
| } | |
| override getContentTdByLine( | |
| lineNumber: LineNumber, | |
| side?: Side, | |
| root: Element = this.outputEl | |
| ): HTMLTableCellElement | null { | |
| return root.querySelector<HTMLTableCellElement>( | |
| `${lineTdSelector(lineNumber, side)} ~ td.content` | |
| ); | |
| } | |
| override getLineElByNumber( | |
| lineNumber: LineNumber, | |
| side?: Side | |
| ): HTMLTableCellElement | null { | |
| return this.outputEl.querySelector<HTMLTableCellElement>( | |
| lineTdSelector(lineNumber, side) | |
| ); | |
| } | |
| override getLineNumberRows() { | |
| return Array.from( | |
| this.outputEl.querySelectorAll<HTMLTableRowElement>( | |
| ':not(.contextControl) > .diff-row' | |
| ) ?? [] | |
| ).filter(tr => tr.querySelector('button')); | |
| } | |
| override getLineNumEls(side: Side): HTMLTableCellElement[] { | |
| return Array.from( | |
| this.outputEl.querySelectorAll<HTMLTableCellElement>( | |
| `td.lineNum.${side}` | |
| ) ?? [] | |
| ); | |
| } | |
| override getBlameTdByLine(lineNum: number): Element | undefined { | |
| return ( | |
| this.outputEl.querySelector(`td.blame[data-line-number="${lineNum}"]`) ?? | |
| undefined | |
| ); | |
| } | |
| override getContentByLine( | |
| lineNumber: LineNumber, | |
| side?: Side, | |
| root?: HTMLElement | |
| ): HTMLElement | null { | |
| const td = this.getContentTdByLine(lineNumber, side, root); | |
| return td ? td.querySelector('.contentText') : null; | |
| } | |
| override renderContentByRange( | |
| start: LineNumber, | |
| end: LineNumber, | |
| side: Side | |
| ) { | |
| const lines: GrDiffLine[] = []; | |
| const elements: HTMLElement[] = []; | |
| let line; | |
| let el; | |
| this.findLinesByRange(start, end, side, lines, elements); | |
| for (let i = 0; i < lines.length; i++) { | |
| line = lines[i]; | |
| el = elements[i]; | |
| if (!el || !el.parentElement) { | |
| // Cannot re-render an element if it does not exist. This can happen | |
| // if lines are collapsed and not visible on the page yet. | |
| continue; | |
| } | |
| const lineNumberEl = this.getLineNumberEl(el, side); | |
| const newContent = this.createTextEl(lineNumberEl, line, side) | |
| .firstChild as HTMLElement; | |
| // Note that ${el.id} ${newContent.id} might actually mismatch: In unified | |
| // diff we are rendering the same content twice for all the diff chunk | |
| // that are unchanged from left to right. TODO: Be smarter about this. | |
| el.parentElement.replaceChild(newContent, el); | |
| } | |
| } | |
| override renderBlameByRange(blame: BlameInfo, start: number, end: number) { | |
| for (let i = start; i <= end; i++) { | |
| // TODO(wyatta): this query is expensive, but, when traversing a | |
| // range, the lines are consecutive, and given the previous blame | |
| // cell, the next one can be reached cheaply. | |
| const blameCell = this.getBlameTdByLine(i); | |
| if (!blameCell) continue; | |
| // Remove the element's children (if any). | |
| while (blameCell.hasChildNodes()) { | |
| blameCell.removeChild(blameCell.lastChild!); | |
| } | |
| const blameEl = createBlameElement(i, blame); | |
| if (blameEl) blameCell.appendChild(blameEl); | |
| } | |
| } | |
| /** | |
| * Finds the line number element given the content element by walking up the | |
| * DOM tree to the diff row and then querying for a .lineNum element on the | |
| * requested side. | |
| * | |
| * TODO(brohlfs): Consolidate this with getLineEl... methods in html file. | |
| */ | |
| // visible for testing | |
| getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null { | |
| let row: HTMLElement | null = content; | |
| while (row && !row.classList.contains('diff-row')) row = row.parentElement; | |
| return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null; | |
| } | |
| /** | |
| * Adds <tr> table rows to a <tbody> section for allowing the user to expand | |
| * collapsed of lines. Called by subclasses. | |
| */ | |
| protected createContextControls( | |
| section: HTMLElement, | |
| group: GrDiffGroup, | |
| viewMode: DiffViewMode | |
| ) { | |
| const leftStart = group.lineRange.left.start_line; | |
| const leftEnd = group.lineRange.left.end_line; | |
| const firstGroupIsSkipped = !!group.contextGroups[0].skip; | |
| const lastGroupIsSkipped = | |
| !!group.contextGroups[group.contextGroups.length - 1].skip; | |
| const containsWholeFile = this.numLinesLeft === leftEnd - leftStart + 1; | |
| const showAbove = | |
| (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile; | |
| const showBelow = leftEnd < this.numLinesLeft && !lastGroupIsSkipped; | |
| if (showAbove) { | |
| const paddingRow = this.createContextControlPaddingRow(viewMode); | |
| paddingRow.classList.add('above'); | |
| section.appendChild(paddingRow); | |
| } | |
| section.appendChild( | |
| this.createContextControlRow(group, showAbove, showBelow, viewMode) | |
| ); | |
| if (showBelow) { | |
| const paddingRow = this.createContextControlPaddingRow(viewMode); | |
| paddingRow.classList.add('below'); | |
| section.appendChild(paddingRow); | |
| } | |
| } | |
| /** | |
| * Creates a context control <tr> table row for with buttons the allow the | |
| * user to expand collapsed lines. Buttons extend from the gap created by this | |
| * method up or down into the area of code that they affect. | |
| */ | |
| private createContextControlRow( | |
| group: GrDiffGroup, | |
| showAbove: boolean, | |
| showBelow: boolean, | |
| viewMode: DiffViewMode | |
| ): HTMLElement { | |
| const row = createElementDiff('tr', 'dividerRow'); | |
| let showConfig: GrContextControlsShowConfig; | |
| if (showAbove && !showBelow) { | |
| showConfig = 'above'; | |
| } else if (!showAbove && showBelow) { | |
| showConfig = 'below'; | |
| } else { | |
| // Note that !showAbove && !showBelow also intentionally creates | |
| // "show-both". This means the file is completely collapsed, which is | |
| // unusual, but at least happens in one test. | |
| showConfig = 'both'; | |
| } | |
| row.classList.add(`show-${showConfig}`); | |
| row.appendChild(this.createBlameCell(0)); | |
| if (viewMode === DiffViewMode.SIDE_BY_SIDE) { | |
| row.appendChild(createElementDiff('td')); | |
| } | |
| const cell = createElementDiff('td', 'dividerCell'); | |
| // Note that <td> table cells that have `display: none` don't count! | |
| const colspan = this.renderPrefs?.show_sign_col ? '5' : '3'; | |
| cell.setAttribute('colspan', colspan); | |
| row.appendChild(cell); | |
| const contextControls = createElementDiff( | |
| 'gr-context-controls' | |
| ) as GrContextControls; | |
| contextControls.diff = this._diff; | |
| contextControls.renderPreferences = this.renderPrefs; | |
| contextControls.group = group; | |
| contextControls.showConfig = showConfig; | |
| cell.appendChild(contextControls); | |
| return row; | |
| } | |
| /** | |
| * Creates a table row to serve as padding between code and context controls. | |
| * Blame column, line gutters, and content area will continue visually, but | |
| * context controls can render over this background to map more clearly to | |
| * the area of code they expand. | |
| */ | |
| private createContextControlPaddingRow(viewMode: DiffViewMode) { | |
| const row = createElementDiff('tr', 'contextBackground'); | |
| if (viewMode === DiffViewMode.SIDE_BY_SIDE) { | |
| row.classList.add('side-by-side'); | |
| row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL); | |
| row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL); | |
| } else { | |
| row.classList.add('unified'); | |
| } | |
| row.appendChild(this.createBlameCell(0)); | |
| row.appendChild(createElementDiff('td', 'contextLineNum')); | |
| if (viewMode === DiffViewMode.SIDE_BY_SIDE) { | |
| row.appendChild(createElementDiff('td', 'sign')); | |
| row.appendChild(createElementDiff('td')); | |
| } | |
| row.appendChild(createElementDiff('td', 'contextLineNum')); | |
| if (viewMode === DiffViewMode.SIDE_BY_SIDE) { | |
| row.appendChild(createElementDiff('td', 'sign')); | |
| } | |
| row.appendChild(createElementDiff('td')); | |
| return row; | |
| } | |
| protected createLineEl( | |
| line: GrDiffLine, | |
| number: LineNumber, | |
| type: GrDiffLineType, | |
| side: Side | |
| ) { | |
| const td = createElementDiff('td'); | |
| td.classList.add(side); | |
| if (line.type === GrDiffLineType.BLANK) { | |
| td.classList.add('blankLineNum'); | |
| return td; | |
| } | |
| if (line.type === GrDiffLineType.BOTH || line.type === type) { | |
| td.classList.add('lineNum'); | |
| td.dataset['value'] = number.toString(); | |
| if ( | |
| ((this._prefs.show_file_comment_button === false || | |
| this.renderPrefs?.show_file_comment_button === false) && | |
| number === 'FILE') || | |
| number === 'LOST' | |
| ) { | |
| return td; | |
| } | |
| const button = createElementDiff('button'); | |
| td.appendChild(button); | |
| button.tabIndex = -1; | |
| button.classList.add('lineNumButton'); | |
| button.classList.add(side); | |
| button.dataset['value'] = number.toString(); | |
| button.id = | |
| side === Side.LEFT ? `left-button-${number}` : `right-button-${number}`; | |
| button.textContent = number === 'FILE' ? 'File' : number.toString(); | |
| if (number === 'FILE') { | |
| button.setAttribute('aria-label', '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 (number !== 'FILE' && number > 0) { | |
| if (line.type === GrDiffLineType.REMOVE) { | |
| button.setAttribute('aria-label', `${number} removed`); | |
| } else if (line.type === GrDiffLineType.ADD) { | |
| button.setAttribute('aria-label', `${number} added`); | |
| } else { | |
| button.setAttribute('aria-label', `${number} unmodified`); | |
| } | |
| } | |
| this.addLineNumberMouseEvents(td, number, side); | |
| } | |
| return td; | |
| } | |
| private addLineNumberMouseEvents( | |
| el: HTMLElement, | |
| number: LineNumber, | |
| side: Side | |
| ) { | |
| el.addEventListener('mouseenter', () => { | |
| fire(el, 'line-mouse-enter', {lineNum: number, side}); | |
| }); | |
| el.addEventListener('mouseleave', () => { | |
| fire(el, 'line-mouse-leave', {lineNum: number, side}); | |
| }); | |
| } | |
| // visible for testing | |
| createTextEl( | |
| lineNumberEl: HTMLElement | null, | |
| line: GrDiffLine, | |
| side?: Side, | |
| twoSlots?: boolean | |
| ) { | |
| const td = createElementDiff('td'); | |
| if (line.type !== GrDiffLineType.BLANK) { | |
| td.classList.add('content'); | |
| } | |
| if (side) { | |
| td.classList.add(side); | |
| } | |
| // If intraline info is not available, the entire line will be | |
| // considered as changed and marked as dark red / green color | |
| if (!line.hasIntralineInfo) { | |
| td.classList.add('no-intraline-info'); | |
| } | |
| td.classList.add(line.type); | |
| const lineNumber = side ? line.lineNumber(side) : 0; | |
| if (lineNumber === 'FILE') { | |
| td.classList.add('file'); | |
| } else if (lineNumber === 'LOST') { | |
| td.classList.add('lost'); | |
| } else { | |
| const responsiveMode = getResponsiveMode(this._prefs, this.renderPrefs); | |
| const contentId = | |
| side && lineNumber > 0 ? `${side}-content-${lineNumber}` : ''; | |
| const contentText = formatText( | |
| line.text, | |
| responsiveMode, | |
| this._prefs.tab_size, | |
| this._prefs.line_length, | |
| contentId | |
| ); | |
| if (side) { | |
| contentText.setAttribute('data-side', side); | |
| this.addLineNumberMouseEvents(td, lineNumber, side); | |
| } | |
| if (lineNumberEl && side) { | |
| for (const layer of this.layers) { | |
| if (typeof layer.annotate === 'function') { | |
| layer.annotate(contentText, lineNumberEl, line, side); | |
| } | |
| } | |
| } else { | |
| console.error('lineNumberEl or side not set, skipping layer.annotate'); | |
| } | |
| td.appendChild(contentText); | |
| } | |
| if (side && lineNumber) { | |
| const threadGroupEl = document.createElement('div'); | |
| threadGroupEl.className = 'thread-group'; | |
| threadGroupEl.setAttribute('data-side', side); | |
| const slot = document.createElement('slot'); | |
| slot.name = `${side}-${lineNumber}`; | |
| threadGroupEl.appendChild(slot); | |
| // For line.type === BOTH in unified diff we want two slots. | |
| if (twoSlots) { | |
| const slot = document.createElement('slot'); | |
| const otherSide = side === Side.LEFT ? Side.RIGHT : Side.LEFT; | |
| slot.name = `${otherSide}-${line.lineNumber(otherSide)}`; | |
| threadGroupEl.appendChild(slot); | |
| } | |
| td.appendChild(threadGroupEl); | |
| } | |
| return td; | |
| } | |
| private createMovedLineAnchor(line: number, side: Side) { | |
| const anchor = createElementDiffWithText('a', `${line}`); | |
| // href is not actually used but important for Screen Readers | |
| anchor.setAttribute('href', `#${line}`); | |
| anchor.addEventListener('click', e => { | |
| e.preventDefault(); | |
| fire(anchor, 'moved-link-clicked', { | |
| lineNum: line, | |
| side, | |
| }); | |
| }); | |
| return anchor; | |
| } | |
| private createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) { | |
| const div = createElementDiff('div'); | |
| if (group.moveDetails?.range) { | |
| const {changed, range} = group.moveDetails; | |
| const otherSide = movedIn ? Side.LEFT : Side.RIGHT; | |
| const andChangedLabel = changed ? 'and changed ' : ''; | |
| const direction = movedIn ? 'from' : 'to'; | |
| const textLabel = `Moved ${andChangedLabel}${direction} lines `; | |
| div.appendChild(createElementDiffWithText('span', textLabel)); | |
| div.appendChild(this.createMovedLineAnchor(range.start, otherSide)); | |
| div.appendChild(createElementDiffWithText('span', ' - ')); | |
| div.appendChild(this.createMovedLineAnchor(range.end, otherSide)); | |
| } else { | |
| div.appendChild( | |
| createElementDiffWithText('span', movedIn ? 'Moved in' : 'Moved out') | |
| ); | |
| } | |
| return div; | |
| } | |
| protected buildMoveControls(group: GrDiffGroup) { | |
| const movedIn = group.adds.length > 0; | |
| const { | |
| numberOfCells, | |
| movedOutIndex, | |
| movedInIndex, | |
| lineNumberCols, | |
| signCols, | |
| } = this.getMoveControlsConfig(); | |
| let controlsClass; | |
| let descriptionIndex; | |
| const descriptionTextDiv = this.createMoveDescriptionDiv(movedIn, group); | |
| if (movedIn) { | |
| controlsClass = 'movedIn'; | |
| descriptionIndex = movedInIndex; | |
| } else { | |
| controlsClass = 'movedOut'; | |
| descriptionIndex = movedOutIndex; | |
| } | |
| const controls = createElementDiff('tr', `moveControls ${controlsClass}`); | |
| const cells = [...Array(numberOfCells).keys()].map(() => | |
| createElementDiff('td') | |
| ); | |
| lineNumberCols.forEach(index => { | |
| cells[index].classList.add('moveControlsLineNumCol'); | |
| }); | |
| if (signCols) { | |
| cells[signCols.left].classList.add('sign', 'left'); | |
| cells[signCols.right].classList.add('sign', 'right'); | |
| } | |
| const moveRangeHeader = createElementDiff('gr-range-header'); | |
| moveRangeHeader.setAttribute('icon', 'move_item'); | |
| moveRangeHeader.appendChild(descriptionTextDiv); | |
| cells[descriptionIndex].classList.add('moveHeader'); | |
| cells[descriptionIndex].appendChild(moveRangeHeader); | |
| cells.forEach(c => { | |
| controls.appendChild(c); | |
| }); | |
| return controls; | |
| } | |
| /** | |
| * Create a blame cell for the given base line. Blame information will be | |
| * included in the cell if available. | |
| */ | |
| // visible for testing | |
| createBlameCell(lineNumber: LineNumber): HTMLTableCellElement { | |
| const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement; | |
| blameTd.setAttribute('data-line-number', lineNumber.toString()); | |
| if (!lineNumber) return blameTd; | |
| const blameInfo = this.getBlameCommitForBaseLine(lineNumber); | |
| if (!blameInfo) return blameTd; | |
| blameTd.appendChild(createBlameElement(lineNumber, blameInfo)); | |
| return blameTd; | |
| } | |
| } |