blob: c4aaa2d399aac2895933b8e96f5ccca6040ce991 [file] [log] [blame]
/**
* @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 {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]/;
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')
);
}
export interface DiffContextExpandedEventDetail
extends DiffContextExpandedExternalDetail {
/** The context control group that should be replaced by `groups`. */
contextGroup: GrDiffGroup;
groups: GrDiffGroup[];
numLines: number;
}
export function findBlame(blameInfos: BlameInfo[], line?: LineNumber) {
if (typeof line !== 'number') return undefined;
return blameInfos.find(info =>
info.ranges.find(range => range.start <= line && line <= range.end)
);
}