diff --git a/apps/staged/src/app.css b/apps/staged/src/app.css
index b74eae8d..0fe5b4b5 100644
--- a/apps/staged/src/app.css
+++ b/apps/staged/src/app.css
@@ -54,6 +54,8 @@
--diff-added-bg: rgba(63, 185, 80, 0.08);
--diff-removed-bg: rgba(248, 81, 73, 0.08);
--diff-changed-bg: rgba(255, 255, 255, 0.04);
+ --diff-modified-bg: rgba(227, 179, 65, 0.08);
+ --diff-modified-inline-bg: rgba(227, 179, 65, 0.25);
--diff-range-border: #524d58;
--diff-comment-highlight: rgba(88, 166, 255, 0.5);
diff --git a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts
index 64aecdde..55ad1a55 100644
--- a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts
+++ b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts
@@ -69,7 +69,8 @@ export default class BranchCardSessionManager {
/** True when a new session will be queued rather than started immediately. */
willQueue = $derived(
!this.getTimeline() || // provisioning — no timeline yet
- this.hasRunningSession // another session is active
+ this.hasRunningSession || // another session is active
+ this.isSessionStartPending // a session start is already in flight
);
/** True when new session actions (new commit, note, review) should be disabled. */
diff --git a/packages/diff-viewer/src/lib/components/DiffViewer.svelte b/packages/diff-viewer/src/lib/components/DiffViewer.svelte
index 060d4e07..94632aa4 100644
--- a/packages/diff-viewer/src/lib/components/DiffViewer.svelte
+++ b/packages/diff-viewer/src/lib/components/DiffViewer.svelte
@@ -54,6 +54,8 @@
isLineInChangedAlignment as helperIsLineInChangedAlignment,
isLineInIndexedRange,
isLineSelected as helperIsLineSelected,
+ getLineClass as helperGetLineClass,
+ getCharHighlights as helperGetCharHighlights,
buildLineCommentEditorLayout,
buildLineSelectionToolbarLayout,
buildRangeCommentEditorLayout,
@@ -63,6 +65,8 @@
normalizeLineSelection,
resolveLineSelectionToolbarLeft,
} from '../utils/diffViewerHelpers';
+ import { createLineDiffCache } from '../utils/inlineDiff.js';
+ import type { BeforeLineClass, AfterLineClass, CharHighlight } from '../utils/inlineDiff.js';
import { setupDiffKeyboardNav } from '../utils/diffKeyboard';
import { pathsMatch } from '../utils/diffModalHelpers';
import CommentEditor from './CommentEditor.svelte';
@@ -125,6 +129,12 @@
onDeleteComment,
}: Props = $props();
+ // ==========================================================================
+ // Inline diff cache (scoped to component instance)
+ // ==========================================================================
+
+ const lineDiffCache = createLineDiffCache();
+
// ==========================================================================
// Element refs
// ==========================================================================
@@ -666,12 +676,13 @@
// Search highlighting
// ==========================================================================
- /** A token segment that may be part of a search match */
+ /** A token segment that may be part of a search match or char-level diff highlight */
interface HighlightedSegment {
content: string;
color: string;
isMatch: boolean;
isCurrent: boolean;
+ isCharChanged: boolean;
}
/**
@@ -738,6 +749,7 @@
color: t.color,
isMatch: false,
isCurrent: false,
+ isCharChanged: false,
}));
}
@@ -760,6 +772,7 @@
color: token.color,
isMatch: false,
isCurrent: false,
+ isCharChanged: false,
});
} else {
// Token has matches - split at match boundaries
@@ -776,6 +789,7 @@
color: token.color,
isMatch: false,
isCurrent: false,
+ isCharChanged: false,
});
}
@@ -785,6 +799,7 @@
color: token.color,
isMatch: true,
isCurrent: match.isCurrent,
+ isCharChanged: false,
});
pos = matchEnd;
@@ -797,6 +812,7 @@
color: token.color,
isMatch: false,
isCurrent: false,
+ isCharChanged: false,
});
}
}
@@ -808,7 +824,73 @@
}
/**
- * Get highlighted token segments for a line, with search matches applied.
+ * Apply character-level diff highlights to segments by splitting them at highlight boundaries.
+ * Works similarly to applySearchHighlights — walks through segments tracking column position.
+ */
+ function applyCharHighlights(
+ segments: HighlightedSegment[],
+ highlights: CharHighlight[]
+ ): HighlightedSegment[] {
+ if (highlights.length === 0) return segments;
+
+ const result: HighlightedSegment[] = [];
+ let charIndex = 0;
+
+ for (const segment of segments) {
+ const segStart = charIndex;
+ const segEnd = charIndex + segment.content.length;
+
+ // Find highlights that overlap with this segment
+ const overlapping = highlights.filter(
+ (h) => h.start < segEnd && h.end > segStart
+ );
+
+ if (overlapping.length === 0) {
+ result.push(segment);
+ } else {
+ let pos = 0; // Position within segment content
+
+ for (const hl of overlapping) {
+ const hlStart = Math.max(0, hl.start - segStart);
+ const hlEnd = Math.min(segment.content.length, hl.end - segStart);
+
+ // Add any content before the highlight
+ if (pos < hlStart) {
+ result.push({
+ ...segment,
+ content: segment.content.slice(pos, hlStart),
+ isCharChanged: false,
+ });
+ }
+
+ // Add the highlighted portion
+ result.push({
+ ...segment,
+ content: segment.content.slice(hlStart, hlEnd),
+ isCharChanged: true,
+ });
+
+ pos = hlEnd;
+ }
+
+ // Add any remaining content after all highlights
+ if (pos < segment.content.length) {
+ result.push({
+ ...segment,
+ content: segment.content.slice(pos),
+ isCharChanged: false,
+ });
+ }
+ }
+
+ charIndex = segEnd;
+ }
+
+ return result;
+ }
+
+ /**
+ * Get highlighted token segments for a line, with search matches and char-level diff applied.
*/
function getHighlightedTokens(
lineIndex: number,
@@ -816,7 +898,14 @@
): HighlightedSegment[] {
const tokens = side === 'before' ? getBeforeTokens(lineIndex) : getAfterTokens(lineIndex);
const matches = getSearchMatchesForLine(lineIndex, side);
- return applySearchHighlights(tokens, matches);
+ let segments = applySearchHighlights(tokens, matches);
+
+ const charHL = getCharHighlightsForLine(side, lineIndex);
+ if (charHL && charHL.length > 0) {
+ segments = applyCharHighlights(segments, charHL);
+ }
+
+ return segments;
}
// ==========================================================================
@@ -832,6 +921,32 @@
);
}
+ function getLineClassForLine(side: 'before' | 'after', lineIndex: number): BeforeLineClass | AfterLineClass | null {
+ return helperGetLineClass(
+ side,
+ lineIndex,
+ beforeLineToAlignment,
+ afterLineToAlignment,
+ changedAlignments,
+ beforeLines,
+ afterLines,
+ lineDiffCache
+ );
+ }
+
+ function getCharHighlightsForLine(side: 'before' | 'after', lineIndex: number): CharHighlight[] | null {
+ return helperGetCharHighlights(
+ side,
+ lineIndex,
+ beforeLineToAlignment,
+ afterLineToAlignment,
+ changedAlignments,
+ beforeLines,
+ afterLines,
+ lineDiffCache
+ );
+ }
+
function isLineSelected(pane: 'before' | 'after', lineIndex: number): boolean {
return helperIsLineSelected(pane, lineIndex, selectedLineRange);
}
@@ -1760,7 +1875,8 @@
: { isStart: false, isEnd: false }}
{@const isInHoveredRange = isLineInHoveredRange('before', i)}
{@const isInFocusedHunk = isLineInFocusedHunk('before', i)}
- {@const isChanged = showRangeMarkers && isLineInChangedAlignment('before', i)}
+ {@const lineClass = showRangeMarkers ? getLineClassForLine('before', i) : null}
+ {@const isChanged = lineClass !== null && lineClass !== 'unchanged'}
handleLineMouseEnter('before', i)}
onmouseleave={handleLineMouseLeave}
>
@@ -1778,6 +1895,7 @@
style="color: {segment.color}"
class:search-match={segment.isMatch && !segment.isCurrent}
class:search-current={segment.isCurrent}
+ class:char-changed={segment.isCharChanged}
>
{segment.content}
@@ -1851,6 +1969,7 @@
style="color: {segment.color}"
class:search-match={segment.isMatch && !segment.isCurrent}
class:search-current={segment.isCurrent}
+ class:char-changed={segment.isCharChanged}
>
{segment.content}
@@ -1927,7 +2046,8 @@
: { isStart: false, isEnd: false }}
{@const isInHoveredRange = isLineInHoveredRange('after', i)}
{@const isInFocusedHunk = isLineInFocusedHunk('after', i)}
- {@const isChanged = showRangeMarkers && isLineInChangedAlignment('after', i)}
+ {@const lineClass = showRangeMarkers ? getLineClassForLine('after', i) : null}
+ {@const isChanged = lineClass !== null && lineClass !== 'unchanged'}
{@const isSelected = isLineSelected('after', i)}
handleLineMouseEnter('after', i)}
onmouseleave={handleLineMouseLeave}
@@ -1948,6 +2069,7 @@
style="color: {segment.color}"
class:search-match={segment.isMatch && !segment.isCurrent}
class:search-current={segment.isCurrent}
+ class:char-changed={segment.isCharChanged}
>
{segment.content}
@@ -2553,6 +2675,15 @@
background-color: var(--diff-added-bg);
}
+ .line.diff-modified {
+ background-color: var(--diff-modified-bg);
+ }
+
+ .char-changed {
+ background-color: var(--diff-modified-inline-bg);
+ border-radius: 2px;
+ }
+
/* Range boundary markers */
.line.range-start::before {
content: '';
diff --git a/packages/diff-viewer/src/lib/utils/diffViewerHelpers.inlineDiff.test.ts b/packages/diff-viewer/src/lib/utils/diffViewerHelpers.inlineDiff.test.ts
new file mode 100644
index 00000000..2ffd0298
--- /dev/null
+++ b/packages/diff-viewer/src/lib/utils/diffViewerHelpers.inlineDiff.test.ts
@@ -0,0 +1,223 @@
+import { describe, expect, it } from 'vitest';
+import { getLineClass, getCharHighlights } from './diffViewerHelpers';
+import type { ChangedAlignmentEntry } from './diffViewerHelpers';
+import type { Alignment } from '../types';
+import { createLineDiffCache } from './inlineDiff';
+
+/**
+ * Helper to build alignment lookup maps and the changedAlignments array
+ * from a full alignment array. Maps line indices to the index within
+ * the changedAlignments array (matching production code).
+ */
+function buildLookups(alignments: Alignment[]) {
+ const beforeLineToAlignment = new Map
();
+ const afterLineToAlignment = new Map();
+ const changedAlignments: ChangedAlignmentEntry[] = [];
+
+ for (let i = 0; i < alignments.length; i++) {
+ const a = alignments[i];
+ if (a.changed) {
+ const changedIdx = changedAlignments.length;
+ changedAlignments.push({ alignment: a, index: i });
+ for (let line = a.before.start; line < a.before.end; line++) {
+ beforeLineToAlignment.set(line, changedIdx);
+ }
+ for (let line = a.after.start; line < a.after.end; line++) {
+ afterLineToAlignment.set(line, changedIdx);
+ }
+ }
+ }
+
+ return { beforeLineToAlignment, afterLineToAlignment, changedAlignments };
+}
+
+describe('getLineClass', () => {
+ it('returns null for lines not in a changed alignment', () => {
+ const alignments: Alignment[] = [
+ { before: { start: 0, end: 3 }, after: { start: 0, end: 3 }, changed: false },
+ ];
+ const { beforeLineToAlignment, afterLineToAlignment, changedAlignments } = buildLookups(alignments);
+
+ const result = getLineClass(
+ 'before', 1, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, ['a', 'b', 'c'], ['a', 'b', 'c'], createLineDiffCache(),
+ );
+ expect(result).toBeNull();
+ });
+
+ it('returns "modified" for a modified before-line in a changed alignment', () => {
+ const beforeLines = ['const x = 1;', 'unchanged'];
+ const afterLines = ['const x = 2;', 'unchanged'];
+ const alignments: Alignment[] = [
+ { before: { start: 0, end: 1 }, after: { start: 0, end: 1 }, changed: true },
+ { before: { start: 1, end: 2 }, after: { start: 1, end: 2 }, changed: false },
+ ];
+ const { beforeLineToAlignment, afterLineToAlignment, changedAlignments } = buildLookups(alignments);
+
+ const result = getLineClass(
+ 'before', 0, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, beforeLines, afterLines, createLineDiffCache(),
+ );
+ expect(result).toBe('modified');
+ });
+
+ it('returns "modified" for a modified after-line', () => {
+ const beforeLines = ['const x = 1;'];
+ const afterLines = ['const x = 2;'];
+ const alignments: Alignment[] = [
+ { before: { start: 0, end: 1 }, after: { start: 0, end: 1 }, changed: true },
+ ];
+ const { beforeLineToAlignment, afterLineToAlignment, changedAlignments } = buildLookups(alignments);
+
+ const result = getLineClass(
+ 'after', 0, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, beforeLines, afterLines, createLineDiffCache(),
+ );
+ expect(result).toBe('modified');
+ });
+
+ it('returns "removed" for a deleted before-line', () => {
+ const beforeLines = ['deleted line', 'kept line'];
+ const afterLines = ['kept line'];
+ const alignments: Alignment[] = [
+ { before: { start: 0, end: 1 }, after: { start: 0, end: 0 }, changed: true },
+ { before: { start: 1, end: 2 }, after: { start: 0, end: 1 }, changed: false },
+ ];
+ const { beforeLineToAlignment, afterLineToAlignment, changedAlignments } = buildLookups(alignments);
+
+ const result = getLineClass(
+ 'before', 0, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, beforeLines, afterLines, createLineDiffCache(),
+ );
+ expect(result).toBe('removed');
+ });
+
+ it('returns "added" for a new after-line', () => {
+ const beforeLines = ['kept line'];
+ const afterLines = ['kept line', 'new line'];
+ const alignments: Alignment[] = [
+ { before: { start: 0, end: 1 }, after: { start: 0, end: 1 }, changed: false },
+ { before: { start: 1, end: 1 }, after: { start: 1, end: 2 }, changed: true },
+ ];
+ const { beforeLineToAlignment, afterLineToAlignment, changedAlignments } = buildLookups(alignments);
+
+ const result = getLineClass(
+ 'after', 1, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, beforeLines, afterLines, createLineDiffCache(),
+ );
+ expect(result).toBe('added');
+ });
+
+ it('correctly maps line indices within multi-line changed alignments', () => {
+ // Alignment covers lines 2-4 in before, 2-5 in after
+ const beforeLines = ['ctx', 'ctx', 'const a = 1;', 'const b = true;', 'ctx'];
+ const afterLines = ['ctx', 'ctx', 'const a = 2;', 'newLine();', 'const b = false;', 'ctx'];
+ const alignments: Alignment[] = [
+ { before: { start: 0, end: 2 }, after: { start: 0, end: 2 }, changed: false },
+ { before: { start: 2, end: 4 }, after: { start: 2, end: 5 }, changed: true },
+ { before: { start: 4, end: 5 }, after: { start: 5, end: 6 }, changed: false },
+ ];
+ const { beforeLineToAlignment, afterLineToAlignment, changedAlignments } = buildLookups(alignments);
+
+ const cache = createLineDiffCache();
+
+ // Line 2 before: "const a = 1;" should be modified (similar to "const a = 2;")
+ expect(getLineClass(
+ 'before', 2, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, beforeLines, afterLines, cache,
+ )).toBe('modified');
+
+ // Line 3 in after: "newLine();" should be added (no similar before-line)
+ expect(getLineClass(
+ 'after', 3, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, beforeLines, afterLines, cache,
+ )).toBe('added');
+ });
+});
+
+describe('getCharHighlights', () => {
+ it('returns null for lines not in a changed alignment', () => {
+ const alignments: Alignment[] = [
+ { before: { start: 0, end: 1 }, after: { start: 0, end: 1 }, changed: false },
+ ];
+ const { beforeLineToAlignment, afterLineToAlignment, changedAlignments } = buildLookups(alignments);
+
+ const result = getCharHighlights(
+ 'before', 0, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, ['hello'], ['hello'], createLineDiffCache(),
+ );
+ expect(result).toBeNull();
+ });
+
+ it('returns null for non-modified lines in a changed alignment', () => {
+ const beforeLines = ['removed line'];
+ const afterLines = ['totally different content here'];
+ const alignments: Alignment[] = [
+ { before: { start: 0, end: 1 }, after: { start: 0, end: 1 }, changed: true },
+ ];
+ const { beforeLineToAlignment, afterLineToAlignment, changedAlignments } = buildLookups(alignments);
+
+ // Lines are too dissimilar to be "modified", so no char highlights
+ const result = getCharHighlights(
+ 'before', 0, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, beforeLines, afterLines, createLineDiffCache(),
+ );
+ expect(result).toBeNull();
+ });
+
+ it('returns highlights for a modified before-line', () => {
+ const beforeLines = ['const x = 1;'];
+ const afterLines = ['const x = 2;'];
+ const alignments: Alignment[] = [
+ { before: { start: 0, end: 1 }, after: { start: 0, end: 1 }, changed: true },
+ ];
+ const { beforeLineToAlignment, afterLineToAlignment, changedAlignments } = buildLookups(alignments);
+
+ const result = getCharHighlights(
+ 'before', 0, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, beforeLines, afterLines, createLineDiffCache(),
+ );
+ expect(result).not.toBeNull();
+ expect(result!.length).toBeGreaterThan(0);
+ // The highlight should cover "1;" (the changed part)
+ for (const h of result!) {
+ expect(h.start).toBeGreaterThanOrEqual(0);
+ expect(h.end).toBeGreaterThan(h.start);
+ expect(h.end).toBeLessThanOrEqual(beforeLines[0].length);
+ }
+ });
+
+ it('returns highlights for a modified after-line', () => {
+ const beforeLines = ['the quick brown fox'];
+ const afterLines = ['the slow brown fox'];
+ const alignments: Alignment[] = [
+ { before: { start: 0, end: 1 }, after: { start: 0, end: 1 }, changed: true },
+ ];
+ const { beforeLineToAlignment, afterLineToAlignment, changedAlignments } = buildLookups(alignments);
+
+ const result = getCharHighlights(
+ 'after', 0, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, beforeLines, afterLines, createLineDiffCache(),
+ );
+ expect(result).not.toBeNull();
+ // "slow" replaces "quick" -> highlight at position 4-8
+ expect(result).toEqual([{ start: 4, end: 8 }]);
+ });
+
+ it('works with offset line indices in alignments', () => {
+ const beforeLines = ['ctx', 'const x = 1;'];
+ const afterLines = ['ctx', 'const x = 2;'];
+ const alignments: Alignment[] = [
+ { before: { start: 0, end: 1 }, after: { start: 0, end: 1 }, changed: false },
+ { before: { start: 1, end: 2 }, after: { start: 1, end: 2 }, changed: true },
+ ];
+ const { beforeLineToAlignment, afterLineToAlignment, changedAlignments } = buildLookups(alignments);
+
+ const result = getCharHighlights(
+ 'after', 1, beforeLineToAlignment, afterLineToAlignment,
+ changedAlignments, beforeLines, afterLines, createLineDiffCache(),
+ );
+ expect(result).not.toBeNull();
+ expect(result!.length).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/diff-viewer/src/lib/utils/diffViewerHelpers.ts b/packages/diff-viewer/src/lib/utils/diffViewerHelpers.ts
index 874892e9..2b4451b3 100644
--- a/packages/diff-viewer/src/lib/utils/diffViewerHelpers.ts
+++ b/packages/diff-viewer/src/lib/utils/diffViewerHelpers.ts
@@ -1,5 +1,6 @@
import type { Alignment, Comment, SmartDiffAnnotation } from '../types';
import type { Token } from './highlighter';
+import type { LineDiffCache, BeforeLineClass, AfterLineClass, CharHighlight } from './inlineDiff.js';
export type ChangedAlignmentEntry = { alignment: Alignment; index: number };
export type PaneSide = 'before' | 'after';
@@ -284,3 +285,70 @@ export function buildLineSelectionToolbarLayout(
left,
};
}
+
+/**
+ * Get the line classification for a specific line within a changed alignment.
+ * Returns null if the line is not in a changed alignment.
+ */
+export function getLineClass(
+ side: PaneSide,
+ lineIndex: number,
+ beforeLineToAlignment: Map,
+ afterLineToAlignment: Map,
+ changedAlignments: ChangedAlignmentEntry[],
+ beforeLines: string[],
+ afterLines: string[],
+ cache: LineDiffCache
+): BeforeLineClass | AfterLineClass | null {
+ const map = side === 'before' ? beforeLineToAlignment : afterLineToAlignment;
+ const alignIdx = map.get(lineIndex);
+ if (alignIdx === undefined) return null;
+
+ const alignment = changedAlignments[alignIdx].alignment;
+ const alignBefore = beforeLines.slice(alignment.before.start, alignment.before.end);
+ const alignAfter = afterLines.slice(alignment.after.start, alignment.after.end);
+ const result = cache.get(alignBefore, alignAfter);
+
+ if (side === 'before') {
+ const localIdx = lineIndex - alignment.before.start;
+ return result.beforeLines[localIdx];
+ } else {
+ const localIdx = lineIndex - alignment.after.start;
+ return result.afterLines[localIdx];
+ }
+}
+
+/**
+ * Get character highlights for a line if it's a modified line in a changed alignment.
+ * Returns null if the line is not modified.
+ */
+export function getCharHighlights(
+ side: PaneSide,
+ lineIndex: number,
+ beforeLineToAlignment: Map,
+ afterLineToAlignment: Map,
+ changedAlignments: ChangedAlignmentEntry[],
+ beforeLines: string[],
+ afterLines: string[],
+ cache: LineDiffCache
+): CharHighlight[] | null {
+ const map = side === 'before' ? beforeLineToAlignment : afterLineToAlignment;
+ const alignIdx = map.get(lineIndex);
+ if (alignIdx === undefined) return null;
+
+ const alignment = changedAlignments[alignIdx].alignment;
+ const alignBefore = beforeLines.slice(alignment.before.start, alignment.before.end);
+ const alignAfter = afterLines.slice(alignment.after.start, alignment.after.end);
+ const result = cache.get(alignBefore, alignAfter);
+
+ const localIdx = side === 'before'
+ ? lineIndex - alignment.before.start
+ : lineIndex - alignment.after.start;
+
+ const pair = result.modifiedPairs.find(p =>
+ side === 'before' ? p.beforeLineIndex === localIdx : p.afterLineIndex === localIdx
+ );
+
+ if (!pair) return null;
+ return side === 'before' ? pair.beforeHighlights : pair.afterHighlights;
+}
diff --git a/packages/diff-viewer/src/lib/utils/index.ts b/packages/diff-viewer/src/lib/utils/index.ts
index 17702ff3..87379b2a 100644
--- a/packages/diff-viewer/src/lib/utils/index.ts
+++ b/packages/diff-viewer/src/lib/utils/index.ts
@@ -38,6 +38,8 @@ export {
measureLineHeight,
normalizeLineSelection,
resolveLineSelectionToolbarLeft,
+ getLineClass,
+ getCharHighlights,
} from './diffViewerHelpers';
export {
@@ -84,3 +86,6 @@ export {
createFileSelectionWithSearch,
type FileSelectionWithSearchConfig
} from './fileSelection';
+
+export { computeLineDiff, createLineDiffCache } from './inlineDiff.js';
+export type { BeforeLineClass, AfterLineClass, CharHighlight, ModifiedPair, LineDiffResult, LineDiffCache } from './inlineDiff.js';
diff --git a/packages/diff-viewer/src/lib/utils/inlineDiff.test.ts b/packages/diff-viewer/src/lib/utils/inlineDiff.test.ts
new file mode 100644
index 00000000..8c5b57fd
--- /dev/null
+++ b/packages/diff-viewer/src/lib/utils/inlineDiff.test.ts
@@ -0,0 +1,450 @@
+import { describe, expect, it } from 'vitest';
+import {
+ computeLineDiff,
+ createLineDiffCache,
+ type LineDiffResult,
+ type CharHighlight,
+} from './inlineDiff';
+
+describe('computeLineDiff', () => {
+ describe('identical lines', () => {
+ it('marks all lines unchanged when before and after are identical', () => {
+ const lines = ['line 1', 'line 2', 'line 3'];
+ const result = computeLineDiff(lines, lines);
+
+ expect(result.beforeLines).toEqual(['unchanged', 'unchanged', 'unchanged']);
+ expect(result.afterLines).toEqual(['unchanged', 'unchanged', 'unchanged']);
+ expect(result.modifiedPairs).toHaveLength(0);
+ });
+
+ it('handles empty input', () => {
+ const result = computeLineDiff([], []);
+ expect(result.beforeLines).toEqual([]);
+ expect(result.afterLines).toEqual([]);
+ expect(result.modifiedPairs).toHaveLength(0);
+ });
+
+ it('handles single identical line', () => {
+ const result = computeLineDiff(['hello'], ['hello']);
+ expect(result.beforeLines).toEqual(['unchanged']);
+ expect(result.afterLines).toEqual(['unchanged']);
+ });
+ });
+
+ describe('pure additions and removals', () => {
+ it('marks all lines as added when before is empty', () => {
+ const result = computeLineDiff([], ['line 1', 'line 2']);
+ expect(result.beforeLines).toEqual([]);
+ expect(result.afterLines).toEqual(['added', 'added']);
+ });
+
+ it('marks all lines as removed when after is empty', () => {
+ const result = computeLineDiff(['line 1', 'line 2'], []);
+ expect(result.beforeLines).toEqual(['removed', 'removed']);
+ expect(result.afterLines).toEqual([]);
+ });
+
+ it('marks completely different lines as removed/added', () => {
+ const before = ['aaa xyz', 'bbb xyz'];
+ const after = ['111 qqq', '222 qqq'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines).toEqual(['removed', 'removed']);
+ expect(result.afterLines).toEqual(['added', 'added']);
+ expect(result.modifiedPairs).toHaveLength(0);
+ });
+
+ it('detects additions at the end with unchanged context', () => {
+ const before = ['line 1', 'line 2'];
+ const after = ['line 1', 'line 2', 'line 3'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines).toEqual(['unchanged', 'unchanged']);
+ expect(result.afterLines).toEqual(['unchanged', 'unchanged', 'added']);
+ });
+
+ it('detects removals at the beginning with unchanged context', () => {
+ const before = ['line 0', 'line 1', 'line 2'];
+ const after = ['line 1', 'line 2'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines).toEqual(['removed', 'unchanged', 'unchanged']);
+ expect(result.afterLines).toEqual(['unchanged', 'unchanged']);
+ });
+
+ it('detects additions in the middle', () => {
+ const before = ['line 1', 'line 3'];
+ const after = ['line 1', 'line 2', 'line 3'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines).toEqual(['unchanged', 'unchanged']);
+ expect(result.afterLines).toEqual(['unchanged', 'added', 'unchanged']);
+ });
+ });
+
+ describe('modified line detection', () => {
+ it('pairs modified lines without offset', () => {
+ const before = ['const x = 1;'];
+ const after = ['const x = 2;'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines).toEqual(['modified']);
+ expect(result.afterLines).toEqual(['modified']);
+ expect(result.modifiedPairs).toHaveLength(1);
+ expect(result.modifiedPairs[0].beforeLineIndex).toBe(0);
+ expect(result.modifiedPairs[0].afterLineIndex).toBe(0);
+ });
+
+ it('detects multiple modified pairs', () => {
+ const before = ['const a = 1;', 'unchanged', 'const b = true;'];
+ const after = ['const a = 2;', 'unchanged', 'const b = false;'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines).toEqual(['modified', 'unchanged', 'modified']);
+ expect(result.afterLines).toEqual(['modified', 'unchanged', 'modified']);
+ expect(result.modifiedPairs).toHaveLength(2);
+ });
+
+ it('handles mixed modifications and unchanged lines', () => {
+ const before = ['first', 'const x = 1;', 'middle', 'last'];
+ const after = ['first', 'const x = 2;', 'middle', 'last'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines).toEqual(['unchanged', 'modified', 'unchanged', 'unchanged']);
+ expect(result.afterLines).toEqual(['unchanged', 'modified', 'unchanged', 'unchanged']);
+ expect(result.modifiedPairs).toHaveLength(1);
+ });
+ });
+
+ describe('insertion offset (peek-ahead)', () => {
+ it('detects a modified pair when an insertion precedes it', () => {
+ const before = [' content: string | string[];'];
+ const after = [' newField: boolean;', ' content: string | string[];'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines).toEqual(['unchanged']);
+ expect(result.afterLines).toEqual(['added', 'unchanged']);
+ });
+
+ it('detects modification through an insertion offset', () => {
+ const before = [' pattern: string | string[];'];
+ const after = [' newField: boolean;', ' pattern: string[];'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines).toEqual(['modified']);
+ expect(result.afterLines).toEqual(['added', 'modified']);
+ expect(result.modifiedPairs).toHaveLength(1);
+ expect(result.modifiedPairs[0].beforeLineIndex).toBe(0);
+ expect(result.modifiedPairs[0].afterLineIndex).toBe(1);
+ });
+
+ it('handles insertion before a modification with unchanged context', () => {
+ const before = ['header', ' pattern: string | string[];', 'footer'];
+ const after = ['header', '// totally new comment here', ' pattern: string[];', 'footer'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines).toEqual(['unchanged', 'modified', 'unchanged']);
+ expect(result.afterLines).toEqual(['unchanged', 'added', 'modified', 'unchanged']);
+ });
+ });
+
+ describe('character highlights', () => {
+ it('produces highlights for word-level changes', () => {
+ const before = ['const x = 1;'];
+ const after = ['const x = 2;'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.modifiedPairs).toHaveLength(1);
+ const pair = result.modifiedPairs[0];
+
+ // "1;" -> "2;" are the changed tokens
+ expect(pair.beforeHighlights.length).toBeGreaterThan(0);
+ expect(pair.afterHighlights.length).toBeGreaterThan(0);
+ });
+
+ it('highlights only the changed word in a sentence', () => {
+ const before = ['the quick brown fox'];
+ const after = ['the slow brown fox'];
+ const result = computeLineDiff(before, after);
+
+ const pair = result.modifiedPairs[0];
+ // "quick" is at position 4-9, "slow" is at position 4-8
+ expect(pair.beforeHighlights).toEqual([{ start: 4, end: 9 }]);
+ expect(pair.afterHighlights).toEqual([{ start: 4, end: 8 }]);
+ });
+
+ it('highlights multiple changed words', () => {
+ const before = ['function foo(bar: string): number {'];
+ const after = ['function baz(bar: boolean): string {'];
+ const result = computeLineDiff(before, after);
+
+ const pair = result.modifiedPairs[0];
+ expect(pair.beforeHighlights.length).toBeGreaterThanOrEqual(2);
+ expect(pair.afterHighlights.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('produces no highlights when lines are identical but in unmatched blocks', () => {
+ // This shouldn't happen in practice since identical lines would be
+ // caught by LCS, but the char-highlight logic should handle it
+ const before = [' return null;', ' return value;'];
+ const after = [' return value;'];
+ const result = computeLineDiff(before, after);
+
+ // "return null;" is removed, "return value;" is unchanged via LCS
+ expect(result.beforeLines[0]).toBe('removed');
+ expect(result.beforeLines[1]).toBe('unchanged');
+ expect(result.afterLines[0]).toBe('unchanged');
+ });
+ });
+
+ describe('complex scenarios', () => {
+ it('handles a realistic code diff with mixed changes', () => {
+ const before = [
+ 'import { foo } from "bar";',
+ '',
+ 'export function hello() {',
+ ' return "world";',
+ '}',
+ ];
+ const after = [
+ 'import { foo, baz } from "bar";',
+ 'import { qux } from "quux";',
+ '',
+ 'export function hello() {',
+ ' return "universe";',
+ '}',
+ ];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines[0]).toBe('modified'); // import changed
+ expect(result.beforeLines[1]).toBe('unchanged'); // empty line
+ expect(result.beforeLines[2]).toBe('unchanged'); // function decl
+ expect(result.beforeLines[3]).toBe('modified'); // return changed
+ expect(result.beforeLines[4]).toBe('unchanged'); // closing brace
+
+ expect(result.afterLines[0]).toBe('modified'); // import changed
+ expect(result.afterLines[1]).toBe('added'); // new import
+ expect(result.afterLines[2]).toBe('unchanged'); // empty line
+ expect(result.afterLines[3]).toBe('unchanged'); // function decl
+ expect(result.afterLines[4]).toBe('modified'); // return changed
+ expect(result.afterLines[5]).toBe('unchanged'); // closing brace
+ });
+
+ it('handles multiple consecutive removals followed by additions', () => {
+ const before = ['aaa', 'bbb', 'ccc'];
+ const after = ['xxx', 'yyy', 'zzz'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines).toEqual(['removed', 'removed', 'removed']);
+ expect(result.afterLines).toEqual(['added', 'added', 'added']);
+ });
+
+ it('detects re-indented lines as modified within a hunk with context', () => {
+ // Real-world case: code wrapped in an if-else block gains indentation.
+ // The hunk alignment includes context lines (identical on both sides)
+ // plus the changed lines. The re-indented lines should be "modified".
+ const before = [
+ 'This is critical - the application parses this to link the PR.',
+ '"#,',
+ ' pr_type = pr_type,',
+ ' base_branch = base_branch,',
+ ' branch_name = branch.branch_name,',
+ ' draft_flag = draft_flag,',
+ ' );',
+ '',
+ ' let mut session = store::Session::new_running(&prompt, &working_dir);',
+ ' if let Some(ref p) = provider {',
+ ];
+ const after = [
+ 'This is critical - the application parses this to link the PR.',
+ '"#,',
+ ' pr_type = pr_type,',
+ ' base_branch = base_branch,',
+ ' branch_name = branch.branch_name,',
+ ' draft_flag = draft_flag,',
+ ' )',
+ ' };',
+ '',
+ ' let mut session = store::Session::new_running(&prompt, &working_dir);',
+ ' if let Some(ref p) = provider {',
+ ];
+ const result = computeLineDiff(before, after);
+
+ // Context lines should be unchanged
+ expect(result.beforeLines[0]).toBe('unchanged');
+ expect(result.beforeLines[1]).toBe('unchanged');
+ expect(result.afterLines[0]).toBe('unchanged');
+ expect(result.afterLines[1]).toBe('unchanged');
+
+ // Re-indented lines should be modified, not removed/added
+ expect(result.beforeLines[2]).toBe('modified'); // pr_type
+ expect(result.beforeLines[3]).toBe('modified'); // base_branch
+ expect(result.beforeLines[4]).toBe('modified'); // branch_name
+ expect(result.beforeLines[5]).toBe('modified'); // draft_flag
+ expect(result.beforeLines[6]).toBe('modified'); // ); -> )
+
+ expect(result.afterLines[2]).toBe('modified'); // pr_type
+ expect(result.afterLines[3]).toBe('modified'); // base_branch
+ expect(result.afterLines[4]).toBe('modified'); // branch_name
+ expect(result.afterLines[5]).toBe('modified'); // draft_flag
+ expect(result.afterLines[6]).toBe('modified'); // )
+ expect(result.afterLines[7]).toBe('added'); // };
+
+ // Trailing context should be unchanged
+ expect(result.beforeLines[7]).toBe('unchanged');
+ expect(result.afterLines[8]).toBe('unchanged');
+ });
+
+ it('detects re-indented lines as modified when many insertions precede them', () => {
+ // Edge case: if two hunks were merged into a single alignment, the
+ // greedy matcher would see many inserted after-lines before reaching
+ // the re-indented lines. The scan-ahead handles this.
+ const before = [
+ ' let prompt = format!(',
+ ' r#"',
+ ' pr_type = pr_type,',
+ ' base_branch = base_branch,',
+ ' branch_name = branch.branch_name,',
+ ' draft_flag = draft_flag,',
+ ' );',
+ ];
+ const after = [
+ ' let prompt = if let Some(ctx) = git_context {',
+ ' format!(',
+ ' r#"',
+ 'Steps:',
+ '1. Push the current branch to the remote',
+ '"#,',
+ ' pr_type = pr_type,',
+ ' base_branch = base_branch,',
+ ' branch_name = branch.branch_name,',
+ ' draft_flag = draft_flag,',
+ ' log_output = ctx.log,',
+ ' stat_output = ctx.stat,',
+ ' )',
+ ' } else {',
+ ' format!(',
+ ' r#"',
+ ' pr_type = pr_type,',
+ ' base_branch = base_branch,',
+ ' branch_name = branch.branch_name,',
+ ' draft_flag = draft_flag,',
+ ' )',
+ ' };',
+ ];
+ const result = computeLineDiff(before, after);
+
+ // The re-indented lines should be modified, not removed/added
+ expect(result.beforeLines[2]).toBe('modified'); // pr_type
+ expect(result.beforeLines[3]).toBe('modified'); // base_branch
+ expect(result.beforeLines[4]).toBe('modified'); // branch_name
+ expect(result.beforeLines[5]).toBe('modified'); // draft_flag
+ });
+
+ it('classifies completely replaced code blocks as removed/added, not modified', () => {
+ // Real-world case: a `let prompt = format!(...)` block is replaced by
+ // a `let git_context = pre_compute_git_context(...)` call preceded by
+ // comments. The lines share structural tokens like `let ... = ...(` but
+ // are semantically unrelated and should NOT be paired as modified.
+ const before = [
+ ' let prompt = format!(',
+ ' r#"',
+ 'Create a {pr_type} for the current branch.',
+ '',
+ 'Steps:',
+ '1. First, look at the diff between the current branch and when it branched off of the base branch `{base_branch}`.',
+ '2. Push the current branch to the remote: `git push -u origin {branch_name}`',
+ '3. Create a PR using the GitHub CLI: `gh pr create --base {base_branch} --fill-first{draft_flag}`',
+ ];
+ const after = [
+ ' // Pre-compute git context in parallel so the agent can skip straight to',
+ ' // pushing and creating the PR instead of running these deterministic',
+ ' // commands itself.',
+ ' let git_context = pre_compute_git_context(',
+ ' is_remote,',
+ ' &working_dir,',
+ ' workspace_name.as_deref(),',
+ ' &store,',
+ ' &branch,',
+ ' base_branch,',
+ ' );',
+ ];
+ const result = computeLineDiff(before, after);
+
+ // Every before-line should be removed, every after-line should be added
+ for (let i = 0; i < before.length; i++) {
+ expect(result.beforeLines[i]).toBe('removed');
+ }
+ for (let i = 0; i < after.length; i++) {
+ expect(result.afterLines[i]).toBe('added');
+ }
+ expect(result.modifiedPairs).toHaveLength(0);
+ });
+
+ it('classifies replaced JSDoc descriptions as removed/added, not modified', () => {
+ // Real-world case: a JSDoc comment is completely rewritten. The
+ // description lines share structural tokens (` * `, `segments`,
+ // `highlights`) which inflate similarity despite being semantically
+ // unrelated. They should be removed/added, not modified.
+ const before = [
+ '/**',
+ ' * Get highlighted token segments for a line, with search matches applied.',
+ ' */',
+ ];
+ const after = [
+ '/**',
+ ' * Apply character-level diff highlights to segments by splitting them at highlight boundaries.',
+ ' * Works similarly to applySearchHighlights — walks through segments tracking column position.',
+ ' */',
+ ];
+ const result = computeLineDiff(before, after);
+
+ // /** and */ are context
+ expect(result.beforeLines[0]).toBe('unchanged');
+ expect(result.beforeLines[2]).toBe('unchanged');
+ expect(result.afterLines[0]).toBe('unchanged');
+ expect(result.afterLines[3]).toBe('unchanged');
+
+ // The description lines are too different to be "modified"
+ expect(result.beforeLines[1]).toBe('removed');
+ expect(result.afterLines[1]).toBe('added');
+ expect(result.afterLines[2]).toBe('added');
+ expect(result.modifiedPairs).toHaveLength(0);
+ });
+
+ it('handles interleaved unchanged and changed lines', () => {
+ const before = ['A', 'B', 'C', 'D', 'E'];
+ const after = ['A', 'B2', 'C', 'D2', 'E'];
+ const result = computeLineDiff(before, after);
+
+ expect(result.beforeLines[0]).toBe('unchanged');
+ expect(result.beforeLines[2]).toBe('unchanged');
+ expect(result.beforeLines[4]).toBe('unchanged');
+ // B and D are dissimilar enough to be removed/added rather than modified
+ expect(result.afterLines[0]).toBe('unchanged');
+ expect(result.afterLines[2]).toBe('unchanged');
+ expect(result.afterLines[4]).toBe('unchanged');
+ });
+ });
+});
+
+describe('createLineDiffCache', () => {
+ it('returns same result object for identical inputs', () => {
+ const cache = createLineDiffCache();
+ const before = ['const x = 1;'];
+ const after = ['const x = 2;'];
+
+ const result1 = cache.get(before, after);
+ const result2 = cache.get(before, after);
+
+ expect(result1).toBe(result2); // same reference
+ });
+
+ it('returns different result objects for different inputs', () => {
+ const cache = createLineDiffCache();
+ const result1 = cache.get(['a'], ['b']);
+ const result2 = cache.get(['c'], ['d']);
+
+ expect(result1).not.toBe(result2);
+ });
+});
diff --git a/packages/diff-viewer/src/lib/utils/inlineDiff.ts b/packages/diff-viewer/src/lib/utils/inlineDiff.ts
new file mode 100644
index 00000000..8d76b6ee
--- /dev/null
+++ b/packages/diff-viewer/src/lib/utils/inlineDiff.ts
@@ -0,0 +1,280 @@
+export type BeforeLineClass = 'removed' | 'modified' | 'unchanged';
+export type AfterLineClass = 'added' | 'modified' | 'unchanged';
+
+export interface CharHighlight {
+ start: number;
+ end: number;
+}
+
+export interface ModifiedPair {
+ beforeLineIndex: number;
+ afterLineIndex: number;
+ beforeHighlights: CharHighlight[];
+ afterHighlights: CharHighlight[];
+}
+
+export interface LineDiffResult {
+ beforeLines: BeforeLineClass[];
+ afterLines: AfterLineClass[];
+ modifiedPairs: ModifiedPair[];
+}
+
+function lcsIndices(
+ a: T[],
+ b: T[],
+ eq: (x: T, y: T) => boolean = (x, y) => x === y,
+): { aIndices: Set; bIndices: Set } {
+ const m = a.length;
+ const n = b.length;
+ const dp: number[][] = Array.from({ length: m + 1 }, () =>
+ new Array(n + 1).fill(0),
+ );
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (eq(a[i - 1], b[j - 1])) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ const aIndices = new Set();
+ const bIndices = new Set();
+ let i = m;
+ let j = n;
+ while (i > 0 && j > 0) {
+ if (eq(a[i - 1], b[j - 1])) {
+ aIndices.add(i - 1);
+ bIndices.add(j - 1);
+ i--;
+ j--;
+ } else if (dp[i - 1][j] >= dp[i][j - 1]) {
+ i--;
+ } else {
+ j--;
+ }
+ }
+
+ return { aIndices, bIndices };
+}
+
+function lcsLength(a: string, b: string): number {
+ const m = a.length;
+ const n = b.length;
+ const prev = new Array(n + 1).fill(0);
+ const curr = new Array(n + 1).fill(0);
+
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (a[i - 1] === b[j - 1]) {
+ curr[j] = prev[j - 1] + 1;
+ } else {
+ curr[j] = Math.max(prev[j], curr[j - 1]);
+ }
+ }
+ for (let j = 0; j <= n; j++) {
+ prev[j] = curr[j];
+ curr[j] = 0;
+ }
+ }
+
+ return prev[n];
+}
+
+function similarity(a: string, b: string): number {
+ if (a.length === 0 && b.length === 0) return 1;
+ if (a.length === 0 || b.length === 0) return 0;
+ const lcsLen = lcsLength(a, b);
+ return (2 * lcsLen) / (a.length + b.length);
+}
+
+const SIMILARITY_THRESHOLD = 0.55;
+
+function splitWords(text: string): string[] {
+ return text.split(/(\s+)/);
+}
+
+function computeCharHighlights(
+ before: string,
+ after: string,
+): { beforeHighlights: CharHighlight[]; afterHighlights: CharHighlight[] } {
+ const beforeTokens = splitWords(before);
+ const afterTokens = splitWords(after);
+
+ const { aIndices, bIndices } = lcsIndices(beforeTokens, afterTokens);
+
+ const beforeHighlights: CharHighlight[] = [];
+ let pos = 0;
+ for (let i = 0; i < beforeTokens.length; i++) {
+ const tokenLen = beforeTokens[i].length;
+ if (!aIndices.has(i)) {
+ beforeHighlights.push({ start: pos, end: pos + tokenLen });
+ }
+ pos += tokenLen;
+ }
+
+ const afterHighlights: CharHighlight[] = [];
+ pos = 0;
+ for (let i = 0; i < afterTokens.length; i++) {
+ const tokenLen = afterTokens[i].length;
+ if (!bIndices.has(i)) {
+ afterHighlights.push({ start: pos, end: pos + tokenLen });
+ }
+ pos += tokenLen;
+ }
+
+ return {
+ beforeHighlights: mergeHighlights(beforeHighlights),
+ afterHighlights: mergeHighlights(afterHighlights),
+ };
+}
+
+function mergeHighlights(highlights: CharHighlight[]): CharHighlight[] {
+ if (highlights.length === 0) return highlights;
+ const merged: CharHighlight[] = [highlights[0]];
+ for (let i = 1; i < highlights.length; i++) {
+ const prev = merged[merged.length - 1];
+ const curr = highlights[i];
+ if (curr.start <= prev.end) {
+ prev.end = Math.max(prev.end, curr.end);
+ } else {
+ merged.push(curr);
+ }
+ }
+ return merged;
+}
+
+const MAX_SCAN_AHEAD = 10;
+
+export function computeLineDiff(
+ beforeLines: string[],
+ afterLines: string[],
+): LineDiffResult {
+ const { aIndices: lcsBeforeIndices, bIndices: lcsAfterIndices } = lcsIndices(
+ beforeLines,
+ afterLines,
+ );
+
+ const beforeClasses: BeforeLineClass[] = new Array(beforeLines.length).fill(
+ 'unchanged',
+ );
+ const afterClasses: AfterLineClass[] = new Array(afterLines.length).fill(
+ 'unchanged',
+ );
+
+ const unmatchedBefore: number[] = [];
+ const unmatchedAfter: number[] = [];
+
+ for (let i = 0; i < beforeLines.length; i++) {
+ if (!lcsBeforeIndices.has(i)) {
+ unmatchedBefore.push(i);
+ }
+ }
+ for (let i = 0; i < afterLines.length; i++) {
+ if (!lcsAfterIndices.has(i)) {
+ unmatchedAfter.push(i);
+ }
+ }
+
+ const modifiedPairs: ModifiedPair[] = [];
+ let bi = 0;
+ let ai = 0;
+
+ while (bi < unmatchedBefore.length && ai < unmatchedAfter.length) {
+ const bIdx = unmatchedBefore[bi];
+ const aIdx = unmatchedAfter[ai];
+ const sim = similarity(beforeLines[bIdx].trim(), afterLines[aIdx].trim());
+
+ if (sim > SIMILARITY_THRESHOLD) {
+ beforeClasses[bIdx] = 'modified';
+ afterClasses[aIdx] = 'modified';
+ const { beforeHighlights, afterHighlights } = computeCharHighlights(
+ beforeLines[bIdx],
+ afterLines[aIdx],
+ );
+ modifiedPairs.push({
+ beforeLineIndex: bIdx,
+ afterLineIndex: aIdx,
+ beforeHighlights,
+ afterHighlights,
+ });
+ bi++;
+ ai++;
+ } else {
+ // Scan ahead in unmatchedAfter to find a match for the current
+ // before-line. This handles cases where many insertions precede a
+ // modification (e.g. code re-indented after being wrapped in a block).
+ let found = false;
+ const scanLimit = Math.min(ai + 1 + MAX_SCAN_AHEAD, unmatchedAfter.length);
+ for (let scan = ai + 1; scan < scanLimit; scan++) {
+ const scanIdx = unmatchedAfter[scan];
+ const scanSim = similarity(beforeLines[bIdx].trim(), afterLines[scanIdx].trim());
+ if (scanSim > SIMILARITY_THRESHOLD) {
+ // Mark all skipped after-lines as pure additions.
+ for (let skip = ai; skip < scan; skip++) {
+ afterClasses[unmatchedAfter[skip]] = 'added';
+ }
+ ai = scan;
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ beforeClasses[bIdx] = 'removed';
+ bi++;
+ }
+ }
+ }
+
+ while (bi < unmatchedBefore.length) {
+ beforeClasses[unmatchedBefore[bi]] = 'removed';
+ bi++;
+ }
+ while (ai < unmatchedAfter.length) {
+ afterClasses[unmatchedAfter[ai]] = 'added';
+ ai++;
+ }
+
+ return {
+ beforeLines: beforeClasses,
+ afterLines: afterClasses,
+ modifiedPairs,
+ };
+}
+
+const MAX_CACHE_SIZE = 100;
+
+function makeCacheKey(beforeLines: string[], afterLines: string[]): string {
+ return beforeLines.join('\n') + '\0' + afterLines.join('\n');
+}
+
+export interface LineDiffCache {
+ get(beforeLines: string[], afterLines: string[]): LineDiffResult;
+}
+
+export function createLineDiffCache(): LineDiffCache {
+ const cache = new Map();
+
+ return {
+ get(beforeLines: string[], afterLines: string[]): LineDiffResult {
+ const key = makeCacheKey(beforeLines, afterLines);
+ const cached = cache.get(key);
+ if (cached) return cached;
+
+ const result = computeLineDiff(beforeLines, afterLines);
+
+ if (cache.size >= MAX_CACHE_SIZE) {
+ const firstKey = cache.keys().next().value;
+ if (firstKey !== undefined) {
+ cache.delete(firstKey);
+ }
+ }
+ cache.set(key, result);
+
+ return result;
+ },
+ };
+}
+