orca/mobile/src/session/mobile-diff-lines.ts

221 lines
5.9 KiB
TypeScript

export type MobileDiffLineKind = 'context' | 'add' | 'delete'
export type MobileDiffLine = {
kind: MobileDiffLineKind
text: string
oldLineNumber?: number
newLineNumber?: number
}
const MAX_DIFF_CELLS = 200_000
const MAX_MOBILE_DIFF_LINES = 2_500
const MAX_BUILT_DIFF_LINES = MAX_MOBILE_DIFF_LINES + 1
const TRUNCATED_LINE: MobileDiffLine = {
kind: 'context',
text: '... diff truncated for mobile preview ...'
}
export function buildMobileDiffLines(
originalContent: string,
modifiedContent: string
): { lines: MobileDiffLine[]; truncated: boolean } {
const originalLines = splitContentLines(originalContent)
const modifiedLines = splitContentLines(modifiedContent)
// Why: the LCS table is quadratic. Large generated files still need a
// responsive mobile preview, so fall back to prefix/suffix diffing.
const lines =
originalLines.length * modifiedLines.length <= MAX_DIFF_CELLS
? buildLcsDiffLines(originalLines, modifiedLines)
: buildPrefixSuffixDiffLines(originalLines, modifiedLines)
return finalizeMobileDiffLines(lines)
}
function splitContentLines(content: string): string[] {
if (content.length === 0) {
return []
}
const lines = content.split(/\r?\n/)
if (content.endsWith('\n')) {
lines.pop()
}
return lines
}
function buildLcsDiffLines(originalLines: string[], modifiedLines: string[]): MobileDiffLine[] {
const rowWidth = modifiedLines.length + 1
const dp = new Uint32Array((originalLines.length + 1) * rowWidth)
for (let i = originalLines.length - 1; i >= 0; i -= 1) {
for (let j = modifiedLines.length - 1; j >= 0; j -= 1) {
dp[i * rowWidth + j] =
originalLines[i] === modifiedLines[j]
? dp[(i + 1) * rowWidth + j + 1] + 1
: Math.max(dp[(i + 1) * rowWidth + j], dp[i * rowWidth + j + 1])
}
}
const lines: MobileDiffLine[] = []
let originalIndex = 0
let modifiedIndex = 0
while (originalIndex < originalLines.length && modifiedIndex < modifiedLines.length) {
if (originalLines[originalIndex] === modifiedLines[modifiedIndex]) {
if (
!appendDiffLine(lines, {
kind: 'context',
text: originalLines[originalIndex] ?? '',
oldLineNumber: originalIndex + 1,
newLineNumber: modifiedIndex + 1
})
) {
return lines
}
originalIndex += 1
modifiedIndex += 1
} else if (
dp[(originalIndex + 1) * rowWidth + modifiedIndex] >=
dp[originalIndex * rowWidth + modifiedIndex + 1]
) {
if (
!appendDiffLine(lines, {
kind: 'delete',
text: originalLines[originalIndex] ?? '',
oldLineNumber: originalIndex + 1
})
) {
return lines
}
originalIndex += 1
} else {
if (
!appendDiffLine(lines, {
kind: 'add',
text: modifiedLines[modifiedIndex] ?? '',
newLineNumber: modifiedIndex + 1
})
) {
return lines
}
modifiedIndex += 1
}
}
while (originalIndex < originalLines.length) {
if (
!appendDiffLine(lines, {
kind: 'delete',
text: originalLines[originalIndex] ?? '',
oldLineNumber: originalIndex + 1
})
) {
return lines
}
originalIndex += 1
}
while (modifiedIndex < modifiedLines.length) {
if (
!appendDiffLine(lines, {
kind: 'add',
text: modifiedLines[modifiedIndex] ?? '',
newLineNumber: modifiedIndex + 1
})
) {
return lines
}
modifiedIndex += 1
}
return lines
}
function buildPrefixSuffixDiffLines(
originalLines: string[],
modifiedLines: string[]
): MobileDiffLine[] {
let prefixLength = 0
while (
prefixLength < originalLines.length &&
prefixLength < modifiedLines.length &&
originalLines[prefixLength] === modifiedLines[prefixLength]
) {
prefixLength += 1
}
let suffixLength = 0
while (
suffixLength + prefixLength < originalLines.length &&
suffixLength + prefixLength < modifiedLines.length &&
originalLines[originalLines.length - suffixLength - 1] ===
modifiedLines[modifiedLines.length - suffixLength - 1]
) {
suffixLength += 1
}
const lines: MobileDiffLine[] = []
for (let i = 0; i < prefixLength; i += 1) {
if (
!appendDiffLine(lines, {
kind: 'context',
text: originalLines[i] ?? '',
oldLineNumber: i + 1,
newLineNumber: i + 1
})
) {
return lines
}
}
for (let i = prefixLength; i < originalLines.length - suffixLength; i += 1) {
if (
!appendDiffLine(lines, {
kind: 'delete',
text: originalLines[i] ?? '',
oldLineNumber: i + 1
})
) {
return lines
}
}
for (let i = prefixLength; i < modifiedLines.length - suffixLength; i += 1) {
if (
!appendDiffLine(lines, {
kind: 'add',
text: modifiedLines[i] ?? '',
newLineNumber: i + 1
})
) {
return lines
}
}
for (let i = originalLines.length - suffixLength; i < originalLines.length; i += 1) {
const modifiedIndex =
modifiedLines.length - suffixLength + (i - (originalLines.length - suffixLength))
if (
!appendDiffLine(lines, {
kind: 'context',
text: originalLines[i] ?? '',
oldLineNumber: i + 1,
newLineNumber: modifiedIndex + 1
})
) {
return lines
}
}
return lines
}
function appendDiffLine(lines: MobileDiffLine[], line: MobileDiffLine): boolean {
// Why: the mobile preview only renders the first capped rows; keep one extra
// row solely to preserve the existing "truncated" marker decision.
lines.push(line)
return lines.length < MAX_BUILT_DIFF_LINES
}
function finalizeMobileDiffLines(lines: MobileDiffLine[]): {
lines: MobileDiffLine[]
truncated: boolean
} {
if (lines.length <= MAX_MOBILE_DIFF_LINES) {
return { lines, truncated: false }
}
return { lines: [...lines.slice(0, MAX_MOBILE_DIFF_LINES), TRUNCATED_LINE], truncated: true }
}