git-split-diffs-api
Version:
Split diffs, now in your terminal
1,320 lines (1,291 loc) • 39.3 kB
JavaScript
// src/index.ts
import { Readable as Readable2, Writable as Writable2 } from "stream";
import chalk from "chalk";
import { exec } from "child_process";
import terminalSize from "terminal-size";
import * as util from "util";
// src/context.ts
import * as shiki from "shiki";
// src/SpannedString.ts
import wcwidth from "wcwidth";
var SpannedString = class _SpannedString {
constructor(string, spans, nextId) {
this._string = "";
this._spanMarkers = [void 0];
this._nextId = 0;
this._string = string;
this._spanMarkers = spans;
this._nextId = nextId;
}
static create() {
return new _SpannedString("", [void 0], 0);
}
addSpan(startIndex, endIndex, attribute) {
this._spanMarkers[startIndex] = this._spanMarkers[startIndex] ?? [];
this._spanMarkers[startIndex].push({
id: this._nextId,
attribute,
isStart: true
});
this._spanMarkers[endIndex] = this._spanMarkers[endIndex] ?? [];
this._spanMarkers[endIndex].push({
id: this._nextId,
attribute,
isStart: false
});
this._nextId++;
return this;
}
appendString(string, ...attributes) {
const startIndex = this._string.length;
const endIndex = startIndex + string.length;
this._string += string;
this._spanMarkers = this._spanMarkers.concat(new Array(string.length));
for (const attribute of attributes) {
this.addSpan(startIndex, endIndex, attribute);
}
return this;
}
appendSpannedString(other) {
this._string = this._string.concat(other._string);
const spanMarkers = this._spanMarkers.concat(
new Array(other._spanMarkers.length - 1)
);
const overlappingSpanIndex = this._spanMarkers.length - 1;
for (let otherIndex = 0; otherIndex < other._spanMarkers.length; otherIndex++) {
const otherSpans = other._spanMarkers[otherIndex];
if (otherSpans) {
const index = otherIndex + overlappingSpanIndex;
spanMarkers[index] = spanMarkers[index] ?? [];
spanMarkers[index]?.push(
...otherSpans.map((span) => ({
...span,
// Remap ids to avoid collisions
id: span.id + this._nextId
}))
);
}
}
this._spanMarkers = spanMarkers;
this._nextId = this._nextId + other._nextId;
return this;
}
slice(startIndex, endIndex = this._string.length) {
if (startIndex < 0 || endIndex < 0) {
throw new Error("Invalid start or end index");
}
if (startIndex > this._string.length) {
startIndex = this._string.length;
}
if (endIndex > this._string.length) {
endIndex = this._string.length;
}
let index = 0;
const activeSpansById = /* @__PURE__ */ new Map();
const updatedActiveSpans = () => {
for (const span of this._spanMarkers[index] ?? []) {
if (span.isStart) {
activeSpansById.set(span.id, span);
} else {
activeSpansById.delete(span.id);
}
}
};
const getActiveSpans = () => {
return [...activeSpansById.values()];
};
const newSpanMarkers = new Array(
endIndex + 1 - startIndex
);
while (index < startIndex) {
updatedActiveSpans();
index++;
}
newSpanMarkers[0] = getActiveSpans().map((span) => ({ ...span }));
while (index < endIndex) {
const spans = this._spanMarkers[index];
updatedActiveSpans();
if (spans) {
const newIndex = index - startIndex;
newSpanMarkers[newIndex] = newSpanMarkers[newIndex] ?? [];
newSpanMarkers[newIndex].push(...spans);
}
index++;
}
newSpanMarkers[endIndex - startIndex] = getActiveSpans().map(
(span) => ({ ...span, isStart: false })
);
return new _SpannedString(
this._string.slice(startIndex, endIndex),
newSpanMarkers,
this._nextId
);
}
fillWidth(width, fillString) {
const paddingLength = width - this.getWidth();
if (paddingLength > 0) {
this.appendString("".padEnd(paddingLength, fillString));
}
return this;
}
getString() {
return this._string;
}
/**
* Returns the screen width of the string in columns, i.e. accounting for
* characters that may occupy more than one character width in the terminal.
*/
getWidth() {
return wcwidth(this._string);
}
/**
* Returns the screen width per character.
*/
getCharWidths() {
const charWidths = [];
for (const char of this._string) {
charWidths.push(wcwidth(char));
}
return charWidths;
}
*iterSubstrings() {
const activeSpansById = /* @__PURE__ */ new Map();
function getActiveAttributes() {
return Array.from(activeSpansById.values()).sort((a, b) => a.id - b.id).map((span) => span.attribute);
}
let lastIndex = 0;
for (let spanIndex = 0; spanIndex <= this._string.length; spanIndex++) {
const spans = this._spanMarkers[spanIndex];
if (spans === void 0 || spans.length === 0) {
continue;
}
if (spanIndex > lastIndex) {
yield [
this._string.slice(lastIndex, spanIndex),
getActiveAttributes()
];
}
for (const span of spans) {
if (span.isStart) {
activeSpansById.set(span.id, span);
} else {
activeSpansById.delete(span.id);
}
}
lastIndex = spanIndex;
}
if (lastIndex < this._string.length) {
yield [this._string.slice(lastIndex), getActiveAttributes()];
}
}
};
// src/themes.ts
import * as assert from "assert";
import * as path from "path";
import * as fs from "fs";
var ThemeColorName = /* @__PURE__ */ ((ThemeColorName2) => {
ThemeColorName2["DEFAULT_COLOR"] = "DEFAULT_COLOR";
ThemeColorName2["COMMIT_HEADER_LABEL_COLOR"] = "COMMIT_HEADER_LABEL_COLOR";
ThemeColorName2["COMMIT_HEADER_COLOR"] = "COMMIT_HEADER_COLOR";
ThemeColorName2["COMMIT_SHA_COLOR"] = "COMMIT_SHA_COLOR";
ThemeColorName2["COMMIT_AUTHOR_COLOR"] = "COMMIT_AUTHOR_COLOR";
ThemeColorName2["COMMIT_DATE_COLOR"] = "COMMIT_DATE_COLOR";
ThemeColorName2["COMMIT_TITLE_COLOR"] = "COMMIT_TITLE_COLOR";
ThemeColorName2["COMMIT_MESSAGE_COLOR"] = "COMMIT_MESSAGE_COLOR";
ThemeColorName2["BORDER_COLOR"] = "BORDER_COLOR";
ThemeColorName2["FILE_NAME_COLOR"] = "FILE_NAME_COLOR";
ThemeColorName2["HUNK_HEADER_COLOR"] = "HUNK_HEADER_COLOR";
ThemeColorName2["DELETED_WORD_COLOR"] = "DELETED_WORD_COLOR";
ThemeColorName2["DELETED_LINE_COLOR"] = "DELETED_LINE_COLOR";
ThemeColorName2["DELETED_LINE_NO_COLOR"] = "DELETED_LINE_NO_COLOR";
ThemeColorName2["INSERTED_WORD_COLOR"] = "INSERTED_WORD_COLOR";
ThemeColorName2["INSERTED_LINE_COLOR"] = "INSERTED_LINE_COLOR";
ThemeColorName2["INSERTED_LINE_NO_COLOR"] = "INSERTED_LINE_NO_COLOR";
ThemeColorName2["UNMODIFIED_LINE_COLOR"] = "UNMODIFIED_LINE_COLOR";
ThemeColorName2["UNMODIFIED_LINE_NO_COLOR"] = "UNMODIFIED_LINE_NO_COLOR";
ThemeColorName2["MISSING_LINE_COLOR"] = "MISSING_LINE_COLOR";
return ThemeColorName2;
})(ThemeColorName || {});
function hexToRgba(hex) {
assert.ok(hex.length === 7 || hex.length === 9 || hex.length === 4, hex);
hex = hex.slice(1);
let hexNo = parseInt(hex, 16);
const bits = hex.length === 3 ? 4 : 8;
let a = 255;
if (hex.length === 8) {
a = hexNo & 255;
hexNo >>>= bits;
}
const b = hexNo & 255;
hexNo >>>= bits;
const g = hexNo & 255;
hexNo >>>= bits;
const r = hexNo & 255;
return { r, g, b, a };
}
function mergeColors(a, b) {
if (!b || b.a === 0) {
return a;
}
if (!a) {
return b;
}
const t = 1 - b.a / 255;
return {
r: b.r * (1 - t) + a.r * t,
g: b.g * (1 - t) + a.g * t,
b: b.b * (1 - t) + a.b * t,
a: b.a * (1 - t) + a.a * t
};
}
function mergeModifiers(a, b) {
if (a && b) {
return a.concat(b);
}
return a ?? b;
}
function mergeThemeColors(a, b) {
return {
color: mergeColors(a.color, b.color),
backgroundColor: mergeColors(a.backgroundColor, b.backgroundColor),
modifiers: mergeModifiers(a.modifiers, b.modifiers)
};
}
function reduceThemeColors(colors) {
let themeColor = {};
for (let i = colors.length - 1; i >= 0; i--) {
themeColor = mergeThemeColors(themeColor, colors[i]);
}
return themeColor;
}
function parseColorDefinition(definition) {
return {
color: definition.color ? hexToRgba(definition.color) : void 0,
backgroundColor: definition.backgroundColor ? hexToRgba(definition.backgroundColor) : void 0,
modifiers: definition.modifiers
};
}
function loadThemeDefinition(themesDir, themeName) {
return JSON.parse(
fs.readFileSync(path.join(themesDir, `${themeName}.json`)).toString()
);
}
function loadTheme(themesDir, themeName) {
const themeDefinition = loadThemeDefinition(themesDir, themeName);
const theme = {
SYNTAX_HIGHLIGHTING_THEME: themeDefinition.SYNTAX_HIGHLIGHTING_THEME
};
const themeColorNames = Object.keys(ThemeColorName);
for (const variableName of themeColorNames) {
const value = themeDefinition[variableName];
if (!value) {
assert.fail(`${variableName} is missing in theme`);
}
theme[variableName] = parseColorDefinition(value);
}
return theme;
}
// src/formattedString.ts
var FormattedString = class extends SpannedString {
};
function T() {
return FormattedString.create();
}
function applyFormatting(context, string) {
const { CHALK, DEFAULT_COLOR } = context;
let formattedString = "";
for (const [substring, colors] of string.iterSubstrings()) {
let formattedSubstring = substring;
const themeColor = reduceThemeColors([...colors, DEFAULT_COLOR]);
const { color, backgroundColor, modifiers } = themeColor;
if (color) {
formattedSubstring = CHALK.rgb(
Math.floor(color.r),
Math.floor(color.g),
Math.floor(color.b)
)(formattedSubstring);
}
if (backgroundColor) {
formattedSubstring = CHALK.bgRgb(
Math.floor(backgroundColor.r),
Math.floor(backgroundColor.g),
Math.floor(backgroundColor.b)
)(formattedSubstring);
}
if (modifiers) {
for (const modifier of modifiers) {
formattedSubstring = CHALK[modifier](formattedSubstring);
}
}
formattedString += formattedSubstring;
}
return formattedString;
}
// src/context.ts
async function getContextForConfig(config, chalk2, screenWidth) {
const SCREEN_WIDTH = screenWidth;
const HORIZONTAL_SEPARATOR = T().fillWidth(SCREEN_WIDTH, "\u2500").addSpan(0, SCREEN_WIDTH, config.BORDER_COLOR);
let HIGHLIGHTER = void 0;
if (config.SYNTAX_HIGHLIGHTING_THEME) {
HIGHLIGHTER = await shiki.createHighlighter({
themes: [config.SYNTAX_HIGHLIGHTING_THEME],
langs: []
});
}
return {
...config,
CHALK: chalk2,
SCREEN_WIDTH,
HORIZONTAL_SEPARATOR,
HIGHLIGHTER
};
}
// src/getGitConfig.ts
import path2 from "path";
import { fileURLToPath } from "url";
var DEFAULT_MIN_LINE_WIDTH = 80;
var DEFAULT_THEME_DIRECTORY = path2.resolve(
path2.dirname(fileURLToPath(import.meta.url)),
"..",
"themes"
);
var DEFAULT_THEME_NAME = "dark";
var GIT_CONFIG_KEY_PREFIX = "split-diffs";
var GIT_CONFIG_LINE_REGEX = new RegExp(
`${GIT_CONFIG_KEY_PREFIX}\\.([^=]+)=(.*)`
);
function extractFromGitConfigString(configString) {
const rawConfig = {};
for (const line of configString.trim().split("\n")) {
const match = line.match(GIT_CONFIG_LINE_REGEX);
if (!match) {
continue;
}
const [, key, value] = match;
rawConfig[key] = value;
}
return rawConfig;
}
function getGitConfig(configString) {
const rawConfig = extractFromGitConfigString(configString);
let minLineWidth = DEFAULT_MIN_LINE_WIDTH;
try {
const parsedMinLineWidth = parseInt(rawConfig["min-line-width"], 10);
if (!isNaN(parsedMinLineWidth)) {
minLineWidth = parsedMinLineWidth;
}
} catch {
}
return {
MIN_LINE_WIDTH: minLineWidth,
WRAP_LINES: rawConfig["wrap-lines"] !== "false",
HIGHLIGHT_LINE_CHANGES: rawConfig["highlight-line-changes"] !== "false",
THEME_DIRECTORY: rawConfig["theme-directory"] ?? DEFAULT_THEME_DIRECTORY,
THEME_NAME: rawConfig["theme-name"] ?? DEFAULT_THEME_NAME,
SYNTAX_HIGHLIGHTING_THEME: rawConfig["syntax-highlighting-theme"]
};
}
// src/transformContentsStreaming.ts
import stream, { Readable } from "stream";
// src/iterLinesFromReadable.ts
var NEWLINE_REGEX = /\n/g;
function* yieldLinesFromString(string) {
string = string.replace(/\r/g, "");
let lastIndex = 0;
let match;
while (match = NEWLINE_REGEX.exec(string)) {
yield string.slice(lastIndex, match.index);
lastIndex = match.index + match[0].length;
}
return string.slice(lastIndex);
}
async function* iterlinesFromReadable(readable) {
let string = "";
for await (const chunk of readable) {
string += chunk.toString();
string = yield* yieldLinesFromString(string);
}
yield string;
}
// src/iterReplaceTabsWithSpaces.ts
var TAB_TO_SPACES = " ";
async function* iterReplaceTabsWithSpaces(context, lines) {
for await (const line of lines) {
yield line.replace(/\t/g, TAB_TO_SPACES);
}
}
// src/iterSideBySideDiffs.ts
import ansiRegex from "ansi-regex";
import * as assert2 from "assert";
// src/wrapSpannedStringByWord.ts
var SPACE_REGEX = /\s/;
function getLineBreaksForString(string, charWidths, width) {
const lineBreaks = [];
let budget = width;
let curLineEnd = 0;
function flushLine() {
lineBreaks.push(curLineEnd);
budget = width;
}
function pushWord(startIndex, endIndex) {
let wordWidth = 0;
for (let i = startIndex; i < endIndex; i++) {
wordWidth += charWidths[i];
}
if (wordWidth <= budget) {
curLineEnd = endIndex;
budget -= wordWidth;
return;
}
if (wordWidth <= width) {
flushLine();
curLineEnd = endIndex;
budget -= wordWidth;
return;
}
for (let i = startIndex; i < endIndex; i++) {
const charLength = charWidths[i];
if (budget < charLength) {
flushLine();
}
budget -= charLength;
curLineEnd++;
}
}
let prevIndex = 0;
let curIndex = 1;
let prevIsSpace = SPACE_REGEX.test(string[prevIndex]);
while (curIndex < string.length) {
const isSpace = SPACE_REGEX.test(string[curIndex]);
if (isSpace) {
pushWord(prevIndex, curIndex);
prevIndex = curIndex;
} else if (prevIsSpace) {
pushWord(prevIndex, curIndex);
prevIndex = curIndex;
}
prevIsSpace = isSpace;
curIndex++;
}
if (prevIndex < curIndex) {
pushWord(prevIndex, curIndex);
}
if (budget < width) {
flushLine();
}
return lineBreaks;
}
function* wrapSpannedStringByWord(spannedString, width) {
const string = spannedString.getString();
const charWidths = spannedString.getCharWidths();
const stringWidth = charWidths.reduce((a, b) => a + b, 0);
if (stringWidth < width) {
yield spannedString;
return;
}
const lineBreaks = getLineBreaksForString(string, charWidths, width);
let prevLineBreak = 0;
for (const lineBreak of lineBreaks) {
yield spannedString.slice(prevLineBreak, lineBreak);
prevLineBreak = lineBreak;
}
if (prevLineBreak < stringWidth - 1) {
yield spannedString.slice(prevLineBreak);
}
}
// src/iterFitTextToWidth.ts
function* iterFitTextToWidth(context, formattedString, width, backgroundColor) {
if (context.WRAP_LINES) {
for (const wrappedLine of wrapSpannedStringByWord(
formattedString,
width
)) {
wrappedLine.fillWidth(width);
if (backgroundColor) {
wrappedLine.addSpan(0, width, backgroundColor);
}
yield wrappedLine;
}
} else {
const truncatedLine = formattedString.slice(0, width).fillWidth(width);
if (backgroundColor) {
truncatedLine.addSpan(0, width, backgroundColor);
}
yield truncatedLine;
}
}
// src/iterFormatCommitBodyLine.ts
function* iterFormatCommitBodyLine(context, line, isFirstLine) {
const { COMMIT_TITLE_COLOR, COMMIT_MESSAGE_COLOR, SCREEN_WIDTH } = context;
const formattedLine = T().appendString(line);
if (isFirstLine) {
formattedLine.addSpan(0, line.length, COMMIT_TITLE_COLOR);
}
yield* iterFitTextToWidth(
context,
formattedLine,
SCREEN_WIDTH,
COMMIT_MESSAGE_COLOR
);
}
// src/iterFormatCommitHeaderLine.ts
function* iterFormatCommitHeaderLine(context, line) {
const {
COMMIT_HEADER_LABEL_COLOR,
COMMIT_AUTHOR_COLOR,
COMMIT_HEADER_COLOR,
COMMIT_DATE_COLOR,
COMMIT_SHA_COLOR,
SCREEN_WIDTH
} = context;
const [label] = line.split(" ", 1);
let labelColor;
switch (label) {
case "commit":
labelColor = COMMIT_SHA_COLOR;
break;
case "Author:":
labelColor = COMMIT_AUTHOR_COLOR;
break;
case "Date:":
labelColor = COMMIT_DATE_COLOR;
break;
}
const formattedLine = T().appendString(line).addSpan(0, label.length, COMMIT_HEADER_LABEL_COLOR);
if (labelColor) {
formattedLine.addSpan(0, SCREEN_WIDTH - label.length - 1, labelColor);
}
yield* iterFitTextToWidth(
context,
formattedLine,
SCREEN_WIDTH,
COMMIT_HEADER_COLOR
);
}
// src/iterFormatFileName.ts
function* iterFormatFileName(context, fileNameA, fileNameB) {
const {
HORIZONTAL_SEPARATOR,
INSERTED_LINE_COLOR,
DELETED_LINE_COLOR,
INSERTED_LINE_NO_COLOR,
DELETED_LINE_NO_COLOR,
FILE_NAME_COLOR,
SCREEN_WIDTH
} = context;
yield HORIZONTAL_SEPARATOR;
const formattedString = T().appendString(" \u25A0\u25A0 ");
let fileNameLabel;
if (!fileNameA) {
formattedString.addSpan(1, 3, INSERTED_LINE_NO_COLOR).addSpan(1, 3, INSERTED_LINE_COLOR);
fileNameLabel = fileNameB;
} else if (!fileNameB) {
formattedString.addSpan(1, 3, DELETED_LINE_NO_COLOR).addSpan(1, 3, DELETED_LINE_COLOR);
fileNameLabel = fileNameA;
} else if (fileNameA === fileNameB) {
formattedString.addSpan(1, 2, DELETED_LINE_NO_COLOR).addSpan(2, 3, INSERTED_LINE_NO_COLOR).addSpan(1, 2, DELETED_LINE_COLOR).addSpan(2, 3, INSERTED_LINE_COLOR);
fileNameLabel = fileNameA;
} else {
formattedString.addSpan(1, 2, DELETED_LINE_NO_COLOR).addSpan(2, 3, INSERTED_LINE_NO_COLOR).addSpan(1, 2, DELETED_LINE_COLOR).addSpan(2, 3, INSERTED_LINE_COLOR);
fileNameLabel = `${fileNameA} -> ${fileNameB}`;
}
formattedString.appendString(fileNameLabel);
yield* iterFitTextToWidth(
context,
formattedString,
SCREEN_WIDTH,
FILE_NAME_COLOR
);
yield HORIZONTAL_SEPARATOR;
}
// src/highlightChangesInLine.ts
import { diffWords } from "diff";
// src/zip.ts
function* zip(...iterables) {
const iterators = iterables.map((iterable) => iterable[Symbol.iterator]());
while (true) {
const values = [];
let hasMore = false;
for (const iterator of iterators) {
const { done, value } = iterator.next();
hasMore ||= !done;
values.push(value);
}
if (!hasMore) {
return;
}
yield values;
}
}
async function* zipAsync(...iterables) {
const iterators = iterables.map(
(iterable) => iterable[Symbol.asyncIterator]()
);
while (true) {
const values = [];
let hasMore = false;
for (const iterator of iterators) {
const { done, value } = await iterator.next();
hasMore ||= !done;
values.push(value);
}
if (!hasMore) {
return;
}
yield values;
}
}
// src/highlightChangesInLine.ts
var HIGHLIGHT_CHANGE_RATIO = 1;
function getChangesInLine(context, lineA, lineB) {
const { HIGHLIGHT_LINE_CHANGES } = context;
if (!HIGHLIGHT_LINE_CHANGES || lineA === null || lineB === null) {
return null;
}
const lineTextA = lineA.slice(1);
const lineTextB = lineB.slice(1);
const changes = diffWords(lineTextA, lineTextB, {
ignoreCase: false,
ignoreWhitespace: false
});
let changedWords = 0;
let totalWords = 0;
for (const { added, removed, count } of changes) {
if (added || removed) {
changedWords += count ?? 0;
} else {
totalWords += count ?? 0;
}
}
if (changedWords > totalWords * HIGHLIGHT_CHANGE_RATIO) {
return null;
}
return changes;
}
function getChangesInLines(context, linesA, linesB) {
const changes = [];
for (const [lineA, lineB] of zip(linesA, linesB)) {
changes.push(getChangesInLine(context, lineA ?? null, lineB ?? null));
}
return changes;
}
function highlightChangesInLine(context, linePrefix, formattedLine, changes) {
if (!changes) {
return;
}
const { DELETED_WORD_COLOR, INSERTED_WORD_COLOR, UNMODIFIED_LINE_COLOR } = context;
let wordColor;
switch (linePrefix) {
case "-":
wordColor = DELETED_WORD_COLOR;
break;
case "+":
wordColor = INSERTED_WORD_COLOR;
break;
default:
wordColor = UNMODIFIED_LINE_COLOR;
break;
}
let lineIndex = 0;
for (const change of changes) {
if (change.removed && linePrefix === "+") {
continue;
}
if (change.added && linePrefix === "-") {
continue;
}
if (change.removed || change.added) {
formattedLine.addSpan(
lineIndex,
lineIndex + change.value.length,
wordColor
);
}
lineIndex += change.value.length;
}
}
// src/highlightSyntaxInLine.ts
import path3 from "path";
import * as shiki2 from "shiki";
function parseShikiColor(token) {
let modifiers;
if (token.fontStyle !== void 0 && token.fontStyle !== shiki2.FontStyle.NotSet && token.fontStyle !== shiki2.FontStyle.None) {
modifiers = [];
if (token.fontStyle & shiki2.FontStyle.Bold) {
modifiers.push("bold");
}
if (token.fontStyle & shiki2.FontStyle.Italic) {
modifiers.push("italic");
}
if (token.fontStyle & shiki2.FontStyle.Underline) {
modifiers.push("underline");
}
}
const themeColor = parseColorDefinition({
color: token.color,
backgroundColor: token.bgColor,
modifiers
});
return themeColor;
}
async function highlightSyntaxInLine(line, fileName, highlighter, theme) {
const language = path3.extname(fileName).slice(1);
if (!shiki2.bundledLanguages[language]) {
return;
}
await highlighter.loadLanguage(language);
const { tokens } = highlighter.codeToTokens(line.getString(), {
includeExplanation: false,
lang: language,
theme
});
for (const token of tokens.flat()) {
line.addSpan(
token.offset,
token.offset + token.content.length,
parseShikiColor(token)
);
}
}
// src/formatAndFitHunkLine.ts
var LINE_NUMBER_WIDTH = 5;
async function* formatAndFitHunkLine(context, lineWidth, fileName, lineNo, line, changes) {
const {
MISSING_LINE_COLOR,
DELETED_LINE_COLOR,
DELETED_LINE_NO_COLOR,
INSERTED_LINE_COLOR,
INSERTED_LINE_NO_COLOR,
UNMODIFIED_LINE_COLOR,
UNMODIFIED_LINE_NO_COLOR
} = context;
const blankLine = "".padStart(lineWidth);
if (line === null || lineNo === 0) {
yield T().appendString(blankLine, MISSING_LINE_COLOR);
return;
}
const linePrefix = line.slice(0, 1);
const lineText = line.slice(1);
let lineColor;
let lineNoColor;
switch (linePrefix) {
case "-":
lineColor = DELETED_LINE_COLOR;
lineNoColor = DELETED_LINE_NO_COLOR;
break;
case "+":
lineColor = INSERTED_LINE_COLOR;
lineNoColor = INSERTED_LINE_NO_COLOR;
break;
default:
lineColor = UNMODIFIED_LINE_COLOR;
lineNoColor = UNMODIFIED_LINE_NO_COLOR;
break;
}
const lineTextWidth = lineWidth - 2 - 1 - 1 - LINE_NUMBER_WIDTH;
let isFirstLine = true;
const formattedLine = T().appendString(lineText);
highlightChangesInLine(context, linePrefix, formattedLine, changes);
if (context.HIGHLIGHTER && context.SYNTAX_HIGHLIGHTING_THEME) {
await highlightSyntaxInLine(
formattedLine,
fileName,
context.HIGHLIGHTER,
context.SYNTAX_HIGHLIGHTING_THEME
);
}
for (const fittedLine of iterFitTextToWidth(
context,
formattedLine,
lineTextWidth
)) {
const lineNoText = (isFirstLine ? lineNo.toString() : "").padStart(LINE_NUMBER_WIDTH) + " ";
const wrappedLinePrefix = (isFirstLine ? linePrefix : "").padStart(2).padEnd(3);
const hunkLine = T().appendString(lineNoText, lineNoColor).appendString(wrappedLinePrefix).appendSpannedString(fittedLine);
hunkLine.addSpan(0, hunkLine.getString().length, lineColor);
yield hunkLine;
isFirstLine = false;
}
}
// src/iterFormatHunkSplit.ts
async function* iterFormatHunkSplit(context, hunkParts, lineChanges) {
const { MISSING_LINE_COLOR } = context;
const lineWidth = Math.floor(context.SCREEN_WIDTH / hunkParts.length);
const blankLine = "".padStart(lineWidth);
const lineNos = hunkParts.map((part) => part.startLineNo);
const numDeletes = hunkParts.map(() => 0);
for (const [changes, ...hunkPartLines] of zip(
lineChanges,
...hunkParts.map((part) => part.lines)
)) {
hunkPartLines.forEach((hunkPartLine, i) => {
const prefix = hunkPartLine?.slice(0, 1) ?? null;
if (prefix === "-") {
numDeletes[i]++;
} else {
lineNos[i] -= numDeletes[i];
numDeletes[i] = 0;
}
});
const formattedLineIterables = hunkPartLines.map(
(hunkPartLine, i) => formatAndFitHunkLine(
context,
lineWidth,
hunkParts[i].fileName,
lineNos[i],
hunkPartLine ?? null,
changes ?? null
)
);
const missingLine = T().appendString(blankLine, MISSING_LINE_COLOR);
for await (const formattedLines of zipAsync(
...formattedLineIterables
)) {
const formattedLine = T();
for (const line of formattedLines) {
formattedLine.appendSpannedString(line ?? missingLine);
}
yield formattedLine;
}
hunkPartLines.forEach((hunkPartLine, i) => {
if (hunkPartLine !== null && hunkPartLine !== void 0) {
lineNos[i]++;
}
});
}
}
// src/iterFormatHunkUnified.ts
async function* iterFormatUnifiedDiffHunkUnified(context, hunkParts, lineChanges) {
const lineWidth = context.SCREEN_WIDTH;
const [
{ fileName: fileNameA, lines: hunkLinesA },
{ fileName: fileNameB, lines: hunkLinesB }
] = hunkParts;
let [{ startLineNo: lineNoA }, { startLineNo: lineNoB }] = hunkParts;
let indexA = 0, indexB = 0;
while (indexA < hunkLinesA.length) {
const hunkLineA = hunkLinesA[indexA];
const prefixA = hunkLineA?.slice(0, 1) ?? null;
switch (prefixA) {
case null:
break;
case "-":
yield* formatAndFitHunkLine(
context,
lineWidth,
fileNameA,
lineNoA,
hunkLineA,
lineChanges[indexA]
);
lineNoA++;
break;
default:
while (indexB < indexA) {
const hunkLineB = hunkLinesB[indexB];
if (hunkLineB !== null) {
yield* formatAndFitHunkLine(
context,
lineWidth,
fileNameB,
lineNoB,
hunkLineB,
lineChanges[indexB]
);
lineNoB++;
}
indexB++;
}
yield* formatAndFitHunkLine(
context,
lineWidth,
fileNameA,
lineNoA,
hunkLineA,
lineChanges[indexB]
);
lineNoA++;
lineNoB++;
indexB++;
}
indexA++;
}
while (indexB < hunkLinesB.length) {
const hunkLineB = hunkLinesB[indexB];
if (hunkLineB !== null) {
yield* formatAndFitHunkLine(
context,
lineWidth,
fileNameB,
lineNoB,
hunkLineB,
lineChanges[indexB]
);
lineNoB++;
}
indexB++;
}
}
async function* iterFormatCombinedDiffHunkUnified(context, hunkParts, lineChanges) {
const lineWidth = context.SCREEN_WIDTH;
const { fileName, lines, startLineNo } = hunkParts[hunkParts.length - 1];
let lineNo = startLineNo;
let numDeletes = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const prefix = line?.slice(0, 1) ?? null;
if (prefix == "-") {
numDeletes++;
} else {
lineNo -= numDeletes;
numDeletes = 0;
}
yield* formatAndFitHunkLine(
context,
lineWidth,
fileName,
lineNo,
line,
lineChanges[i]
);
lineNo++;
}
}
// src/iterFormatHunk.ts
async function* iterFormatHunk(context, diffType, hunkHeaderLine, hunkParts) {
const { HUNK_HEADER_COLOR, SCREEN_WIDTH, MIN_LINE_WIDTH } = context;
yield* iterFitTextToWidth(
context,
T().appendString(hunkHeaderLine),
SCREEN_WIDTH,
HUNK_HEADER_COLOR
);
const changes = getChangesInLines(
context,
hunkParts[0].lines,
hunkParts[1].lines
);
const splitDiffs = SCREEN_WIDTH >= MIN_LINE_WIDTH * hunkParts.length;
if (splitDiffs) {
yield* iterFormatHunkSplit(context, hunkParts, changes);
} else if (diffType === "unified-diff") {
yield* iterFormatUnifiedDiffHunkUnified(context, hunkParts, changes);
} else if (diffType === "combined-diff") {
yield* iterFormatCombinedDiffHunkUnified(context, hunkParts, changes);
}
}
// src/iterSideBySideDiffs.ts
var ANSI_COLOR_CODE_REGEX = ansiRegex();
var BINARY_FILES_DIFF_REGEX = /^Binary files (?:a\/(.*)|\/dev\/null) and (?:b\/(.*)|\/dev\/null) differ$/;
var COMBINED_HUNK_HEADER_START_REGEX = /^(@{2,}) /;
async function* iterSideBySideDiffsFormatted(context, lines) {
const { HORIZONTAL_SEPARATOR } = context;
let state = "unknown";
let isFirstCommitBodyLine = false;
let fileNameA = "";
let fileNameB = "";
function* yieldFileName() {
yield* iterFormatFileName(context, fileNameA, fileNameB);
}
let hunkParts = [];
let hunkHeaderLine = "";
async function* yieldHunk(diffType) {
yield* iterFormatHunk(context, diffType, hunkHeaderLine, hunkParts);
for (const hunkPart of hunkParts) {
hunkPart.startLineNo = -1;
hunkPart.lines = [];
}
}
async function* flushPending() {
if (state === "unified-diff" || state === "combined-diff") {
yield* yieldFileName();
} else if (state === "unified-diff-hunk-body") {
yield* yieldHunk("unified-diff");
} else if (state === "combined-diff-hunk-body") {
yield* yieldHunk("combined-diff");
}
}
for await (const rawLine of lines) {
const line = rawLine.replace(ANSI_COLOR_CODE_REGEX, "");
let nextState = null;
if (line.startsWith("commit ")) {
nextState = "commit-header";
} else if (state === "commit-header" && line.startsWith(" ")) {
nextState = "commit-body";
} else if (line.startsWith("diff --git")) {
nextState = "unified-diff";
} else if (line.startsWith("@@ ")) {
nextState = "unified-diff-hunk-header";
} else if (state === "unified-diff-hunk-header") {
nextState = "unified-diff-hunk-body";
} else if (line.startsWith("diff --cc") || line.startsWith("diff --combined")) {
nextState = "combined-diff";
} else if (COMBINED_HUNK_HEADER_START_REGEX.test(line)) {
nextState = "combined-diff-hunk-header";
} else if (state === "combined-diff-hunk-header") {
nextState = "combined-diff-hunk-body";
} else if (state === "commit-body" && line.length > 0 && !line.startsWith(" ")) {
nextState = "unknown";
}
if (nextState) {
yield* flushPending();
switch (nextState) {
case "commit-header":
if (state === "unified-diff-hunk-header" || state === "unified-diff-hunk-body") {
yield HORIZONTAL_SEPARATOR;
}
break;
case "unified-diff":
fileNameA = "";
fileNameB = "";
break;
case "unified-diff-hunk-header":
hunkParts = [
{ fileName: fileNameA, startLineNo: -1, lines: [] },
{ fileName: fileNameB, startLineNo: -1, lines: [] }
];
break;
case "commit-body":
isFirstCommitBodyLine = true;
break;
}
state = nextState;
}
switch (state) {
case "unknown": {
yield T().appendString(rawLine);
break;
}
case "commit-header": {
yield* iterFormatCommitHeaderLine(context, line);
break;
}
case "commit-body": {
yield* iterFormatCommitBodyLine(
context,
line,
isFirstCommitBodyLine
);
isFirstCommitBodyLine = false;
break;
}
case "unified-diff":
case "combined-diff": {
if (line.startsWith("--- a/")) {
fileNameA = line.slice("--- a/".length);
} else if (line.startsWith("+++ b/")) {
fileNameB = line.slice("+++ b/".length);
} else if (line.startsWith("--- ")) {
fileNameA = line.slice("--- ".length);
if (fileNameA === "/dev/null") {
fileNameA = "";
}
} else if (line.startsWith("+++ ")) {
fileNameB = line.slice("+++ ".length);
if (fileNameB === "/dev/null") {
fileNameB = "";
}
} else if (line.startsWith("rename from ")) {
fileNameA = line.slice("rename from ".length);
} else if (line.startsWith("rename to ")) {
fileNameB = line.slice("rename to ".length);
} else if (line.startsWith("Binary files")) {
const match = line.match(BINARY_FILES_DIFF_REGEX);
if (match) {
[, fileNameA, fileNameB] = match;
}
}
break;
}
case "unified-diff-hunk-header": {
const hunkHeaderStart = line.indexOf("@@ ");
const hunkHeaderEnd = line.indexOf(" @@", hunkHeaderStart + 1);
assert2.ok(hunkHeaderStart >= 0);
assert2.ok(hunkHeaderEnd > hunkHeaderStart);
const hunkHeader = line.slice(
hunkHeaderStart + 3,
hunkHeaderEnd
);
hunkHeaderLine = line;
const [aHeader, bHeader] = hunkHeader.split(" ");
const [startAString] = aHeader.split(",");
const [startBString] = bHeader.split(",");
assert2.ok(startAString.startsWith("-"));
hunkParts[0].startLineNo = parseInt(startAString.slice(1), 10);
assert2.ok(startBString.startsWith("+"));
hunkParts[1].startLineNo = parseInt(startBString.slice(1), 10);
break;
}
case "unified-diff-hunk-body": {
const [{ lines: hunkLinesA }, { lines: hunkLinesB }] = hunkParts;
if (line.startsWith("-")) {
hunkLinesA.push(line);
} else if (line.startsWith("+")) {
hunkLinesB.push(line);
} else {
while (hunkLinesA.length < hunkLinesB.length) {
hunkLinesA.push(null);
}
while (hunkLinesB.length < hunkLinesA.length) {
hunkLinesB.push(null);
}
hunkLinesA.push(line);
hunkLinesB.push(line);
}
break;
}
case "combined-diff-hunk-header": {
const match = COMBINED_HUNK_HEADER_START_REGEX.exec(line);
assert2.ok(match);
const hunkHeaderStart = match.index + match[0].length;
const hunkHeaderEnd = line.lastIndexOf(" " + match[1]);
assert2.ok(hunkHeaderStart >= 0);
assert2.ok(hunkHeaderEnd > hunkHeaderStart);
const hunkHeader = line.slice(hunkHeaderStart, hunkHeaderEnd);
hunkHeaderLine = line;
const fileRanges = hunkHeader.split(" ");
hunkParts = [];
for (let i = 0; i < fileRanges.length; i++) {
const fileRange = fileRanges[i];
const [fileRangeStart] = fileRange.slice(1).split(",");
hunkParts.push({
fileName: i === fileRanges.length - 1 ? fileNameB : fileNameA,
startLineNo: parseInt(fileRangeStart, 10),
lines: []
});
}
break;
}
case "combined-diff-hunk-body": {
const linePrefix = line.slice(0, hunkParts.length - 1);
const lineSuffix = line.slice(hunkParts.length - 1);
const isLineAdded = linePrefix.includes("+");
const isLineRemoved = linePrefix.includes("-");
let i = 0;
while (i < hunkParts.length - 1) {
const hunkPart = hunkParts[i];
const partPrefix = linePrefix[i];
if (isLineAdded) {
if (partPrefix === "+") {
hunkPart.lines.push(null);
} else {
hunkPart.lines.push("+" + lineSuffix);
}
} else if (isLineRemoved) {
if (partPrefix === "-") {
hunkPart.lines.push("-" + lineSuffix);
} else {
hunkPart.lines.push(null);
}
} else {
hunkPart.lines.push(" " + lineSuffix);
}
i++;
}
if (isLineRemoved) {
hunkParts[i].lines.push("-" + lineSuffix);
} else if (isLineAdded) {
hunkParts[i].lines.push("+" + lineSuffix);
} else {
hunkParts[i].lines.push(" " + lineSuffix);
}
break;
}
}
}
yield* flushPending();
}
async function* iterSideBySideDiffs(context, lines) {
for await (const formattedString of iterSideBySideDiffsFormatted(
context,
lines
)) {
yield applyFormatting(context, formattedString);
}
}
// src/iterWithNewlines.ts
import * as os from "os";
async function* iterWithNewlines(context, lines) {
for await (const line of lines) {
yield line + os.EOL;
}
}
// src/transformContentsStreaming.ts
function transformContentsStreaming(context, input, output) {
return new Promise((resolve, reject) => {
const transformedInput = Readable.from(
iterWithNewlines(
context,
iterSideBySideDiffs(
context,
iterReplaceTabsWithSpaces(
context,
iterlinesFromReadable(input)
)
)
)
);
stream.pipeline(transformedInput, output, (err) => {
if (err) {
switch (err.code) {
case "EPIPE":
break;
default:
reject(err);
return;
}
}
resolve();
});
});
}
// src/getConfig.ts
var CONFIG_DEFAULTS = {
MIN_LINE_WIDTH: 80,
WRAP_LINES: true,
HIGHLIGHT_LINE_CHANGES: true
};
function getConfig(gitConfig) {
const theme = loadTheme(gitConfig.THEME_DIRECTORY, gitConfig.THEME_NAME);
return {
...CONFIG_DEFAULTS,
...theme,
...gitConfig,
SYNTAX_HIGHLIGHTING_THEME: gitConfig.SYNTAX_HIGHLIGHTING_THEME ?? theme.SYNTAX_HIGHLIGHTING_THEME
};
}
// src/index.ts
var execAsync = util.promisify(exec);
async function transform(input, userConfig, columns = terminalSize().columns) {
const { THEME_NAME, ...restUserConfig } = userConfig;
const { stdout: gitConfigString } = await execAsync("git config -l");
const gitConfig = getGitConfig(gitConfigString);
if (THEME_NAME) {
gitConfig.THEME_NAME = THEME_NAME;
}
const config = getConfig(gitConfig);
const finalConfig = {
...config,
...restUserConfig
};
const context = await getContextForConfig(
finalConfig,
chalk,
columns
);
let string = "";
await transformContentsStreaming(
context,
Readable2.from(input),
new class extends Writable2 {
write(chunk) {
string += chunk.toString();
return true;
}
}()
);
return string;
}
export {
transform
};