blob: 89bb49ed7f8e3c4dbdcde1c8099d723165c3515f [file] [log] [blame]
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {GrDiffLine, Highlights} from '../gr-diff/gr-diff-line';
import {
GrDiffGroup,
GrDiffGroupType,
hideInContextControl,
} from '../gr-diff/gr-diff-group';
import {DiffContent, DiffRangesToFocus} from '../../../types/diff';
import {Side} from '../../../constants/constants';
import {getStringLength} from '../gr-diff-highlight/gr-annotation';
import {GrDiffLineType, LineNumber} from '../../../api/diff';
import {FULL_CONTEXT, KeyLocations} from '../gr-diff/gr-diff-utils';
// visible for testing
export interface State {
lineNums: {
left: number;
right: number;
};
chunkIndex: number;
}
interface ChunkEnd {
offset: number;
keyLocation: boolean;
}
/** Interface for listening to the output of the processor. */
export interface GroupConsumer {
addGroup(group: GrDiffGroup): void;
clearGroups(): void;
}
/** Interface for listening to the output of the processor. */
export interface ProcessingOptions {
context: number;
keyLocations?: KeyLocations;
asyncThreshold?: number;
isBinary?: boolean;
diffRangesToFocus?: DiffRangesToFocus;
}
/**
* Converts the API's `DiffContent`s to `GrDiffGroup`s for rendering.
*
* Glossary:
* - "chunk": A single `DiffContent` as returned by the API.
* - "group": A single `GrDiffGroup` as used for rendering.
* - "common" chunk/group: A chunk/group that should be considered unchanged
* for diffing purposes. This can mean its either actually unchanged, or it
* has only whitespace changes.
* - "key location": A line number and side of the diff that should not be
* collapsed e.g. because a comment is attached to it, or because it was
* provided in the URL and thus should be visible
* - "uncollapsible" chunk/group: A chunk/group that is either not "common",
* or cannot be collapsed because it contains a key location
*
* Here a a number of tasks this processor performs:
* - splitting large chunks to allow more granular async rendering
* - adding a group for the "File" pseudo line that file-level comments can
* be attached to
* - replacing common parts of the diff that are outside the user's
* context setting and do not have comments with a group representing the
* "expand context" widget. This may require splitting a chunk/group so
* that the part that is within the context or has comments is shown, while
* the rest is not.
*/
export class GrDiffProcessor {
// visible for testing
context: number;
// visible for testing
keyLocations: KeyLocations;
private isBinary = false;
private groups: GrDiffGroup[] = [];
// visible for testing
diffRangesToFocus?: DiffRangesToFocus;
constructor(options: ProcessingOptions) {
this.context = options.context;
this.keyLocations = options.keyLocations ?? {left: {}, right: {}};
this.isBinary = options.isBinary ?? false;
this.diffRangesToFocus = options.diffRangesToFocus;
}
/**
* Process the diff chunks into GrDiffGroups.
*
* @return an array of GrDiffGroups
*/
process(chunks: DiffContent[]): GrDiffGroup[] {
this.groups = [];
this.groups.push(this.makeGroup('LOST'));
this.groups.push(this.makeGroup('FILE'));
this.processChunks(chunks);
return this.groups;
}
processChunks(chunks: DiffContent[]) {
if (this.isBinary) return;
const state = {
lineNums: {left: 0, right: 0},
chunkIndex: 0,
};
chunks = this.splitCommonChunksWithKeyLocations(chunks);
while (state.chunkIndex < chunks.length) {
const stateUpdate = this.processNext(state, chunks);
for (const group of stateUpdate.groups) {
this.groups.push(group);
}
state.lineNums.left += stateUpdate.lineDelta.left;
state.lineNums.right += stateUpdate.lineDelta.right;
state.chunkIndex = stateUpdate.newChunkIndex;
}
}
/**
* Process the next uncollapsible chunk, or the next collapsible chunks.
*/
// visible for testing
processNext(state: State, chunks: DiffContent[]) {
const firstUncollapsibleChunkIndex = this.firstUncollapsibleChunkIndex(
chunks,
state
);
if (firstUncollapsibleChunkIndex === state.chunkIndex) {
const chunk = chunks[state.chunkIndex];
return {
lineDelta: {
left: this.linesLeft(chunk).length,
right: this.linesRight(chunk).length,
},
groups: [
this.chunkToGroup(
chunk,
state.lineNums.left + 1,
state.lineNums.right + 1
),
],
newChunkIndex: state.chunkIndex + 1,
};
}
return this.processCollapsibleChunks(
state,
chunks,
firstUncollapsibleChunkIndex
);
}
private linesLeft(chunk: DiffContent) {
return chunk.ab || chunk.a || [];
}
private linesRight(chunk: DiffContent) {
return chunk.ab || chunk.b || [];
}
private firstUncollapsibleChunkIndex(chunks: DiffContent[], state: State) {
let chunkIndex = state.chunkIndex;
let offsetLeft = state.lineNums.left;
let offsetRight = state.lineNums.right;
while (
chunkIndex < chunks.length &&
this.isCollapsibleChunk(chunks[chunkIndex], offsetLeft, offsetRight)
) {
offsetLeft += this.chunkLength(chunks[chunkIndex], Side.LEFT);
offsetRight += this.chunkLength(chunks[chunkIndex], Side.RIGHT);
chunkIndex++;
}
return chunkIndex;
}
/**
* Check if a chunk is collapsible.
*
* A chunk is collapsible if it is either common or skippable, and it is not
* a key location, or it is outside of the focus range.
*
* @param chunk The chunk to check.
* @param offsetLeft The offset of the left side of the chunk.
* @param offsetRight The offset of the right side of the chunk.
* @return True if the chunk is collapsible, false otherwise.
*/
private isCollapsibleChunk(
chunk: DiffContent,
offsetLeft: number,
offsetRight: number
) {
const isCommonOrSkip = chunk.ab || chunk.common || chunk.skip;
const isOutsideOfFocusRange = this.isChunkOutsideOfFocusRange(
chunk,
offsetLeft,
offsetRight
);
return (isCommonOrSkip && !chunk.keyLocation) || isOutsideOfFocusRange;
}
private isChunkOutsideOfFocusRange(
chunk: DiffContent,
offsetLeft: number,
offsetRight: number
) {
if (!this.diffRangesToFocus) {
return false;
}
const leftLineCount = this.linesLeft(chunk).length;
const rightLineCount = this.linesRight(chunk).length;
const hasLeftSideOverlap = this.diffRangesToFocus.left.some(range =>
this.hasAnyOverlap(
{start: offsetLeft, end: offsetLeft + leftLineCount},
range
)
);
const hasRightSideOverlap = this.diffRangesToFocus.right.some(range =>
this.hasAnyOverlap(
{start: offsetRight, end: offsetRight + rightLineCount},
range
)
);
return !hasLeftSideOverlap && !hasRightSideOverlap;
}
private hasAnyOverlap(
firstRange: {start: number; end: number},
secondRange: {start: number; end: number}
) {
const startOverlap = Math.max(firstRange.start, secondRange.start);
const endOverlap = Math.min(firstRange.end, secondRange.end);
return startOverlap <= endOverlap;
}
/**
* Process a stretch of collapsible chunks.
*
* Outputs up to three groups:
* 1) Visible context before the hidden common code, unless it's the
* very beginning of the file.
* 2) Context hidden behind a context bar, unless empty.
* 3) Visible context after the hidden common code, unless it's the very
* end of the file.
*/
private processCollapsibleChunks(
state: State,
chunks: DiffContent[],
firstUncollapsibleChunkIndex: number
) {
const collapsibleChunks = chunks.slice(
state.chunkIndex,
firstUncollapsibleChunkIndex
);
const leftLineCount = collapsibleChunks.reduce(
(sum, chunk) => sum + this.chunkLength(chunk, Side.LEFT),
0
);
const rightLineCount = collapsibleChunks.reduce(
(sum, chunk) => sum + this.chunkLength(chunk, Side.RIGHT),
0
);
let groups = this.chunksToGroups(
collapsibleChunks,
state.lineNums.left + 1,
state.lineNums.right + 1
);
const hasSkippedGroup = !!groups.find(g => g.skip);
const hasNonCommonDeltaGroup = !!groups.find(
g => g.type === GrDiffGroupType.DELTA && !g.ignoredWhitespaceOnly
);
if (
this.context !== FULL_CONTEXT ||
hasSkippedGroup ||
hasNonCommonDeltaGroup
) {
const contextNumLines = this.context > 0 ? this.context : 0;
const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
const hiddenEndLeft =
leftLineCount -
(firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
const hiddenEndRight =
rightLineCount -
(firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
groups = hideInContextControl(
groups,
hiddenStart,
hiddenEndLeft,
hiddenEndRight
);
}
return {
lineDelta: {
left: leftLineCount,
right: rightLineCount,
},
groups,
newChunkIndex: firstUncollapsibleChunkIndex,
};
}
private chunkLength(chunk: DiffContent, side: Side) {
if (chunk.skip || chunk.common || chunk.ab) {
return this.commonChunkLength(chunk);
} else if (side === Side.LEFT) {
return this.linesLeft(chunk).length;
} else {
return this.linesRight(chunk).length;
}
}
private commonChunkLength(chunk: DiffContent) {
if (chunk.skip) {
return chunk.skip;
}
console.assert(!!chunk.ab || !!chunk.common);
console.assert(
!chunk.a || (!!chunk.b && chunk.a.length === chunk.b.length),
'common chunk needs same number of a and b lines: ',
chunk
);
return this.linesLeft(chunk).length;
}
private chunksToGroups(
chunks: DiffContent[],
offsetLeft: number,
offsetRight: number
): GrDiffGroup[] {
return chunks.map(chunk => {
const group = this.chunkToGroup(chunk, offsetLeft, offsetRight);
offsetLeft += this.chunkLength(chunk, Side.LEFT);
offsetRight += this.chunkLength(chunk, Side.RIGHT);
return group;
});
}
private chunkToGroup(
chunk: DiffContent,
offsetLeft: number,
offsetRight: number
): GrDiffGroup {
const type =
chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
const lines = this.linesFromChunk(chunk, offsetLeft, offsetRight);
const options = {
moveDetails: chunk.move_details,
dueToRebase: !!chunk.due_to_rebase,
ignoredWhitespaceOnly: !!chunk.common,
keyLocation: !!chunk.keyLocation,
};
if (chunk.skip !== undefined) {
return new GrDiffGroup({
type,
skip: chunk.skip,
offsetLeft,
offsetRight,
...options,
});
} else {
return new GrDiffGroup({
type,
lines,
...options,
});
}
}
private linesFromChunk(
chunk: DiffContent,
offsetLeft: number,
offsetRight: number
) {
if (chunk.ab) {
return chunk.ab.map((row, i) =>
this.lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
);
}
let lines: GrDiffLine[] = [];
if (chunk.a) {
// Avoiding a.push(...b) because that causes callstack overflows for
// large b, which can occur when large files are added removed.
lines = lines.concat(
this.linesFromRows(
GrDiffLineType.REMOVE,
chunk.a,
offsetLeft,
chunk.edit_a
)
);
}
if (chunk.b) {
// Avoiding a.push(...b) because that causes callstack overflows for
// large b, which can occur when large files are added removed.
lines = lines.concat(
this.linesFromRows(
GrDiffLineType.ADD,
chunk.b,
offsetRight,
chunk.edit_b
)
);
}
return lines;
}
// visible for testing
linesFromRows(
lineType: GrDiffLineType,
rows: string[],
offset: number,
intralineInfos?: number[][]
): GrDiffLine[] {
const grDiffHighlights = intralineInfos
? this.convertIntralineInfos(rows, intralineInfos)
: undefined;
return rows.map((row, i) =>
this.lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
);
}
private lineFromRow(
type: GrDiffLineType,
offsetLeft: number,
offsetRight: number,
row: string,
i: number,
highlights?: Highlights[]
): GrDiffLine {
const line = new GrDiffLine(type);
line.text = row;
if (type !== GrDiffLineType.ADD) line.beforeNumber = offsetLeft + i;
if (type !== GrDiffLineType.REMOVE) line.afterNumber = offsetRight + i;
if (highlights) {
line.hasIntralineInfo = true;
line.highlights = highlights.filter(hl => hl.contentIndex === i);
} else {
line.hasIntralineInfo = false;
}
return line;
}
private makeGroup(number: LineNumber) {
const line = new GrDiffLine(GrDiffLineType.BOTH);
line.beforeNumber = number;
line.afterNumber = number;
return new GrDiffGroup({type: GrDiffGroupType.BOTH, lines: [line]});
}
/**
* In order to show key locations, such as comments, out of the bounds of
* the selected context, treat them as separate chunks within the model so
* that the content (and context surrounding it) renders correctly.
*
* @param chunks DiffContents as returned from server.
* @return Finer grained DiffContents.
*/
// visible for testing
splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
const result = [];
let leftLineNum = 1;
let rightLineNum = 1;
for (const chunk of chunks) {
// If it isn't a common chunk, append it as-is and update line numbers.
if (!chunk.ab && !chunk.skip && !chunk.common) {
if (chunk.a) {
leftLineNum += chunk.a.length;
}
if (chunk.b) {
rightLineNum += chunk.b.length;
}
result.push(chunk);
continue;
}
if (chunk.common && chunk.a!.length !== chunk.b!.length) {
throw new Error(
'DiffContent with common=true must always have equal length'
);
}
const numLines = this.commonChunkLength(chunk);
const chunkEnds = this.findChunkEndsAtKeyLocations(
numLines,
leftLineNum,
rightLineNum
);
leftLineNum += numLines;
rightLineNum += numLines;
if (chunk.skip) {
result.push({
...chunk,
skip: chunk.skip,
keyLocation: false,
});
} else if (chunk.ab) {
result.push(
...this.splitAtChunkEnds(chunk.ab, chunkEnds).map(
({lines, keyLocation}) => {
return {
...chunk,
ab: lines,
keyLocation,
};
}
)
);
} else if (chunk.common) {
const aChunks = this.splitAtChunkEnds(chunk.a!, chunkEnds);
const bChunks = this.splitAtChunkEnds(chunk.b!, chunkEnds);
result.push(
...aChunks.map(({lines, keyLocation}, i) => {
return {
...chunk,
a: lines,
b: bChunks[i].lines,
keyLocation,
};
})
);
}
}
return result;
}
/**
* @return Offsets of the new chunk ends, including whether it's a key
* location.
*/
private findChunkEndsAtKeyLocations(
numLines: number,
leftOffset: number,
rightOffset: number
): ChunkEnd[] {
const result = [];
let lastChunkEnd = 0;
for (let i = 0; i < numLines; i++) {
// If this line should not be collapsed.
if (
this.keyLocations[Side.LEFT][leftOffset + i] ||
this.keyLocations[Side.RIGHT][rightOffset + i]
) {
// If any lines have been accumulated into the chunk leading up to
// this non-collapse line, then add them as a chunk and start a new
// one.
if (i > lastChunkEnd) {
result.push({offset: i, keyLocation: false});
lastChunkEnd = i;
}
// Add the non-collapse line as its own chunk.
result.push({offset: i + 1, keyLocation: true});
}
}
if (numLines > lastChunkEnd) {
result.push({offset: numLines, keyLocation: false});
}
return result;
}
private splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
const result = [];
let lastChunkEndOffset = 0;
for (const {offset, keyLocation} of chunkEnds) {
if (lastChunkEndOffset === offset) continue;
result.push({
lines: lines.slice(lastChunkEndOffset, offset),
keyLocation,
});
lastChunkEndOffset = offset;
}
return result;
}
/**
* Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
* for rendering.
*/
// visible for testing
convertIntralineInfos(
rows: string[],
intralineInfos: number[][]
): Highlights[] {
// +1 to account for the \n that is not part of the rows passed here
const lineLengths = rows.map(r => getStringLength(r) + 1);
let rowIndex = 0;
let idx = 0;
const normalized = [];
for (const [skipLength, markLength] of intralineInfos) {
let lineLength = lineLengths[rowIndex];
let j = 0;
while (j < skipLength) {
if (idx === lineLength) {
idx = 0;
lineLength = lineLengths[++rowIndex];
continue;
}
idx++;
j++;
}
let lineHighlight: Highlights = {
contentIndex: rowIndex,
startIndex: idx,
};
j = 0;
while (lineLength && j < markLength) {
if (idx === lineLength) {
idx = 0;
lineLength = lineLengths[++rowIndex];
normalized.push(lineHighlight);
lineHighlight = {
contentIndex: rowIndex,
startIndex: idx,
};
continue;
}
idx++;
j++;
}
lineHighlight.endIndex = idx;
normalized.push(lineHighlight);
}
return normalized;
}
}