blob: 0739562fa7e54e3df62cdd76a016a86af3806c07 [file] [log] [blame]
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {BLANK_LINE, GrDiffLine} from './gr-diff-line';
import {GrDiffLineType, LineNumber, LineRange, Side} from '../../../api/diff';
import {assertIsDefined, assert} from '../../../utils/common-util';
import {isDefined} from '../../../types/types';
export enum GrDiffGroupType {
/** A group of unchanged diff lines. */
BOTH = 'both',
/**
* A context control group "hides" other diff groups in its `contextGroups` field.
*/
CONTEXT_CONTROL = 'contextControl',
/** Added, removed or modified chunk. */
DELTA = 'delta',
}
export interface GrDiffLinePair {
left: GrDiffLine;
right: GrDiffLine;
}
/**
* Hides lines in the given range behind a context control group.
*
* Groups that would be partially visible are split into their visible and
* hidden parts, respectively.
* The groups need to be "common groups", meaning they have to have either
* originated from an `ab` chunk, or from an `a`+`b` chunk with
* `common: true`.
*
* If the hidden range is 3 lines or less, nothing is hidden and no context
* control group is created.
*
* @param groups Common groups, ordered by their line ranges.
* @param hiddenStart The first element to be hidden, as a
* non-negative line number offset relative to the first group's start
* line, left and right respectively.
* @param hiddenEnd The first visible element after the hidden range,
* as a non-negative line number offset relative to the first group's
* start line, left and right respectively.
*/
export function hideInContextControl(
groups: readonly GrDiffGroup[],
hiddenStart: number,
hiddenEnd: number
): GrDiffGroup[] {
if (groups.length === 0) return [];
// Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
hiddenStart = Math.max(hiddenStart, 0);
hiddenEnd = Math.max(hiddenEnd, hiddenStart);
let before: GrDiffGroup[] = [];
let hidden = groups;
let after: readonly GrDiffGroup[] = [];
const numHidden = hiddenEnd - hiddenStart;
// Showing a context control row for less than 4 lines does not make much,
// because then that row would consume as much space as the collapsed code.
if (numHidden > 3) {
if (hiddenStart) {
[before, hidden] = splitCommonGroups(hidden, hiddenStart);
}
if (hiddenEnd) {
let beforeLength = 0;
if (before.length > 0) {
const beforeStart = before[0].lineRange.left.start_line;
const beforeEnd = before[before.length - 1].lineRange.left.end_line;
beforeLength = beforeEnd - beforeStart + 1;
}
[hidden, after] = splitCommonGroups(hidden, hiddenEnd - beforeLength);
}
} else {
[hidden, after] = [[], hidden];
}
const result = [...before];
if (hidden.length) {
result.push(
new GrDiffGroup({
type: GrDiffGroupType.CONTEXT_CONTROL,
contextGroups: [...hidden],
})
);
}
result.push(...after);
return result;
}
/**
* Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
* used in function splitCommonGroups
* Groups with some lines before and some lines after the split will be split
* into two groups, which will be put into the first and second list.
*
* @param group The group to be split in two
* @param leftSplit The line number relative to the split on the left side
* @param rightSplit The line number relative to the split on the right side
* @return two new groups, one before the split and another after it
*/
function splitGroupInTwo(
group: GrDiffGroup,
leftSplit: number,
rightSplit: number
) {
let beforeSplit: GrDiffGroup | undefined;
let afterSplit: GrDiffGroup | undefined;
// split line is in the middle of a group, we need to break the group
// in lines before and after the split.
if (group.skip) {
// Currently we assume skip chunks "refuse" to be split. Expanding this
// group will in the future mean load more data - and therefore we want to
// fire an event when user wants to do it.
const closerToStartThanEnd =
leftSplit - group.lineRange.left.start_line <
group.lineRange.right.end_line - leftSplit;
if (closerToStartThanEnd) {
afterSplit = group;
} else {
beforeSplit = group;
}
} else {
const before = [];
const after = [];
for (const line of group.lines) {
if (
(line.beforeNumber &&
typeof line.beforeNumber === 'number' &&
line.beforeNumber < leftSplit) ||
(line.afterNumber &&
typeof line.afterNumber === 'number' &&
line.afterNumber < rightSplit)
) {
before.push(line);
} else {
after.push(line);
}
}
if (before.length) {
beforeSplit =
before.length === group.lines.length
? group
: group.cloneWithLines(before);
}
if (after.length) {
afterSplit =
after.length === group.lines.length
? group
: group.cloneWithLines(after);
}
}
return {beforeSplit, afterSplit};
}
/**
* Splits a list of common groups into two lists of groups.
*
* Groups where all lines are before or all lines are after the split will be
* retained as is and put into the first or second list respectively. Groups
* with some lines before and some lines after the split will be split into
* two groups, which will be put into the first and second list.
*
* @param split A line number offset relative to the first group's
* start line at which the groups should be split.
* @return The outer array has 2 elements, the
* list of groups before and the list of groups after the split.
*/
function splitCommonGroups(
groups: readonly GrDiffGroup[],
split: number
): GrDiffGroup[][] {
if (groups.length === 0) return [[], []];
const leftSplit = groups[0].lineRange.left.start_line + split;
const rightSplit = groups[0].lineRange.right.start_line + split;
const beforeGroups = [];
const afterGroups = [];
for (const group of groups) {
const isCompletelyBefore =
group.lineRange.left.end_line < leftSplit ||
group.lineRange.right.end_line < rightSplit;
const isCompletelyAfter =
leftSplit <= group.lineRange.left.start_line ||
rightSplit <= group.lineRange.right.start_line;
if (isCompletelyBefore) {
beforeGroups.push(group);
} else if (isCompletelyAfter) {
afterGroups.push(group);
} else {
const {beforeSplit, afterSplit} = splitGroupInTwo(
group,
leftSplit,
rightSplit
);
if (beforeSplit) {
beforeGroups.push(beforeSplit);
}
if (afterSplit) {
afterGroups.push(afterSplit);
}
}
}
return [beforeGroups, afterGroups];
}
export interface GrMoveDetails {
changed: boolean;
range?: {
start: number;
end: number;
};
}
/**
* A chunk of the diff that should be rendered together. Typically corresponds
* to a gr-diff-section. It mostly just contains an array of diff `lines`.
*
* A group of type CONTEXT_CONTROL does not contain any lines directly, but
* "hides" other groups in `contextGroups`, which the user can expand.
*/
export class GrDiffGroup {
constructor(
options:
| {
type: GrDiffGroupType.BOTH | GrDiffGroupType.DELTA;
lines?: GrDiffLine[];
skip?: undefined;
moveDetails?: GrMoveDetails;
dueToRebase?: boolean;
ignoredWhitespaceOnly?: boolean;
keyLocation?: boolean;
}
| {
type: GrDiffGroupType.BOTH | GrDiffGroupType.DELTA;
lines?: undefined;
skip: number;
offsetLeft: number;
offsetRight: number;
moveDetails?: GrMoveDetails;
dueToRebase?: boolean;
ignoredWhitespaceOnly?: boolean;
keyLocation?: boolean;
}
| {
type: GrDiffGroupType.CONTEXT_CONTROL;
contextGroups: GrDiffGroup[];
}
) {
this.type = options.type;
switch (options.type) {
case GrDiffGroupType.BOTH:
case GrDiffGroupType.DELTA: {
this.moveDetails = options.moveDetails;
this.dueToRebase = options.dueToRebase ?? false;
this.ignoredWhitespaceOnly = options.ignoredWhitespaceOnly ?? false;
this.keyLocation = options.keyLocation ?? false;
if (options.skip && options.lines) {
throw new Error('Cannot set skip and lines');
}
this.skip = options.skip;
if (options.skip !== undefined) {
this.lineRange = {
left: {
start_line: options.offsetLeft,
end_line: options.offsetLeft + options.skip - 1,
},
right: {
start_line: options.offsetRight,
end_line: options.offsetRight + options.skip - 1,
},
};
} else {
assertIsDefined(options.lines);
assert(options.lines.length > 0, 'diff group must have lines');
for (const line of options.lines) {
this.addLine(line);
}
}
break;
}
case GrDiffGroupType.CONTEXT_CONTROL: {
this.contextGroups = options.contextGroups;
if (this.contextGroups.length > 0) {
const firstGroup = this.contextGroups[0];
const lastGroup = this.contextGroups[this.contextGroups.length - 1];
this.lineRange = {
left: {
start_line: firstGroup.lineRange.left.start_line,
end_line: lastGroup.lineRange.left.end_line,
},
right: {
start_line: firstGroup.lineRange.right.start_line,
end_line: lastGroup.lineRange.right.end_line,
},
};
}
break;
}
default:
throw new Error(`Unknown group type: ${this.type}`);
}
}
readonly type: GrDiffGroupType;
readonly dueToRebase: boolean = false;
/**
* True means all changes in this line are whitespace changes that should
* not be highlighted as changed as per the user settings.
*/
readonly ignoredWhitespaceOnly: boolean = false;
/**
* True means it should not be collapsed (because it was in the URL, or
* there is a comment on that line)
*/
readonly keyLocation: boolean = false;
readonly lines: GrDiffLine[] = [];
readonly adds: GrDiffLine[] = [];
readonly removes: GrDiffLine[] = [];
/**
* Only set, iff type is CONTEXT_CONTROL.
*
* A CONTEXT_CONTROL group "hides" other groups that the user may expand.
* This field contains those hidden groups.
*/
readonly contextGroups: GrDiffGroup[] = [];
readonly skip?: number;
/** Both start and end line are inclusive. */
readonly lineRange: {[side in Side]: LineRange} = {
[Side.LEFT]: {start_line: 0, end_line: 0},
[Side.RIGHT]: {start_line: 0, end_line: 0},
};
readonly moveDetails?: GrMoveDetails;
/**
* Creates a new group with the same properties but different lines.
*
* The element property is not copied, because the original element is still a
* rendering of the old lines, so that would not make sense.
*/
cloneWithLines(lines: GrDiffLine[]): GrDiffGroup {
if (
this.type !== GrDiffGroupType.BOTH &&
this.type !== GrDiffGroupType.DELTA
) {
throw new Error('Cannot clone context group with lines');
}
const group = new GrDiffGroup({
type: this.type,
lines,
dueToRebase: this.dueToRebase,
ignoredWhitespaceOnly: this.ignoredWhitespaceOnly,
});
return group;
}
private addLine(line: GrDiffLine) {
this.lines.push(line);
const notDelta =
this.type === GrDiffGroupType.BOTH ||
this.type === GrDiffGroupType.CONTEXT_CONTROL;
if (
notDelta &&
(line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.REMOVE)
) {
throw Error('Cannot add delta line to a non-delta group.');
}
if (line.type === GrDiffLineType.ADD) {
this.adds.push(line);
} else if (line.type === GrDiffLineType.REMOVE) {
this.removes.push(line);
}
this._updateRangeWithNewLine(line);
}
getSideBySidePairs(): GrDiffLinePair[] {
if (
this.type === GrDiffGroupType.BOTH ||
this.type === GrDiffGroupType.CONTEXT_CONTROL
) {
return this.lines.map(line => {
return {left: line, right: line};
});
}
const pairs: GrDiffLinePair[] = [];
let i = 0;
let j = 0;
while (i < this.removes.length || j < this.adds.length) {
pairs.push({
left: this.removes[i] || BLANK_LINE,
right: this.adds[j] || BLANK_LINE,
});
i++;
j++;
}
return pairs;
}
getUnifiedPairs(): GrDiffLinePair[] {
return this.lines
.map(line => {
if (line.type === GrDiffLineType.ADD) {
return {left: BLANK_LINE, right: line};
}
if (line.type === GrDiffLineType.REMOVE) {
if (this.ignoredWhitespaceOnly) return undefined;
return {left: line, right: BLANK_LINE};
}
return {left: line, right: line};
})
.filter(isDefined);
}
/** Returns true if it is, or contains, a skip group. */
hasSkipGroup() {
return (
this.skip !== undefined ||
this.contextGroups?.some(g => g.skip !== undefined)
);
}
/** Returns true if it contains a DELTA group. */
hasDeltaGroup() {
return this.contextGroups?.some(g => g.type === GrDiffGroupType.DELTA);
}
containsLine(side: Side, line: LineNumber) {
if (typeof line !== 'number') {
// For FILE and LOST, beforeNumber and afterNumber are the same
return this.lines[0]?.beforeNumber === line;
}
const lineRange = this.lineRange[side];
return lineRange.start_line <= line && line <= lineRange.end_line;
}
startLine(side: Side): LineNumber {
// For both CONTEXT_CONTROL groups and SKIP groups the `lines` array will
// be empty. So we have to use `lineRange` instead of looking at the first
// line.
if (
this.type === GrDiffGroupType.CONTEXT_CONTROL ||
this.skip !== undefined
) {
return side === Side.LEFT
? this.lineRange.left.start_line
: this.lineRange.right.start_line;
}
// For "normal" groups we could also use the `lineRange`, but for FILE or
// LOST lines we want to return FILE or LOST. The `lineRange` contains
// numbers only.
return this.lines[0].lineNumber(side);
}
private _updateRangeWithNewLine(line: GrDiffLine) {
if (typeof line.beforeNumber !== 'number') return;
if (typeof line.afterNumber !== 'number') return;
if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
if (
this.lineRange.right.start_line === 0 ||
line.afterNumber < this.lineRange.right.start_line
) {
this.lineRange.right.start_line = line.afterNumber;
}
if (line.afterNumber > this.lineRange.right.end_line) {
this.lineRange.right.end_line = line.afterNumber;
}
}
if (
line.type === GrDiffLineType.REMOVE ||
line.type === GrDiffLineType.BOTH
) {
if (
this.lineRange.left.start_line === 0 ||
line.beforeNumber < this.lineRange.left.start_line
) {
this.lineRange.left.start_line = line.beforeNumber;
}
if (line.beforeNumber > this.lineRange.left.end_line) {
this.lineRange.left.end_line = line.beforeNumber;
}
}
}
/**
* Determines whether the group is either totally an addition or totally
* a removal.
*/
isTotal(): boolean {
return (
this.type === GrDiffGroupType.DELTA &&
(!this.adds.length || !this.removes.length) &&
!(!this.adds.length && !this.removes.length)
);
}
id() {
return `${this.type} ${this.startLine(Side.LEFT)} ${this.startLine(
Side.RIGHT
)}`;
}
}