| // Copyright 2019 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| /* eslint-disable @devtools/no-lit-render-outside-of-view, @devtools/enforce-custom-element-definitions-location */ |
| |
| import * as i18n from '../../../core/i18n/i18n.js'; |
| import * as Diff from '../../../third_party/diff/diff.js'; |
| import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; |
| import * as Lit from '../../lit/lit.js'; |
| import * as CodeHighlighter from '../code_highlighter/code_highlighter.js'; |
| |
| import diffViewStyles from './diffView.css.js'; |
| |
| const {html} = Lit; |
| |
| const UIStrings = { |
| /** |
| * @description Text prepended to a removed line in a diff in the Changes tool, viewable only by screen reader. |
| */ |
| deletions: 'Deletion:', |
| /** |
| * @description Text prepended to a new line in a diff in the Changes tool, viewable only by screen reader. |
| */ |
| additions: 'Addition:', |
| /** |
| * @description Screen-reader accessible name for the code editor in the Changes tool showing the user's changes. |
| */ |
| changesDiffViewer: 'Changes diff viewer', |
| /** |
| * @description Text in Changes View of the Changes tab |
| * @example {2} PH1 |
| */ |
| SkippingDMatchingLines: '( … Skipping {PH1} matching lines … )', |
| /** |
| * @description Text in Changes View for the case where the modified file contents are the same with its unmodified state |
| * e.g. the file contents changed from A -> B then B -> A and not saved yet. |
| */ |
| noDiff: 'File is identical to its unmodified state', |
| } as const; |
| const str_ = i18n.i18n.registerUIStrings('ui/components/diff_view/DiffView.ts', UIStrings); |
| const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); |
| |
| interface Token { |
| text: string; |
| className: string; |
| } |
| |
| interface Row { |
| originalLineNumber: number; |
| currentLineNumber: number; |
| tokens: Token[]; |
| type: RowType; |
| } |
| |
| export const enum RowType { |
| DELETION = 'deletion', |
| ADDITION = 'addition', |
| EQUAL = 'equal', |
| SPACER = 'spacer', |
| } |
| |
| export function buildDiffRows(diff: Diff.Diff.DiffArray): { |
| originalLines: readonly string[], |
| currentLines: readonly string[], |
| rows: readonly Row[], |
| } { |
| let currentLineNumber = 0; |
| let originalLineNumber = 0; |
| const paddingLines = 3; |
| |
| const originalLines: string[] = []; |
| const currentLines: string[] = []; |
| const rows: Row[] = []; |
| |
| for (let i = 0; i < diff.length; ++i) { |
| const token = diff[i]; |
| switch (token[0]) { |
| case Diff.Diff.Operation.Equal: |
| rows.push(...createEqualRows(token[1], i === 0, i === diff.length - 1)); |
| originalLines.push(...token[1]); |
| currentLines.push(...token[1]); |
| break; |
| case Diff.Diff.Operation.Insert: |
| for (const line of token[1]) { |
| rows.push(createRow(line, RowType.ADDITION)); |
| } |
| currentLines.push(...token[1]); |
| break; |
| case Diff.Diff.Operation.Delete: |
| originalLines.push(...token[1]); |
| if (diff[i + 1] && diff[i + 1][0] === Diff.Diff.Operation.Insert) { |
| i++; |
| rows.push(...createModifyRows(token[1].join('\n'), diff[i][1].join('\n'))); |
| currentLines.push(...diff[i][1]); |
| } else { |
| for (const line of token[1]) { |
| rows.push(createRow(line, RowType.DELETION)); |
| } |
| } |
| break; |
| } |
| } |
| |
| return {originalLines, currentLines, rows}; |
| |
| function createEqualRows(lines: string[], atStart: boolean, atEnd: boolean): Row[] { |
| const equalRows = []; |
| if (!atStart) { |
| for (let i = 0; i < paddingLines && i < lines.length; i++) { |
| equalRows.push(createRow(lines[i], RowType.EQUAL)); |
| } |
| if (lines.length > paddingLines * 2 + 1 && !atEnd) { |
| equalRows.push(createRow( |
| i18nString(UIStrings.SkippingDMatchingLines, {PH1: (lines.length - paddingLines * 2)}), RowType.SPACER)); |
| } |
| } |
| if (!atEnd) { |
| const start = Math.max(lines.length - paddingLines - 1, atStart ? 0 : paddingLines); |
| let skip = lines.length - paddingLines - 1; |
| if (!atStart) { |
| skip -= paddingLines; |
| } |
| if (skip > 0) { |
| originalLineNumber += skip; |
| currentLineNumber += skip; |
| } |
| |
| for (let i = start; i < lines.length; i++) { |
| equalRows.push(createRow(lines[i], RowType.EQUAL)); |
| } |
| } |
| return equalRows; |
| } |
| |
| function createModifyRows(before: string, after: string): Row[] { |
| const internalDiff = Diff.Diff.DiffWrapper.charDiff(before, after, true /* cleanup diff */); |
| const deletionRows = [createRow('', RowType.DELETION)]; |
| const insertionRows = [createRow('', RowType.ADDITION)]; |
| |
| for (const token of internalDiff) { |
| const text = token[1]; |
| const type = token[0]; |
| const className = type === Diff.Diff.Operation.Equal ? '' : 'inner-diff'; |
| const lines = text.split('\n'); |
| for (let i = 0; i < lines.length; i++) { |
| if (i > 0 && type !== Diff.Diff.Operation.Insert) { |
| deletionRows.push(createRow('', RowType.DELETION)); |
| } |
| if (i > 0 && type !== Diff.Diff.Operation.Delete) { |
| insertionRows.push(createRow('', RowType.ADDITION)); |
| } |
| if (!lines[i]) { |
| continue; |
| } |
| if (type !== Diff.Diff.Operation.Insert) { |
| deletionRows[deletionRows.length - 1].tokens.push({text: lines[i], className}); |
| } |
| if (type !== Diff.Diff.Operation.Delete) { |
| insertionRows[insertionRows.length - 1].tokens.push({text: lines[i], className}); |
| } |
| } |
| } |
| return deletionRows.concat(insertionRows); |
| } |
| |
| function createRow(text: string, type: RowType): Row { |
| if (type === RowType.ADDITION) { |
| currentLineNumber++; |
| } |
| if (type === RowType.DELETION) { |
| originalLineNumber++; |
| } |
| if (type === RowType.EQUAL) { |
| originalLineNumber++; |
| currentLineNumber++; |
| } |
| |
| return {originalLineNumber, currentLineNumber, tokens: text ? [{text, className: 'inner-diff'}] : [], type}; |
| } |
| } |
| |
| function documentMap(lines: readonly string[]): Map<number, number> { |
| const map = new Map<number, number>(); |
| for (let pos = 0, lineNo = 0; lineNo < lines.length; lineNo++) { |
| map.set(lineNo + 1, pos); |
| pos += lines[lineNo].length + 1; |
| } |
| return map; |
| } |
| |
| class DiffRenderer { |
| private constructor( |
| readonly originalHighlighter: CodeHighlighter.CodeHighlighter.CodeHighlighter, |
| readonly originalMap: Map<number, number>, |
| readonly currentHighlighter: CodeHighlighter.CodeHighlighter.CodeHighlighter, |
| readonly currentMap: Map<number, number>, |
| ) { |
| } |
| |
| #render(rows: readonly Row[]): Lit.TemplateResult { |
| return html` |
| <style>${diffViewStyles}</style> |
| <style>${CodeHighlighter.codeHighlighterStyles}</style> |
| <div class="diff-listing" aria-label=${i18nString(UIStrings.changesDiffViewer)}> |
| ${rows.map(row => this.#renderRow(row))} |
| </div>`; |
| } |
| |
| #renderRow(row: Row): Lit.TemplateResult { |
| const baseNumber = |
| row.type === RowType.EQUAL || row.type === RowType.DELETION ? String(row.originalLineNumber) : ''; |
| const curNumber = row.type === RowType.EQUAL || row.type === RowType.ADDITION ? String(row.currentLineNumber) : ''; |
| let marker = '', markerClass = 'diff-line-marker', screenReaderText = null; |
| if (row.type === RowType.ADDITION) { |
| marker = '+'; |
| markerClass += ' diff-line-addition'; |
| screenReaderText = html`<span class="diff-hidden-text">${i18nString(UIStrings.additions)}</span>`; |
| } else if (row.type === RowType.DELETION) { |
| marker = '-'; |
| markerClass += ' diff-line-deletion'; |
| screenReaderText = html`<span class="diff-hidden-text">${i18nString(UIStrings.deletions)}</span>`; |
| } |
| return html` |
| <div class="diff-line-number" aria-hidden="true">${baseNumber}</div> |
| <div class="diff-line-number" aria-hidden="true">${curNumber}</div> |
| <div class=${markerClass} aria-hidden="true">${marker}</div> |
| <div class="diff-line-content diff-line-${row.type}" data-line-number=${curNumber} jslog=${ |
| VisualLogging.link('changes.reveal-source').track({click: true})}>${screenReaderText}${ |
| this.#renderRowContent(row)}</div>`; |
| } |
| |
| #renderRowContent(row: Row): Lit.TemplateResult[] { |
| if (row.type === RowType.SPACER) { |
| return row.tokens.map(tok => html`${tok.text}`); |
| } |
| const [doc, startPos] = row.type === RowType.DELETION ? |
| [this.originalHighlighter, this.originalMap.get(row.originalLineNumber) as number] : |
| [this.currentHighlighter, this.currentMap.get(row.currentLineNumber) as number]; |
| const content: Lit.TemplateResult[] = []; |
| let pos = startPos; |
| for (const token of row.tokens) { |
| const tokenContent: Array<Lit.TemplateResult|string> = []; |
| doc.highlightRange(pos, pos + token.text.length, (text, style) => { |
| tokenContent.push(style ? html`<span class=${style}>${text}</span>` : text); |
| }); |
| content.push( |
| token.className ? html`<span class=${token.className}>${tokenContent}</span>` : html`${tokenContent}`); |
| pos += token.text.length; |
| } |
| return content; |
| } |
| |
| static async render(diff: Diff.Diff.DiffArray, mimeType: string, parent: HTMLElement|DocumentFragment): |
| Promise<void> { |
| const {originalLines, currentLines, rows} = buildDiffRows(diff); |
| const renderer = new DiffRenderer( |
| await CodeHighlighter.CodeHighlighter.create(originalLines.join('\n'), mimeType), |
| documentMap(originalLines), |
| await CodeHighlighter.CodeHighlighter.create(currentLines.join('\n'), mimeType), |
| documentMap(currentLines), |
| ); |
| Lit.render(renderer.#render(rows), parent, {host: this}); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'devtools-diff-view': DiffView; |
| } |
| } |
| |
| export interface DiffViewData { |
| diff: Diff.Diff.DiffArray; |
| mimeType: string; |
| } |
| |
| function renderNoDiffState(container: HTMLElement|DocumentFragment): void { |
| // clang-format off |
| Lit.render(html` |
| <style>${diffViewStyles}</style> |
| <p class="diff-listing-no-diff" data-testid="no-diff">${i18nString(UIStrings.noDiff)}</p>`, |
| container, {host: container}); |
| // clang-format on |
| } |
| |
| export class DiffView extends HTMLElement { |
| readonly #shadow = this.attachShadow({mode: 'open'}); |
| loaded: Promise<void>; |
| |
| constructor(data?: DiffViewData) { |
| super(); |
| |
| this.loaded = this.#render(data); |
| } |
| |
| set data(data: DiffViewData) { |
| this.loaded = this.#render(data); |
| } |
| |
| async #render(data?: DiffViewData): Promise<void> { |
| if (!data || data.diff.length === 0) { |
| renderNoDiffState(this.#shadow); |
| return; |
| } |
| |
| await DiffRenderer.render(data.diff, data.mimeType, this.#shadow); |
| } |
| } |
| |
| customElements.define('devtools-diff-view', DiffView); |