227 lines
8.7 KiB
TypeScript
227 lines
8.7 KiB
TypeScript
import { diffChars, diffLines, type Change } from "diff";
|
|
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
|
|
import { formatXmlSource } from "@/lib/common/xmlFormat";
|
|
import type { QueryResult } from "@/types/database";
|
|
import type { DoltDiffColumnKind, DoltRowChangeKind } from "@/lib/dolt/doltVersionControl";
|
|
|
|
export type DoltCellValue = QueryResult["rows"][number][number];
|
|
export type DoltCellSide = "before" | "after";
|
|
export type DoltCellFormat = "raw" | "json" | "xml";
|
|
export type DoltCellFormatMode = "auto" | DoltCellFormat;
|
|
export type DoltCellValueState = "value" | "row-missing" | "column-missing";
|
|
export type DoltTextDiffKind = "equal" | "added" | "removed" | "modified";
|
|
|
|
export interface DoltDiffCellTarget {
|
|
rowIndex: number;
|
|
columnIndex: number;
|
|
side: DoltCellSide;
|
|
columnName: string;
|
|
columnKind: DoltDiffColumnKind;
|
|
rowKind: DoltRowChangeKind;
|
|
beforeValue: DoltCellValue;
|
|
afterValue: DoltCellValue;
|
|
}
|
|
|
|
export interface DoltCellDisplayValue {
|
|
state: DoltCellValueState;
|
|
text: string;
|
|
rawText: string;
|
|
canFormat: boolean;
|
|
}
|
|
|
|
export interface DoltCellFormatResult {
|
|
text: string;
|
|
error: string | null;
|
|
}
|
|
|
|
export interface DoltTextDiffSegment {
|
|
text: string;
|
|
changed: boolean;
|
|
}
|
|
|
|
export interface DoltTextDiffRow {
|
|
kind: DoltTextDiffKind;
|
|
beforeLineNumber: number | null;
|
|
afterLineNumber: number | null;
|
|
beforeText: string;
|
|
afterText: string;
|
|
beforeSegments: DoltTextDiffSegment[];
|
|
afterSegments: DoltTextDiffSegment[];
|
|
}
|
|
|
|
const MAX_DETAILED_LINE_DIFF_CHARACTERS = 500_000;
|
|
const MAX_CHARACTER_DIFF_CHARACTERS = 20_000;
|
|
|
|
export function doltCellValueState(target: DoltDiffCellTarget, side: DoltCellSide): DoltCellValueState {
|
|
if (side === "before") {
|
|
if (target.columnKind === "added") return "column-missing";
|
|
if (target.rowKind === "added") return "row-missing";
|
|
} else {
|
|
if (target.columnKind === "removed") return "column-missing";
|
|
if (target.rowKind === "removed") return "row-missing";
|
|
}
|
|
return "value";
|
|
}
|
|
|
|
export function doltCellDisplayValue(target: DoltDiffCellTarget, side: DoltCellSide, labels: { nullValue: string; rowMissing: string; columnMissing: string }): DoltCellDisplayValue {
|
|
const state = doltCellValueState(target, side);
|
|
if (state === "row-missing") return { state, text: labels.rowMissing, rawText: "", canFormat: false };
|
|
if (state === "column-missing") return { state, text: labels.columnMissing, rawText: "", canFormat: false };
|
|
const value = side === "before" ? target.beforeValue : target.afterValue;
|
|
if (value === null) return { state, text: labels.nullValue, rawText: "", canFormat: false };
|
|
const text = String(value);
|
|
return { state, text, rawText: text, canFormat: typeof value === "string" };
|
|
}
|
|
|
|
export function formatDoltCellText(value: DoltCellDisplayValue, format: DoltCellFormat): DoltCellFormatResult {
|
|
if (format === "raw" || !value.canFormat) return { text: value.text, error: null };
|
|
try {
|
|
return {
|
|
text: format === "json" ? formatJsonSource(value.rawText, 2) : formatXmlSource(value.rawText, " "),
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
return { text: value.text, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
export function detectDoltCellFormat(values: readonly DoltCellDisplayValue[]): DoltCellFormat {
|
|
const candidates = values.filter((value) => value.canFormat && value.rawText.trim()).map((value) => value.rawText.trim());
|
|
const jsonCandidates = candidates.filter((value) => value.startsWith("{") || value.startsWith("["));
|
|
const xmlCandidates = candidates.filter((value) => value.startsWith("<"));
|
|
const hasValidJson = jsonCandidates.some((value) => !formatDoltCellText({ state: "value", text: value, rawText: value, canFormat: true }, "json").error);
|
|
const hasValidXml = xmlCandidates.some((value) => !formatDoltCellText({ state: "value", text: value, rawText: value, canFormat: true }, "xml").error);
|
|
if (hasValidJson !== hasValidXml) return hasValidJson ? "json" : "xml";
|
|
return "raw";
|
|
}
|
|
|
|
function normalizeLineEndings(value: string): string {
|
|
return value.replace(/\r\n?/g, "\n");
|
|
}
|
|
|
|
function linesFromChange(change: Change): string[] {
|
|
if (!change.value) return [];
|
|
const lines = change.value.split("\n");
|
|
if (change.value.endsWith("\n")) lines.pop();
|
|
return lines;
|
|
}
|
|
|
|
function unchangedSegments(text: string): DoltTextDiffSegment[] {
|
|
return text ? [{ text, changed: false }] : [];
|
|
}
|
|
|
|
function changedSegments(beforeText: string, afterText: string): { before: DoltTextDiffSegment[]; after: DoltTextDiffSegment[] } {
|
|
if (beforeText.length + afterText.length > MAX_CHARACTER_DIFF_CHARACTERS) {
|
|
return {
|
|
before: beforeText ? [{ text: beforeText, changed: true }] : [],
|
|
after: afterText ? [{ text: afterText, changed: true }] : [],
|
|
};
|
|
}
|
|
const changes = diffChars(beforeText, afterText);
|
|
return {
|
|
before: changes.filter((change) => !change.added).map((change) => ({ text: change.value, changed: !!change.removed })),
|
|
after: changes.filter((change) => !change.removed).map((change) => ({ text: change.value, changed: !!change.added })),
|
|
};
|
|
}
|
|
|
|
function appendEqualRows(rows: DoltTextDiffRow[], lines: string[], beforeLine: { value: number }, afterLine: { value: number }) {
|
|
for (const line of lines) {
|
|
rows.push({
|
|
kind: "equal",
|
|
beforeLineNumber: beforeLine.value++,
|
|
afterLineNumber: afterLine.value++,
|
|
beforeText: line,
|
|
afterText: line,
|
|
beforeSegments: unchangedSegments(line),
|
|
afterSegments: unchangedSegments(line),
|
|
});
|
|
}
|
|
}
|
|
|
|
function appendChangedRows(rows: DoltTextDiffRow[], removed: string[], added: string[], beforeLine: { value: number }, afterLine: { value: number }) {
|
|
const count = Math.max(removed.length, added.length);
|
|
for (let index = 0; index < count; index += 1) {
|
|
const beforeText = removed[index];
|
|
const afterText = added[index];
|
|
if (beforeText !== undefined && afterText !== undefined) {
|
|
const segments = changedSegments(beforeText, afterText);
|
|
rows.push({
|
|
kind: beforeText === afterText ? "equal" : "modified",
|
|
beforeLineNumber: beforeLine.value++,
|
|
afterLineNumber: afterLine.value++,
|
|
beforeText,
|
|
afterText,
|
|
beforeSegments: beforeText === afterText ? unchangedSegments(beforeText) : segments.before,
|
|
afterSegments: beforeText === afterText ? unchangedSegments(afterText) : segments.after,
|
|
});
|
|
} else if (beforeText !== undefined) {
|
|
rows.push({
|
|
kind: "removed",
|
|
beforeLineNumber: beforeLine.value++,
|
|
afterLineNumber: null,
|
|
beforeText,
|
|
afterText: "",
|
|
beforeSegments: unchangedSegments(beforeText),
|
|
afterSegments: [],
|
|
});
|
|
} else if (afterText !== undefined) {
|
|
rows.push({
|
|
kind: "added",
|
|
beforeLineNumber: null,
|
|
afterLineNumber: afterLine.value++,
|
|
beforeText: "",
|
|
afterText,
|
|
beforeSegments: [],
|
|
afterSegments: unchangedSegments(afterText),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildFallbackRows(before: string, after: string): DoltTextDiffRow[] {
|
|
const beforeLines = before.split("\n");
|
|
const afterLines = after.split("\n");
|
|
const rows: DoltTextDiffRow[] = [];
|
|
appendChangedRows(rows, beforeLines, afterLines, { value: 1 }, { value: 1 });
|
|
return rows;
|
|
}
|
|
|
|
export function buildDoltTextDiff(beforeValue: string, afterValue: string): DoltTextDiffRow[] {
|
|
const before = normalizeLineEndings(beforeValue);
|
|
const after = normalizeLineEndings(afterValue);
|
|
if (before.length + after.length > MAX_DETAILED_LINE_DIFF_CHARACTERS) return buildFallbackRows(before, after);
|
|
|
|
const changes = diffLines(before, after, { newlineIsToken: false });
|
|
const rows: DoltTextDiffRow[] = [];
|
|
const beforeLine = { value: 1 };
|
|
const afterLine = { value: 1 };
|
|
let index = 0;
|
|
while (index < changes.length) {
|
|
const change = changes[index];
|
|
if (!change.added && !change.removed) {
|
|
appendEqualRows(rows, linesFromChange(change), beforeLine, afterLine);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
const removed: string[] = [];
|
|
const added: string[] = [];
|
|
while (index < changes.length && (changes[index].added || changes[index].removed)) {
|
|
const changedLines = linesFromChange(changes[index]);
|
|
if (changes[index].removed) removed.push(...changedLines);
|
|
else added.push(...changedLines);
|
|
index += 1;
|
|
}
|
|
appendChangedRows(rows, removed, added, beforeLine, afterLine);
|
|
}
|
|
|
|
if (rows.length === 0) appendEqualRows(rows, [""], beforeLine, afterLine);
|
|
return rows;
|
|
}
|
|
|
|
export function doltCellCopyText(target: DoltDiffCellTarget, side: DoltCellSide): string | null {
|
|
if (doltCellValueState(target, side) !== "value") return null;
|
|
const value = side === "before" ? target.beforeValue : target.afterValue;
|
|
return value === null ? "" : String(value);
|
|
}
|