blob: 0b688a6860b3d880569b645b794f9c6c3b708990 [file] [log] [blame]
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {BlameInfo, CommentRange} from '../../../types/common';
import {Side} from '../../../constants/constants';
import {
DiffPreferencesInfo,
DiffResponsiveMode,
DisplayLine,
FILE,
LOST,
LineNumber,
RenderPreferences,
} from '../../../api/diff';
import {getBaseUrl} from '../../../utils/url-util';
/**
* 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.
* Otherwise gr-diff treats such elements as a black box.
*/
export interface GrDiffCommentThread {
side: Side;
line: LineNumber;
range?: CommentRange;
rootId?: string;
}
export function toCommentThreadModel(
threadEl: HTMLElement
): 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 &&
!(showFullContext === FullContext.NO && prefsContext === FULL_CONTEXT)
) {
return prefsContext;
}
return defaultContext;
}
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', 'line-num'.
export interface GrDiffThreadElement extends HTMLElement {
rootId: string;
}
export function isThreadEl(node: Node): node is GrDiffThreadElement {
return (
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;
}