| /** | |
| * @license | |
| * Copyright 2020 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import {BlameInfo, CommentRange} from '../../../types/common'; | |
| import {Side, SpecialFilePath} from '../../../constants/constants'; | |
| import { | |
| DiffContextExpandedExternalDetail, | |
| DiffPreferencesInfo, | |
| DiffResponsiveMode, | |
| DisplayLine, | |
| FILE, | |
| LOST, | |
| LineNumber, | |
| RenderPreferences, | |
| } from '../../../api/diff'; | |
| import {getBaseUrl} from '../../../utils/url-util'; | |
| import {GrDiffGroup} from './gr-diff-group'; | |
| /** | |
| * In JS, unicode code points above 0xFFFF occupy two elements of a string. | |
| * For example '𐀏'.length is 2. An occurrence of such a code point is called a | |
| * surrogate pair. | |
| * | |
| * This regex segments a string along tabs ('\t') and surrogate pairs, since | |
| * these are two cases where '1 char' does not automatically imply '1 column'. | |
| * | |
| * TODO: For human languages whose orthographies use combining marks, this | |
| * approach won't correctly identify the grapheme boundaries. In those cases, | |
| * a grapheme consists of multiple code points that should count as only one | |
| * character against the column limit. Getting that correct (if it's desired) | |
| * is probably beyond the limits of a regex, but there are nonstandard APIs to | |
| * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs. | |
| * | |
| * Further reading: | |
| * On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode | |
| * Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries | |
| * A proposed JS API: https://github.com/tc39/proposal-intl-segmenter | |
| */ | |
| export const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/; | |
| // TODO(newdiff-cleanup): Remove once newdiff migration is completed. | |
| export function isNewDiff() { | |
| const flags = new Set(window.ENABLED_EXPERIMENTS ?? []); | |
| return flags.has('UiFeature__new_diff'); | |
| } | |
| export function getResponsiveMode( | |
| prefs?: DiffPreferencesInfo, | |
| renderPrefs?: RenderPreferences | |
| ): DiffResponsiveMode { | |
| if (renderPrefs?.responsive_mode) { | |
| return renderPrefs.responsive_mode; | |
| } | |
| // Backwards compatibility to the line_wrapping param. | |
| if (prefs?.line_wrapping) { | |
| return 'FULL_RESPONSIVE'; | |
| } | |
| return 'NONE'; | |
| } | |
| export function isResponsive(responsiveMode?: DiffResponsiveMode) { | |
| return ( | |
| responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY' | |
| ); | |
| } | |
| /** | |
| * Compare two ranges. Either argument may be falsy, but will only return | |
| * true if both are falsy or if neither are falsy and have the same position | |
| * values. | |
| */ | |
| export function rangesEqual(a?: CommentRange, b?: CommentRange): boolean { | |
| if (!a && !b) { | |
| return true; | |
| } | |
| if (!a || !b) { | |
| return false; | |
| } | |
| return ( | |
| a.start_line === b.start_line && | |
| a.start_character === b.start_character && | |
| a.end_line === b.end_line && | |
| a.end_character === b.end_character | |
| ); | |
| } | |
| export function isLongCommentRange(range: CommentRange): boolean { | |
| return range.end_line - range.start_line > 10; | |
| } | |
| export function getLineNumberByChild(node?: Node) { | |
| return getLineNumber(getLineElByChild(node)); | |
| } | |
| export function lineNumberToNumber(lineNumber?: LineNumber | null): number { | |
| if (typeof lineNumber !== 'number') return 0; | |
| return lineNumber; | |
| } | |
| export function 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 as Element).assignedSlot ?? | |
| (node as ShadowRoot).host ?? | |
| node.previousSibling ?? | |
| node.parentNode ?? | |
| undefined; | |
| } | |
| return null; | |
| } | |
| export function getSideByLineEl(lineEl: Element) { | |
| return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT; | |
| } | |
| export function getLineNumber(lineEl?: Element | null): LineNumber | null { | |
| if (!lineEl) return null; | |
| const lineNumberStr = lineEl.getAttribute('data-value'); | |
| if (!lineNumberStr) return null; | |
| if (lineNumberStr === FILE) return FILE; | |
| if (lineNumberStr === LOST) return LOST; | |
| const lineNumber = Number(lineNumberStr); | |
| return Number.isInteger(lineNumber) ? lineNumber : null; | |
| } | |
| export function getLine(threadEl: HTMLElement): LineNumber { | |
| const lineAtt = threadEl.getAttribute('line-num'); | |
| if (lineAtt === LOST) return lineAtt; | |
| if (!lineAtt || lineAtt === FILE) return FILE; | |
| const line = Number(lineAtt); | |
| if (isNaN(line)) throw new Error(`cannot parse line number: ${lineAtt}`); | |
| if (line < 1) throw new Error(`line number smaller than 1: ${line}`); | |
| return line; | |
| } | |
| export function getSide(threadEl: HTMLElement): Side | undefined { | |
| const sideAtt = threadEl.getAttribute('diff-side'); | |
| if (!sideAtt) { | |
| console.warn('comment thread without side'); | |
| return undefined; | |
| } | |
| if (sideAtt !== Side.LEFT && sideAtt !== Side.RIGHT) | |
| throw Error(`unexpected value for side: ${sideAtt}`); | |
| return sideAtt as Side; | |
| } | |
| export function getRange(threadEl: HTMLElement): CommentRange | undefined { | |
| const rangeAtt = threadEl.getAttribute('range'); | |
| if (!rangeAtt) return undefined; | |
| const range = JSON.parse(rangeAtt) as CommentRange; | |
| if (!range.start_line) return undefined; | |
| return range; | |
| } | |
| /** | |
| * This is all the data that gr-diff extracts from comment thread elements, | |
| * see `GrDiffThreadElement`. Otherwise gr-diff treats such elements as a black | |
| * box. | |
| */ | |
| export interface GrDiffCommentThread { | |
| side: Side; | |
| line: LineNumber; | |
| range?: CommentRange; | |
| rootId?: string; | |
| } | |
| /** | |
| * Retrieves all the data from a comment thread element that the gr-diff API | |
| * contract defines for such elements. | |
| */ | |
| export function getDataFromCommentThreadEl( | |
| threadEl?: EventTarget | null | |
| ): GrDiffCommentThread | undefined { | |
| if (!isThreadEl(threadEl)) return undefined; | |
| const side = getSide(threadEl); | |
| const line = getLine(threadEl); | |
| const range = getRange(threadEl); | |
| if (!side) return undefined; | |
| if (!line) return undefined; | |
| return {side, line, range, rootId: threadEl.rootId}; | |
| } | |
| export interface KeyLocations { | |
| left: {[key: string]: boolean}; | |
| right: {[key: string]: boolean}; | |
| } | |
| /** | |
| * "Context" is the number of lines that we are showing around diff chunks and | |
| * commented lines. This typically comes from a user preference and is set to | |
| * something like 3 or 10. | |
| * | |
| * `FULL_CONTEXT` means that the user wants to see the entire file. We could | |
| * also call this "infinite context". | |
| */ | |
| export const FULL_CONTEXT = -1; | |
| export enum FullContext { | |
| /** User has opted into showing the full context. */ | |
| YES = 'YES', | |
| /** User has opted into showing only limited context. */ | |
| NO = 'NO', | |
| /** | |
| * User has not decided yet. Will see a warning message with two options then, | |
| * if the file is too large. | |
| */ | |
| UNDECIDED = 'UNDECIDED', | |
| } | |
| export function computeContext( | |
| prefsContext: number | undefined, | |
| showFullContext: FullContext, | |
| defaultContext: number | |
| ) { | |
| if (showFullContext === FullContext.YES) { | |
| return FULL_CONTEXT; | |
| } | |
| if ( | |
| prefsContext !== undefined && | |
| !(showFullContext === FullContext.NO && prefsContext === FULL_CONTEXT) | |
| ) { | |
| return prefsContext; | |
| } | |
| return defaultContext; | |
| } | |
| export function computeLineLength( | |
| prefs: DiffPreferencesInfo, | |
| path: string | undefined | |
| ): number { | |
| if (path === SpecialFilePath.COMMIT_MESSAGE) { | |
| return 72; | |
| } | |
| const lineLength = prefs.line_length; | |
| if (Number.isInteger(lineLength) && lineLength > 0) { | |
| return lineLength; | |
| } | |
| return 100; | |
| } | |
| export function computeKeyLocations( | |
| lineOfInterest: DisplayLine | undefined, | |
| comments: GrDiffCommentThread[] | |
| ) { | |
| const keyLocations: KeyLocations = {left: {}, right: {}}; | |
| if (lineOfInterest) { | |
| keyLocations[lineOfInterest.side][lineOfInterest.lineNum] = true; | |
| } | |
| for (const comment of comments) { | |
| keyLocations[comment.side][comment.line] = true; | |
| if (comment.range?.start_line) { | |
| keyLocations[comment.side][comment.range.start_line] = true; | |
| } | |
| } | |
| return keyLocations; | |
| } | |
| export function compareComments( | |
| c1: GrDiffCommentThread, | |
| c2: GrDiffCommentThread | |
| ): number { | |
| if (c1.side !== c2.side) { | |
| return c1.side === Side.RIGHT ? 1 : -1; | |
| } | |
| if (c1.line !== c2.line) { | |
| if (c1.line === FILE && c2.line !== FILE) return -1; | |
| if (c1.line !== FILE && c2.line === FILE) return 1; | |
| if (c1.line === LOST && c2.line !== LOST) return -1; | |
| if (c1.line !== LOST && c2.line === LOST) return 1; | |
| return (c1.line as number) - (c2.line as number); | |
| } | |
| if (c1.rootId !== c2.rootId) { | |
| if (!c1.rootId) return -1; | |
| if (!c2.rootId) return 1; | |
| return c1.rootId > c2.rootId ? 1 : -1; | |
| } | |
| if (c1.range && c2.range) { | |
| const r1 = JSON.stringify(c1.range); | |
| const r2 = JSON.stringify(c2.range); | |
| return r1 > r2 ? 1 : -1; | |
| } | |
| if (c1.range) return 1; | |
| if (c2.range) return -1; | |
| return 0; | |
| } | |
| // TODO: This type should be exposed to gr-diff clients in a separate type file. | |
| // For Gerrit these are instances of GrCommentThread, but other gr-diff users | |
| // have different HTML elements in use for comment threads. | |
| // TODO: Also document the required HTML attributes that thread elements must | |
| // have, e.g. 'diff-side', 'range' (optional), 'line-num'. | |
| // Comment widgets are also required to have `comment-thread` in their css | |
| // class list. | |
| export interface GrDiffThreadElement extends HTMLElement { | |
| rootId: string; | |
| } | |
| export function isThreadEl( | |
| node?: Node | EventTarget | null | |
| ): node is GrDiffThreadElement { | |
| return ( | |
| !!node && | |
| (node as Node).nodeType === Node.ELEMENT_NODE && | |
| (node as Element).classList.contains('comment-thread') | |
| ); | |
| } | |
| /** | |
| * Simple helper method for creating element classes in the context of | |
| * gr-diff. This is just a super simple convenience function. | |
| */ | |
| export function diffClasses(...additionalClasses: string[]) { | |
| return ['gr-diff', ...additionalClasses].join(' '); | |
| } | |
| /** | |
| * Simple helper method for creating elements in the context of gr-diff. | |
| * This is just a super simple convenience function. | |
| */ | |
| export function createElementDiff( | |
| tagName: string, | |
| classStr?: string | |
| ): HTMLElement { | |
| const el = document.createElement(tagName); | |
| el.classList.add('gr-diff'); | |
| if (classStr) { | |
| for (const className of classStr.split(' ')) { | |
| el.classList.add(className); | |
| } | |
| } | |
| return el; | |
| } | |
| export function createElementDiffWithText( | |
| tagName: string, | |
| textContent: string | |
| ) { | |
| const element = createElementDiff(tagName); | |
| element.textContent = textContent; | |
| return element; | |
| } | |
| export function createLineBreak(mode: DiffResponsiveMode) { | |
| return isResponsive(mode) | |
| ? createElementDiff('wbr') | |
| : createElementDiff('span', 'br'); | |
| } | |
| /** | |
| * Returns a <span> element holding a '\t' character, that will visually | |
| * occupy |tabSize| many columns. | |
| * | |
| * @param tabSize The effective size of this tab stop. | |
| */ | |
| export function createTabWrapper(tabSize: number): HTMLElement { | |
| // Force this to be a number to prevent arbitrary injection. | |
| const result = createElementDiff('span', 'tab'); | |
| result.setAttribute( | |
| 'style', | |
| `tab-size: ${tabSize}; -moz-tab-size: ${tabSize};` | |
| ); | |
| result.innerText = '\t'; | |
| return result; | |
| } | |
| /** | |
| * 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. | |
| * | |
| * @param text The text to be formatted. | |
| * @param responsiveMode The responsive mode of the diff. | |
| * @param tabSize The width of each tab stop. | |
| * @param lineLimit The column after which to wrap lines. | |
| */ | |
| export function formatText( | |
| text: string, | |
| responsiveMode: DiffResponsiveMode, | |
| tabSize: number, | |
| lineLimit: number, | |
| elementId: string | |
| ): HTMLElement { | |
| const contentText = createElementDiff('div', 'contentText'); | |
| // <gr-legacy-text> is not defined anywhere, so this behave just as a <div> | |
| // would. We use this during the migration to lit based diff elements to | |
| // match <gr-diff-text>. We define a css rule with `display:contents` making | |
| // sure that this extra element is basically a no-op. | |
| const legacyText = document.createElement('gr-legacy-text'); | |
| contentText.appendChild(legacyText); | |
| contentText.id = elementId; | |
| let columnPos = 0; | |
| let textOffset = 0; | |
| for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) { | |
| if (segment) { | |
| // |segment| contains only normal characters. If |segment| doesn't fit | |
| // entirely on the current line, append chunks of |segment| followed by | |
| // line breaks. | |
| let rowStart = 0; | |
| let rowEnd = lineLimit - columnPos; | |
| while (rowEnd < segment.length) { | |
| legacyText.appendChild( | |
| document.createTextNode(segment.substring(rowStart, rowEnd)) | |
| ); | |
| legacyText.appendChild(createLineBreak(responsiveMode)); | |
| columnPos = 0; | |
| rowStart = rowEnd; | |
| rowEnd += lineLimit; | |
| } | |
| // Append the last part of |segment|, which fits on the current line. | |
| legacyText.appendChild( | |
| document.createTextNode(segment.substring(rowStart)) | |
| ); | |
| columnPos += segment.length - rowStart; | |
| textOffset += segment.length; | |
| } | |
| if (textOffset < text.length) { | |
| // Handle the special character at |textOffset|. | |
| if (text.startsWith('\t', textOffset)) { | |
| // Append a single '\t' character. | |
| let effectiveTabSize = tabSize - (columnPos % tabSize); | |
| if (columnPos + effectiveTabSize > lineLimit) { | |
| legacyText.appendChild(createLineBreak(responsiveMode)); | |
| columnPos = 0; | |
| effectiveTabSize = tabSize; | |
| } | |
| legacyText.appendChild(createTabWrapper(effectiveTabSize)); | |
| columnPos += effectiveTabSize; | |
| textOffset++; | |
| } else { | |
| // Append a single surrogate pair. | |
| if (columnPos >= lineLimit) { | |
| legacyText.appendChild(createLineBreak(responsiveMode)); | |
| columnPos = 0; | |
| } | |
| legacyText.appendChild( | |
| document.createTextNode(text.substring(textOffset, textOffset + 2)) | |
| ); | |
| textOffset += 2; | |
| columnPos += 1; | |
| } | |
| } | |
| } | |
| return contentText; | |
| } | |
| /** | |
| * Given the number of a base line and the BlameInfo create a <span> element | |
| * with a hovercard. This is supposed to be put into a <td> cell of the diff. | |
| */ | |
| export function createBlameElement( | |
| lineNum: LineNumber, | |
| commit: BlameInfo | |
| ): HTMLElement { | |
| const isStartOfRange = commit.ranges.some(r => r.start === lineNum); | |
| const date = new Date(commit.time * 1000).toLocaleDateString(); | |
| const blameNode = createElementDiff( | |
| 'span', | |
| isStartOfRange ? 'startOfRange' : '' | |
| ); | |
| const shaNode = createElementDiff('a', 'blameDate'); | |
| shaNode.innerText = `${date}`; | |
| shaNode.setAttribute('href', `${getBaseUrl()}/q/${commit.id}`); | |
| blameNode.appendChild(shaNode); | |
| const shortName = commit.author.split(' ')[0]; | |
| const authorNode = createElementDiff('span', 'blameAuthor'); | |
| authorNode.innerText = ` ${shortName}`; | |
| blameNode.appendChild(authorNode); | |
| const hoverCardFragment = createElementDiff('span', 'blameHoverCard'); | |
| hoverCardFragment.innerText = `Commit ${commit.id} | |
| Author: ${commit.author} | |
| Date: ${date} | |
| ${commit.commit_msg}`; | |
| const hovercard = createElementDiff('gr-hovercard'); | |
| hovercard.appendChild(hoverCardFragment); | |
| blameNode.appendChild(hovercard); | |
| return blameNode; | |
| } | |
| export interface DiffContextExpandedEventDetail | |
| extends DiffContextExpandedExternalDetail { | |
| /** The context control group that should be replaced by `groups`. */ | |
| contextGroup: GrDiffGroup; | |
| groups: GrDiffGroup[]; | |
| numLines: number; | |
| } |