diff2html/src/side-by-side-renderer.ts

319 lines
10 KiB
TypeScript
Raw Normal View History

2019-10-12 21:45:49 +00:00
import HoganJsUtils from "./hoganjs-utils";
import * as Rematch from "./rematch";
import * as renderUtils from "./render-utils";
import {
DiffLine,
LineType,
DiffFile,
DiffBlock,
DiffLineContext,
DiffLineDeleted,
DiffLineInserted,
DiffLineContent
} from "./types";
2019-10-12 21:45:49 +00:00
export interface SideBySideRendererConfig extends renderUtils.RenderConfig {
renderNothingWhenEmpty?: boolean;
matchingMaxComparisons?: number;
maxLineSizeInBlockForComparison?: number;
}
export const defaultSideBySideRendererConfig = {
...renderUtils.defaultRenderConfig,
renderNothingWhenEmpty: false,
matchingMaxComparisons: 2500,
maxLineSizeInBlockForComparison: 200
};
const genericTemplatesPath = "generic";
const baseTemplatesPath = "side-by-side";
const iconsBaseTemplatesPath = "icon";
const tagsBaseTemplatesPath = "tag";
export default class SideBySideRenderer {
private readonly hoganUtils: HoganJsUtils;
private readonly config: typeof defaultSideBySideRendererConfig;
2019-10-21 22:37:42 +00:00
constructor(hoganUtils: HoganJsUtils, config: SideBySideRendererConfig = {}) {
2019-10-12 21:45:49 +00:00
this.hoganUtils = hoganUtils;
this.config = { ...defaultSideBySideRendererConfig, ...config };
}
2019-10-21 22:37:42 +00:00
render(diffFiles: DiffFile[]): string | undefined {
2019-11-26 09:09:59 +00:00
const diffsHtml = diffFiles
2019-10-12 21:45:49 +00:00
.map(file => {
let diffs;
if (file.blocks.length) {
diffs = this.generateFileHtml(file);
2019-10-12 21:45:49 +00:00
} else {
diffs = this.generateEmptyDiff();
}
2019-11-26 09:09:59 +00:00
return this.makeFileDiffHtml(file, diffs);
2019-10-12 21:45:49 +00:00
})
.join("\n");
2019-11-26 09:09:59 +00:00
return this.hoganUtils.render(genericTemplatesPath, "wrapper", { content: diffsHtml });
2019-10-12 21:45:49 +00:00
}
// TODO: Make this private after improving tests
2019-11-26 09:09:59 +00:00
makeFileDiffHtml(file: DiffFile, diffs: FileHtml): string {
2019-11-26 09:44:09 +00:00
if (this.config.renderNothingWhenEmpty && Array.isArray(file.blocks) && file.blocks.length === 0) return "";
2019-10-12 21:45:49 +00:00
const fileDiffTemplate = this.hoganUtils.template(baseTemplatesPath, "file-diff");
const filePathTemplate = this.hoganUtils.template(genericTemplatesPath, "file-path");
const fileIconTemplate = this.hoganUtils.template(iconsBaseTemplatesPath, "file");
const fileTagTemplate = this.hoganUtils.template(tagsBaseTemplatesPath, renderUtils.getFileIcon(file));
return fileDiffTemplate.render({
file: file,
fileHtmlId: renderUtils.getHtmlId(file),
diffs: diffs,
filePath: filePathTemplate.render(
{
fileDiffName: renderUtils.filenameDiff(file)
},
{
fileIcon: fileIconTemplate,
fileTag: fileTagTemplate
}
)
});
}
// TODO: Make this private after improving tests
2019-11-26 09:44:09 +00:00
generateEmptyDiff(): FileHtml {
return {
right: "",
left: this.hoganUtils.render(genericTemplatesPath, "empty-diff", {
contentClass: "d2h-code-side-line",
CSSLineClass: renderUtils.CSSLineClass
})
};
2019-10-12 21:45:49 +00:00
}
generateFileHtml(file: DiffFile): FileHtml {
2019-11-26 09:44:09 +00:00
const matcher = Rematch.newMatcherFn(
Rematch.newDistanceFn((e: DiffLine) => renderUtils.deconstructLine(e.content, file.isCombined).content)
2019-10-21 22:37:42 +00:00
);
2019-10-12 21:45:49 +00:00
return file.blocks
.map(block => {
const fileHtml = {
left: this.makeHeaderHtml(block.header),
right: this.makeHeaderHtml("")
};
this.applyLineGroupping(block).forEach(([contextLines, oldLines, newLines]) => {
if (oldLines.length && newLines.length && !contextLines.length) {
this.applyRematchMatching(oldLines, newLines, matcher).map(([oldLines, newLines]) => {
const { left, right } = this.processChangedLines(file.isCombined, oldLines, newLines);
fileHtml.left += left;
fileHtml.right += right;
});
} else if (contextLines.length) {
contextLines.forEach(line => {
const { prefix, content } = renderUtils.deconstructLine(line.content, file.isCombined);
const { left, right } = this.generateLineHtml(
{
type: renderUtils.CSSLineClass.CONTEXT,
prefix: prefix,
content: content,
number: line.oldNumber
},
{
type: renderUtils.CSSLineClass.CONTEXT,
prefix: prefix,
content: content,
number: line.newNumber
}
);
fileHtml.left += left;
fileHtml.right += right;
});
} else if (oldLines.length || newLines.length) {
const { left, right } = this.processChangedLines(file.isCombined, oldLines, newLines);
fileHtml.left += left;
fileHtml.right += right;
} else {
console.error("Unknown state reached while processing groups of lines", contextLines, oldLines, newLines);
}
});
2019-10-12 21:45:49 +00:00
return fileHtml;
})
.reduce(
(accomulated, html) => {
return { left: accomulated.left + html.left, right: accomulated.right + html.right };
},
{ left: "", right: "" }
);
}
2019-10-12 21:45:49 +00:00
applyLineGroupping(block: DiffBlock): DiffLineGroups {
const blockLinesGroups: DiffLineGroups = [];
2019-10-12 21:45:49 +00:00
let oldLines: (DiffLineDeleted & DiffLineContent)[] = [];
let newLines: (DiffLineInserted & DiffLineContent)[] = [];
2019-10-12 21:45:49 +00:00
for (let i = 0; i < block.lines.length; i++) {
const diffLine = block.lines[i];
2019-10-12 21:45:49 +00:00
if (
(diffLine.type !== LineType.INSERT && newLines.length) ||
(diffLine.type === LineType.CONTEXT && oldLines.length > 0)
) {
blockLinesGroups.push([[], oldLines, newLines]);
oldLines = [];
newLines = [];
}
2019-10-12 21:45:49 +00:00
if (diffLine.type === LineType.CONTEXT) {
blockLinesGroups.push([[diffLine], [], []]);
} else if (diffLine.type === LineType.INSERT && oldLines.length === 0) {
blockLinesGroups.push([[], [], [diffLine]]);
} else if (diffLine.type === LineType.INSERT && oldLines.length > 0) {
newLines.push(diffLine);
} else if (diffLine.type === LineType.DELETE) {
oldLines.push(diffLine);
}
}
2019-10-12 21:45:49 +00:00
if (oldLines.length || newLines.length) {
blockLinesGroups.push([[], oldLines, newLines]);
oldLines = [];
newLines = [];
}
2019-10-12 21:45:49 +00:00
return blockLinesGroups;
}
2019-10-12 21:45:49 +00:00
applyRematchMatching(
oldLines: DiffLine[],
newLines: DiffLine[],
matcher: Rematch.MatcherFn<DiffLine>
): DiffLine[][][] {
const comparisons = oldLines.length * newLines.length;
const maxLineSizeInBlock = Math.max.apply(
null,
[0].concat(oldLines.concat(newLines).map(elem => elem.content.length))
);
const doMatching =
comparisons < this.config.matchingMaxComparisons &&
maxLineSizeInBlock < this.config.maxLineSizeInBlockForComparison &&
(this.config.matching === "lines" || this.config.matching === "words");
2019-10-12 21:45:49 +00:00
const matches = doMatching ? matcher(oldLines, newLines) : [[oldLines, newLines]];
2019-10-12 21:45:49 +00:00
return matches;
}
2019-10-12 21:45:49 +00:00
2019-11-26 09:44:09 +00:00
// TODO: Make this private after improving tests
makeHeaderHtml(blockHeader: string): string {
return this.hoganUtils.render(genericTemplatesPath, "block-header", {
2019-11-26 09:44:09 +00:00
CSSLineClass: renderUtils.CSSLineClass,
blockHeader: blockHeader,
lineClass: "d2h-code-side-linenumber",
contentClass: "d2h-code-side-line"
});
}
2019-10-12 21:45:49 +00:00
// TODO: Make this private after improving tests
processChangedLines(isCombined: boolean, oldLines: DiffLine[], newLines: DiffLine[]): FileHtml {
2019-10-12 21:45:49 +00:00
const fileHtml = {
right: "",
left: ""
};
const maxLinesNumber = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLinesNumber; i++) {
const oldLine = oldLines[i];
const newLine = newLines[i];
const diff =
oldLine !== undefined && newLine !== undefined
? renderUtils.diffHighlight(oldLine.content, newLine.content, isCombined, this.config)
: undefined;
const preparedOldLine =
oldLine !== undefined && oldLine.oldNumber !== undefined
? {
...(diff !== undefined
? {
prefix: diff.oldLine.prefix,
content: diff.oldLine.content,
type: renderUtils.CSSLineClass.DELETE_CHANGES
}
: {
...renderUtils.deconstructLine(oldLine.content, isCombined),
type: renderUtils.toCSSClass(oldLine.type)
}),
number: oldLine.oldNumber
}
: undefined;
const preparedNewLine =
newLine !== undefined && newLine.newNumber !== undefined
? {
...(diff !== undefined
? {
prefix: diff.newLine.prefix,
content: diff.newLine.content,
type: renderUtils.CSSLineClass.INSERT_CHANGES
}
: {
...renderUtils.deconstructLine(newLine.content, isCombined),
type: renderUtils.toCSSClass(newLine.type)
}),
number: newLine.newNumber
}
: undefined;
const { left, right } = this.generateLineHtml(preparedOldLine, preparedNewLine);
fileHtml.left += left;
fileHtml.right += right;
2019-10-12 21:45:49 +00:00
}
return fileHtml;
}
// TODO: Make this private after improving tests
generateLineHtml(oldLine?: DiffPreparedLine, newLine?: DiffPreparedLine): FileHtml {
return {
left: this.generateSingleHtml(oldLine),
right: this.generateSingleHtml(newLine)
};
}
generateSingleHtml(line?: DiffPreparedLine): string {
const lineClass = "d2h-code-side-linenumber";
const contentClass = "d2h-code-side-line";
2019-10-12 21:45:49 +00:00
return this.hoganUtils.render(genericTemplatesPath, "line", {
type: line?.type || `${renderUtils.CSSLineClass.CONTEXT} d2h-emptyplaceholder`,
lineClass: line !== undefined ? lineClass : `${lineClass} d2h-code-side-emptyplaceholder`,
contentClass: line !== undefined ? contentClass : `${contentClass} d2h-code-side-emptyplaceholder`,
prefix: line?.prefix === " " ? "&nbsp;" : line?.prefix || "&nbsp;",
content: line?.content || "&nbsp;",
lineNumber: line?.number
});
2019-10-12 21:45:49 +00:00
}
}
2019-11-26 09:09:59 +00:00
type DiffLineGroups = [
(DiffLineContext & DiffLineContent)[],
(DiffLineDeleted & DiffLineContent)[],
(DiffLineInserted & DiffLineContent)[]
][];
type DiffPreparedLine = {
type: renderUtils.CSSLineClass;
prefix: string;
content: string;
number: number;
};
2019-11-26 09:09:59 +00:00
type FileHtml = {
left: string;
right: string;
2019-11-26 09:09:59 +00:00
};