| /** | |
| * @license | |
| * Copyright 2026 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import {DiffInfo} from '../../../types/diff'; | |
| import {CommentSide, Side} from '../../../constants/constants'; | |
| import {CommentThread, PatchRange} from '../../../types/common'; | |
| import {isInBaseOfPatchRange} from '../../../utils/comment-util'; | |
| import {getDiffLines} from '../../../utils/diff-util'; | |
| import {htmlEscape} from '../../../utils/inner-html-util'; | |
| import {Marked, Token, Tokens} from 'marked'; | |
| export type DiffBlockStatus = 'unchanged' | 'added' | 'deleted' | 'modified'; | |
| export type MarkdownToken = Token & { | |
| startLine?: number; | |
| endLine?: number; | |
| }; | |
| export interface AlignedDiffRow { | |
| status: DiffBlockStatus; | |
| leftToken?: MarkdownToken; | |
| rightToken?: MarkdownToken; | |
| leftHtml?: string; | |
| rightHtml?: string; | |
| leftStartLine?: number; | |
| leftEndLine?: number; | |
| rightStartLine?: number; | |
| rightEndLine?: number; | |
| } | |
| export interface AlignedDiffRowWithThreads extends AlignedDiffRow { | |
| leftThreads: CommentThread[]; | |
| rightThreads: CommentThread[]; | |
| } | |
| export interface InlineDiffSegment { | |
| text: string; | |
| type: 'common' | 'added' | 'deleted'; | |
| } | |
| /** Reconstruct the entire file content from diff chunks for the given side. */ | |
| export function reconstructFileContent(diff: DiffInfo, side: Side): string { | |
| return getDiffLines(diff, side).join('\n'); | |
| } | |
| /** Parse markdown text into top-level block tokens with line number metadata. */ | |
| export function parseMarkdownBlocks(markdown: string): MarkdownToken[] { | |
| if (!markdown) return []; | |
| const marked = new Marked(); | |
| const tokens = marked.lexer(markdown); | |
| const result: MarkdownToken[] = []; | |
| let curLine = 1; | |
| for (const t of tokens) { | |
| const raw = t.raw; | |
| const newlineCount = (raw.match(/\n/g) || []).length; | |
| const startLine = curLine; | |
| const endLine = curLine + newlineCount - (raw.endsWith('\n') ? 1 : 0); | |
| curLine += newlineCount; | |
| if (t.type === 'space') { | |
| continue; | |
| } | |
| (t as MarkdownToken).startLine = startLine; | |
| (t as MarkdownToken).endLine = Math.max(startLine, endLine); | |
| result.push(t as MarkdownToken); | |
| } | |
| return result; | |
| } | |
| /** Tokenize text into words, whitespace, and punctuation for fine-grained inline diffing. */ | |
| export function tokenizeWords(text: string): string[] { | |
| return text.match(/\s+|[^\s\w]+|\w+/g) || []; | |
| } | |
| /** Compute LCS-based diff between two arrays of strings (e.g. words or lines). */ | |
| export function computeSequenceDiff( | |
| seqA: string[], | |
| seqB: string[] | |
| ): InlineDiffSegment[] { | |
| const m = seqA.length; | |
| const n = seqB.length; | |
| // DP table for LCS lengths | |
| const dp: number[][] = Array.from({length: m + 1}, () => | |
| new Array(n + 1).fill(0) | |
| ); | |
| for (let i = 0; i < m; i++) { | |
| for (let j = 0; j < n; j++) { | |
| if (seqA[i] === seqB[j]) { | |
| dp[i + 1][j + 1] = dp[i][j] + 1; | |
| } else { | |
| dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]); | |
| } | |
| } | |
| } | |
| // Backtrack to build diff segments | |
| const segments: InlineDiffSegment[] = []; | |
| let i = m; | |
| let j = n; | |
| while (i > 0 || j > 0) { | |
| if (i > 0 && j > 0 && seqA[i - 1] === seqB[j - 1]) { | |
| segments.unshift({text: seqA[i - 1], type: 'common'}); | |
| i--; | |
| j--; | |
| } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { | |
| segments.unshift({text: seqB[j - 1], type: 'added'}); | |
| j--; | |
| } else if (i > 0 && (j === 0 || dp[i][j - 1] < dp[i - 1][j])) { | |
| segments.unshift({text: seqA[i - 1], type: 'deleted'}); | |
| i--; | |
| } | |
| } | |
| return segments; | |
| } | |
| /** Render a single marked token to standard HTML using marked. */ | |
| export function renderTokenToHtml(token?: Token): string { | |
| if (!token) return ''; | |
| const marked = new Marked(); | |
| return marked.parse(token.raw, {async: false}) || ''; | |
| } | |
| /** Render an inline diff for modified text in headings or paragraphs. */ | |
| export function renderInlineTextDiff( | |
| textA: string, | |
| textB: string, | |
| wrapperTag = 'p' | |
| ): {leftHtml: string; rightHtml: string} { | |
| const wordsA = tokenizeWords(textA); | |
| const wordsB = tokenizeWords(textB); | |
| const diff = computeSequenceDiff(wordsA, wordsB); | |
| // Merge consecutive segments of the same type | |
| const mergedSegments: InlineDiffSegment[] = []; | |
| for (const seg of diff) { | |
| const last = mergedSegments[mergedSegments.length - 1]; | |
| if (last && last.type === seg.type) { | |
| last.text += seg.text; | |
| } else { | |
| mergedSegments.push({...seg}); | |
| } | |
| } | |
| let leftContent = ''; | |
| let rightContent = ''; | |
| for (const seg of mergedSegments) { | |
| const escaped = htmlEscape(seg.text).toString(); | |
| if (seg.type === 'common') { | |
| leftContent += escaped; | |
| rightContent += escaped; | |
| } else if (seg.type === 'deleted') { | |
| leftContent += `<del class="diff-highlight-del">${escaped}</del>`; | |
| } else if (seg.type === 'added') { | |
| rightContent += `<ins class="diff-highlight-add">${escaped}</ins>`; | |
| } | |
| } | |
| return { | |
| leftHtml: `<${wrapperTag}>${leftContent}</${wrapperTag}>`, | |
| rightHtml: `<${wrapperTag}>${rightContent}</${wrapperTag}>`, | |
| }; | |
| } | |
| /** Render a modified code block with line-by-line diff highlights. */ | |
| export function renderCodeBlockDiff( | |
| tokenA: Tokens.Code, | |
| tokenB: Tokens.Code | |
| ): {leftHtml: string; rightHtml: string} { | |
| const linesA = tokenA.text.split('\n'); | |
| const linesB = tokenB.text.split('\n'); | |
| const lineDiff = computeSequenceDiff(linesA, linesB); | |
| let leftLines = ''; | |
| let rightLines = ''; | |
| for (const seg of lineDiff) { | |
| const escaped = htmlEscape(seg.text).toString(); | |
| if (seg.type === 'common') { | |
| leftLines += `${escaped}\n`; | |
| rightLines += `${escaped}\n`; | |
| } else if (seg.type === 'deleted') { | |
| leftLines += `<span class="diff-highlight-del">${escaped}</span>\n`; | |
| } else if (seg.type === 'added') { | |
| rightLines += `<span class="diff-highlight-add">${escaped}</span>\n`; | |
| } | |
| } | |
| const langClass = tokenB.lang | |
| ? ` class="language-${htmlEscape(tokenB.lang)}"` | |
| : ''; | |
| return { | |
| leftHtml: `<pre><code${langClass}>${leftLines.trimEnd()}</code></pre>`, | |
| rightHtml: `<pre><code${langClass}>${rightLines.trimEnd()}</code></pre>`, | |
| }; | |
| } | |
| /** | |
| * Align base (A) and revision (B) markdown block tokens into rows. | |
| * Matches exact blocks as anchors, pairs modified blocks of compatible types, | |
| * and leaves unmatched blocks as added or deleted with empty partner cells. | |
| */ | |
| export function alignMarkdownTokens( | |
| tokensA: MarkdownToken[], | |
| tokensB: MarkdownToken[] | |
| ): AlignedDiffRow[] { | |
| // Filter out whitespace-only 'space' tokens between blocks | |
| const blocksA = tokensA.filter(t => t.type !== 'space'); | |
| const blocksB = tokensB.filter(t => t.type !== 'space'); | |
| const m = blocksA.length; | |
| const n = blocksB.length; | |
| // DP table to find LCS of exact matches | |
| const dp: number[][] = Array.from({length: m + 1}, () => | |
| new Array(n + 1).fill(0) | |
| ); | |
| for (let i = 0; i < m; i++) { | |
| for (let j = 0; j < n; j++) { | |
| if (blocksA[i].raw === blocksB[j].raw) { | |
| dp[i + 1][j + 1] = dp[i][j] + 1; | |
| } else { | |
| dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]); | |
| } | |
| } | |
| } | |
| // Backtrack to extract exact match pairs | |
| const anchorPairs: {aIdx: number; bIdx: number}[] = []; | |
| let i = m; | |
| let j = n; | |
| while (i > 0 && j > 0) { | |
| if (blocksA[i - 1].raw === blocksB[j - 1].raw) { | |
| anchorPairs.unshift({aIdx: i - 1, bIdx: j - 1}); | |
| i--; | |
| j--; | |
| } else if (dp[i][j - 1] >= dp[i - 1][j]) { | |
| j--; | |
| } else { | |
| i--; | |
| } | |
| } | |
| const rows: AlignedDiffRow[] = []; | |
| let lastA = 0; | |
| let lastB = 0; | |
| function processInterval(endA: number, endB: number) { | |
| const unalignedA = blocksA.slice(lastA, endA); | |
| const unalignedB = blocksB.slice(lastB, endB); | |
| let idxA = 0; | |
| let idxB = 0; | |
| // Greedily pair up adjacent tokens of compatible type as 'modified' | |
| while (idxA < unalignedA.length && idxB < unalignedB.length) { | |
| const tokA = unalignedA[idxA]; | |
| const tokB = unalignedB[idxB]; | |
| const canPair = | |
| tokA.type === tokB.type || | |
| (tokA.type === 'paragraph' && tokB.type === 'paragraph') || | |
| (tokA.type === 'heading' && tokB.type === 'heading') || | |
| (tokA.type === 'code' && tokB.type === 'code'); | |
| if (canPair) { | |
| let leftHtml: string; | |
| let rightHtml: string; | |
| if (tokA.type === 'code' && tokB.type === 'code') { | |
| const diff = renderCodeBlockDiff( | |
| tokA as Tokens.Code, | |
| tokB as Tokens.Code | |
| ); | |
| leftHtml = diff.leftHtml; | |
| rightHtml = diff.rightHtml; | |
| } else if (tokA.type === 'heading' && tokB.type === 'heading') { | |
| const depth = (tokB as Tokens.Heading).depth; | |
| const diff = renderInlineTextDiff(tokA.text, tokB.text, `h${depth}`); | |
| leftHtml = diff.leftHtml; | |
| rightHtml = diff.rightHtml; | |
| } else if (tokA.type === 'paragraph' && tokB.type === 'paragraph') { | |
| const diff = renderInlineTextDiff(tokA.text, tokB.text, 'p'); | |
| leftHtml = diff.leftHtml; | |
| rightHtml = diff.rightHtml; | |
| } else { | |
| leftHtml = renderTokenToHtml(tokA); | |
| rightHtml = renderTokenToHtml(tokB); | |
| } | |
| rows.push({ | |
| status: 'modified', | |
| leftToken: tokA, | |
| rightToken: tokB, | |
| leftHtml, | |
| rightHtml, | |
| leftStartLine: tokA.startLine, | |
| leftEndLine: tokA.endLine, | |
| rightStartLine: tokB.startLine, | |
| rightEndLine: tokB.endLine, | |
| }); | |
| idxA++; | |
| idxB++; | |
| } else { | |
| // Output deleted block on left | |
| rows.push({ | |
| status: 'deleted', | |
| leftToken: tokA, | |
| leftHtml: renderTokenToHtml(tokA), | |
| leftStartLine: tokA.startLine, | |
| leftEndLine: tokA.endLine, | |
| }); | |
| idxA++; | |
| } | |
| } | |
| // Remaining unmatched in A | |
| while (idxA < unalignedA.length) { | |
| const tokA = unalignedA[idxA]; | |
| rows.push({ | |
| status: 'deleted', | |
| leftToken: tokA, | |
| leftHtml: renderTokenToHtml(tokA), | |
| leftStartLine: tokA.startLine, | |
| leftEndLine: tokA.endLine, | |
| }); | |
| idxA++; | |
| } | |
| // Remaining unmatched in B | |
| while (idxB < unalignedB.length) { | |
| const tokB = unalignedB[idxB]; | |
| rows.push({ | |
| status: 'added', | |
| rightToken: tokB, | |
| rightHtml: renderTokenToHtml(tokB), | |
| rightStartLine: tokB.startLine, | |
| rightEndLine: tokB.endLine, | |
| }); | |
| idxB++; | |
| } | |
| } | |
| // Interleave intervals between anchor pairs | |
| for (const anchor of anchorPairs) { | |
| processInterval(anchor.aIdx, anchor.bIdx); | |
| const tokA = blocksA[anchor.aIdx]; | |
| const tokB = blocksB[anchor.bIdx]; | |
| const htmlA = renderTokenToHtml(tokA); | |
| const htmlB = renderTokenToHtml(tokB); | |
| rows.push({ | |
| status: 'unchanged', | |
| leftToken: tokA, | |
| rightToken: tokB, | |
| leftHtml: htmlA, | |
| rightHtml: htmlB, | |
| leftStartLine: tokA.startLine, | |
| leftEndLine: tokA.endLine, | |
| rightStartLine: tokB.startLine, | |
| rightEndLine: tokB.endLine, | |
| }); | |
| lastA = anchor.aIdx + 1; | |
| lastB = anchor.bIdx + 1; | |
| } | |
| // Trailing interval | |
| processInterval(m, n); | |
| return rows; | |
| } | |
| /** Determine which side of the diff (LEFT or RIGHT) a comment thread belongs to. */ | |
| export function getThreadDiffSide( | |
| thread: CommentThread, | |
| patchRange?: PatchRange | |
| ): Side { | |
| if (!patchRange) { | |
| return thread.commentSide === CommentSide.PARENT ? Side.LEFT : Side.RIGHT; | |
| } | |
| const commentProps = { | |
| patch_set: thread.patchNum, | |
| side: thread.commentSide, | |
| parent: thread.mergeParentNum, | |
| }; | |
| if (isInBaseOfPatchRange(commentProps, patchRange)) { | |
| return Side.LEFT; | |
| } | |
| return Side.RIGHT; | |
| } | |
| /** | |
| * Assigns comment threads to their corresponding markdown diff rows based on line | |
| * numbers and diff side. File-level comments (or threads without line numbers) are | |
| * grouped separately. | |
| */ | |
| export function attachThreadsToRows( | |
| rows: AlignedDiffRow[], | |
| threads: CommentThread[] = [], | |
| patchRange?: PatchRange | |
| ): { | |
| rowsWithThreads: AlignedDiffRowWithThreads[]; | |
| fileLevelThreads: CommentThread[]; | |
| } { | |
| const rowsWithThreads: AlignedDiffRowWithThreads[] = rows.map(r => { | |
| return { | |
| ...r, | |
| leftThreads: [], | |
| rightThreads: [], | |
| }; | |
| }); | |
| const fileLevelThreads: CommentThread[] = []; | |
| if (rowsWithThreads.length === 0) { | |
| return { | |
| rowsWithThreads, | |
| fileLevelThreads: [...threads], | |
| }; | |
| } | |
| for (const thread of threads) { | |
| const line = thread.line; | |
| if (line === undefined || line === 'FILE') { | |
| fileLevelThreads.push(thread); | |
| continue; | |
| } | |
| const lineNum = typeof line === 'number' ? line : Number(line); | |
| if (isNaN(lineNum)) { | |
| fileLevelThreads.push(thread); | |
| continue; | |
| } | |
| const side = getThreadDiffSide(thread, patchRange); | |
| if (side === Side.LEFT) { | |
| const leftRows = rowsWithThreads.filter( | |
| r => r.leftStartLine !== undefined | |
| ); | |
| if (leftRows.length === 0) { | |
| rowsWithThreads[0].leftThreads.push(thread); | |
| continue; | |
| } | |
| const exactRow = leftRows.find( | |
| r => lineNum >= r.leftStartLine! && lineNum <= r.leftEndLine! | |
| ); | |
| if (exactRow) { | |
| exactRow.leftThreads.push(thread); | |
| continue; | |
| } | |
| if (lineNum < leftRows[0].leftStartLine!) { | |
| leftRows[0].leftThreads.push(thread); | |
| continue; | |
| } | |
| let targetRow = leftRows[leftRows.length - 1]; | |
| for (let i = 0; i < leftRows.length - 1; i++) { | |
| if ( | |
| lineNum > leftRows[i].leftEndLine! && | |
| lineNum < leftRows[i + 1].leftStartLine! | |
| ) { | |
| targetRow = leftRows[i]; | |
| break; | |
| } | |
| } | |
| targetRow.leftThreads.push(thread); | |
| } else { | |
| const rightRows = rowsWithThreads.filter( | |
| r => r.rightStartLine !== undefined | |
| ); | |
| if (rightRows.length === 0) { | |
| rowsWithThreads[0].rightThreads.push(thread); | |
| continue; | |
| } | |
| const exactRow = rightRows.find( | |
| r => lineNum >= r.rightStartLine! && lineNum <= r.rightEndLine! | |
| ); | |
| if (exactRow) { | |
| exactRow.rightThreads.push(thread); | |
| continue; | |
| } | |
| if (lineNum < rightRows[0].rightStartLine!) { | |
| rightRows[0].rightThreads.push(thread); | |
| continue; | |
| } | |
| let targetRow = rightRows[rightRows.length - 1]; | |
| for (let i = 0; i < rightRows.length - 1; i++) { | |
| if ( | |
| lineNum > rightRows[i].rightEndLine! && | |
| lineNum < rightRows[i + 1].rightStartLine! | |
| ) { | |
| targetRow = rightRows[i]; | |
| break; | |
| } | |
| } | |
| targetRow.rightThreads.push(thread); | |
| } | |
| } | |
| return {rowsWithThreads, fileLevelThreads}; | |
| } |