| /** |
| * @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 hiddenEndLeft The first visible element after the hidden range, |
| * as a non-negative line number offset for left side relative to the first |
| * group's start line. |
| * @param hiddenEndRight The first visible element after the hidden range, |
| * as a non-negative line number offset for right side relative to the first |
| * group's start line. If not provided hiddenEndLeft will be used. |
| */ |
| export function hideInContextControl( |
| groups: readonly GrDiffGroup[], |
| hiddenStart: number, |
| hiddenEndLeft: number, |
| hiddenEndRight?: number |
| ): GrDiffGroup[] { |
| if (groups.length === 0) return []; |
| // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring |
| hiddenStart = Math.max(hiddenStart, 0); |
| hiddenEndLeft = Math.max(hiddenEndLeft, hiddenStart); |
| hiddenEndRight = Math.max(hiddenEndRight ?? hiddenEndLeft, hiddenStart); |
| |
| let before: GrDiffGroup[] = []; |
| let hidden = groups; |
| let after: readonly GrDiffGroup[] = []; |
| |
| const numHiddenLeft = hiddenEndLeft - hiddenStart; |
| const numHiddenRight = hiddenEndRight - 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 (numHiddenLeft > 3 && numHiddenRight > 3) { |
| if (hiddenStart) { |
| [before, hidden] = splitCommonGroups(hidden, hiddenStart, hiddenStart); |
| } |
| if (hiddenEndLeft && hiddenEndRight) { |
| let beforeLengthLeft = 0; |
| let beforeLengthRight = 0; |
| if (before.length > 0) { |
| const beforeStartLeft = before[0].lineRange.left.start_line; |
| const beforeEndLeft = before[before.length - 1].lineRange.left.end_line; |
| beforeLengthLeft = beforeEndLeft - beforeStartLeft + 1; |
| |
| const beforeStartRight = before[0].lineRange.right.start_line; |
| const beforeEndRight = |
| before[before.length - 1].lineRange.right.end_line; |
| beforeLengthRight = beforeEndRight - beforeStartRight + 1; |
| } |
| [hidden, after] = splitCommonGroups( |
| hidden, |
| hiddenEndLeft - beforeLengthLeft, |
| hiddenEndRight - beforeLengthRight |
| ); |
| } |
| } 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. Groups with |
| * type DELTA which are not common will not be split. |
| * |
| * @param splitLeft A line number offset for left side relative to the first |
| * group's start line at which the groups should be split. |
| * @param splitRight A line number offset for right side 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[], |
| splitLeft: number, |
| splitRight: number |
| ): GrDiffGroup[][] { |
| if (groups.length === 0) return [[], []]; |
| const leftSplit = groups[0].lineRange.left.start_line + splitLeft; |
| const rightSplit = groups[0].lineRange.right.start_line + splitRight; |
| let isSplitDone = false; |
| const beforeGroups = []; |
| const afterGroups = []; |
| for (const group of groups) { |
| if (isSplitDone) { |
| afterGroups.push(group); |
| } else if ( |
| group.type === GrDiffGroupType.DELTA && |
| !group.ignoredWhitespaceOnly |
| ) { |
| beforeGroups.push(group); |
| } else { |
| 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); |
| } |
| isSplitDone = true; |
| } |
| } |
| } |
| 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 excluding whitespace only |
| * changes. |
| */ |
| hasNonCommonDeltaGroup() { |
| return this.contextGroups?.some( |
| g => g.type === GrDiffGroupType.DELTA && !g.ignoredWhitespaceOnly |
| ); |
| } |
| |
| 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 |
| )}`; |
| } |
| } |