From ec432cac1a9527d332685e57476bf1bc8a97eb2a Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 30 Nov 2025 17:04:14 +0100 Subject: [PATCH 01/26] add word highlights --- packages/core/src/renderables/Diff.ts | 506 +++++++++++++++--- .../src/renderables/LineNumberRenderable.ts | 87 ++- 2 files changed, 505 insertions(+), 88 deletions(-) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index 80d8d8485..bb9bf7375 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -1,12 +1,69 @@ import { Renderable, type RenderableOptions } from "../Renderable" import type { RenderContext } from "../types" import { CodeRenderable, type CodeOptions } from "./Code" -import { LineNumberRenderable, type LineSign, type LineColorConfig } from "./LineNumberRenderable" +import { + LineNumberRenderable, + type LineSign, + type LineColorConfig, + type LineInlineHighlight, +} from "./LineNumberRenderable" import { RGBA, parseColor } from "../lib/RGBA" import { SyntaxStyle } from "../syntax-style" -import { parsePatch, type StructuredPatch } from "diff" +import { parsePatch, diffChars, diffWordsWithSpace, type StructuredPatch } from "diff" import { TextRenderable } from "./Text" +/** Represents a highlighted span within a line for word-level diff */ +interface InlineHighlight { + startCol: number + endCol: number + type: "added-word" | "removed-word" +} + +/** Computes similarity between two strings (0.0 to 1.0) using character-level diff */ +function computeLineSimilarity(a: string, b: string): number { + if (a === b) return 1.0 + if (a.length === 0 && b.length === 0) return 1.0 + if (a.length === 0 || b.length === 0) return 0.0 + + const changes = diffChars(a, b) + let unchangedLength = 0 + for (const change of changes) { + if (!change.added && !change.removed) { + unchangedLength += change.value.length + } + } + return unchangedLength / Math.max(a.length, b.length) +} + +/** Computes word-level inline highlights for two strings */ +function computeInlineHighlights( + oldContent: string, + newContent: string, +): { oldHighlights: InlineHighlight[]; newHighlights: InlineHighlight[] } { + const changes = diffWordsWithSpace(oldContent, newContent) + + const oldHighlights: InlineHighlight[] = [] + const newHighlights: InlineHighlight[] = [] + let oldCol = 0 + let newCol = 0 + + for (const change of changes) { + const len = change.value.length + if (change.added) { + newHighlights.push({ startCol: newCol, endCol: newCol + len, type: "added-word" }) + newCol += len + } else if (change.removed) { + oldHighlights.push({ startCol: oldCol, endCol: oldCol + len, type: "removed-word" }) + oldCol += len + } else { + oldCol += len + newCol += len + } + } + + return { oldHighlights, newHighlights } +} + interface LogicalLine { content: string lineNum?: number @@ -14,6 +71,8 @@ interface LogicalLine { color?: string | RGBA sign?: LineSign type: "context" | "add" | "remove" | "empty" + /** Inline highlights for word-level diff */ + inlineHighlights?: InlineHighlight[] } export interface DiffRenderableOptions extends RenderableOptions { @@ -44,6 +103,30 @@ export interface DiffRenderableOptions extends RenderableOptions removedSignColor?: string | RGBA addedLineNumberBg?: string | RGBA removedLineNumberBg?: string | RGBA + + // Word-level highlighting options + /** + * Enable word-level highlighting within modified lines. + * When enabled, individual words/characters that changed are highlighted. + * @default true + */ + showWordHighlights?: boolean + /** + * Background color for added words within modified lines. + * @default A brighter version of addedBg + */ + addedWordBg?: string | RGBA + /** + * Background color for removed words within modified lines. + * @default A brighter version of removedBg + */ + removedWordBg?: string | RGBA + /** + * Minimum similarity threshold (0.0 to 1.0) for pairing lines. + * Lines with similarity below this threshold are treated as separate add/remove. + * @default 0.4 + */ + lineSimilarityThreshold?: number } export class DiffRenderable extends Renderable { @@ -77,6 +160,12 @@ export class DiffRenderable extends Renderable { private _addedLineNumberBg: RGBA private _removedLineNumberBg: RGBA + // Word-level highlighting + private _showWordHighlights: boolean + private _addedWordBg: RGBA + private _removedWordBg: RGBA + private _lineSimilarityThreshold: number + // Child renderables - reused for both unified and split views // Unified view uses only leftSide, split view uses both leftSide and rightSide private leftSide: LineNumberRenderable | null = null @@ -134,6 +223,17 @@ export class DiffRenderable extends Renderable { this._addedLineNumberBg = parseColor(options.addedLineNumberBg ?? "transparent") this._removedLineNumberBg = parseColor(options.removedLineNumberBg ?? "transparent") + // Word-level highlighting + this._showWordHighlights = options.showWordHighlights ?? true + this._lineSimilarityThreshold = options.lineSimilarityThreshold ?? 0.4 + // Default word highlight colors: brighter versions of the line colors + this._addedWordBg = options.addedWordBg + ? parseColor(options.addedWordBg) + : this.brightenColor(this._addedBg, 1.5) + this._removedWordBg = options.removedWordBg + ? parseColor(options.removedWordBg) + : this.brightenColor(this._removedBg, 1.5) + // Only parse and build if diff is provided if (this._diff) { this.parseDiff() @@ -141,6 +241,118 @@ export class DiffRenderable extends Renderable { } } + /** + * Brightens a color by a given factor. + * Used to create word highlight colors from line colors. + */ + private brightenColor(color: RGBA, factor: number): RGBA { + return RGBA.fromValues( + Math.min(1, color.r * factor), + Math.min(1, color.g * factor), + Math.min(1, color.b * factor), + color.a, + ) + } + + /** + * Processes a change block (consecutive removes and adds) with word-level highlighting. + * + * This method preserves the original positional pairing behavior for alignment + * (first remove with first add, etc.) while adding word-level highlights for + * lines that are similar enough to be considered modifications. + * + * The approach: + * 1. Use positional pairing for alignment (as the original code did) + * 2. Compute word highlights only when lines are similar enough + */ + // Maximum lines in a change block before skipping word highlights. + // Large blocks likely indicate bulk changes where word-level diffs aren't useful, + // and computing them would cause unnecessary CPU usage. + private static readonly MAX_WORD_HIGHLIGHT_BLOCK_SIZE = 50 + + private processChangeBlockWithHighlights( + removes: { content: string; lineNum: number }[], + adds: { content: string; lineNum: number }[], + ): { leftLines: LogicalLine[]; rightLines: LogicalLine[] } { + const leftLines: LogicalLine[] = [] + const rightLines: LogicalLine[] = [] + + // Use positional pairing (original behavior) for alignment + const maxLength = Math.max(removes.length, adds.length) + + // Skip word highlights for large blocks to prevent CPU spikes + const blockSize = removes.length + adds.length + const shouldComputeWordHighlights = + this._showWordHighlights && blockSize <= DiffRenderable.MAX_WORD_HIGHLIGHT_BLOCK_SIZE + + for (let j = 0; j < maxLength; j++) { + const remove = j < removes.length ? removes[j] : null + const add = j < adds.length ? adds[j] : null + + let leftHighlights: InlineHighlight[] = [] + let rightHighlights: InlineHighlight[] = [] + + // Compute word highlights only when: + // 1. Word highlights are enabled and block is small enough + // 2. Both lines exist (positional pair) + // 3. Lines are similar enough (above threshold) + if (shouldComputeWordHighlights && remove && add) { + const similarity = computeLineSimilarity(remove.content, add.content) + if (similarity >= this._lineSimilarityThreshold) { + const highlights = computeInlineHighlights(remove.content, add.content) + leftHighlights = highlights.oldHighlights + rightHighlights = highlights.newHighlights + } + } + + // Build left (old/remove) line + if (remove) { + leftLines.push({ + content: remove.content, + lineNum: remove.lineNum, + color: this._removedBg, + sign: { + after: " -", + afterColor: this._removedSignColor, + }, + type: "remove", + inlineHighlights: leftHighlights, + }) + } else { + // Empty placeholder for alignment + leftLines.push({ + content: "", + hideLineNumber: true, + type: "empty", + }) + } + + // Build right (new/add) line + if (add) { + rightLines.push({ + content: add.content, + lineNum: add.lineNum, + color: this._addedBg, + sign: { + after: " +", + afterColor: this._addedSignColor, + }, + type: "add", + inlineHighlights: rightHighlights, + }) + } else { + // Empty placeholder for alignment + rightLines.push({ + content: "", + hideLineNumber: true, + type: "empty", + }) + } + } + + return { leftLines, rightLines } + } + private parseDiff(): void { if (!this._diff) { this._parsedDiff = null @@ -363,6 +575,7 @@ export class DiffRenderable extends Renderable { lineNumbers: Map, hideLineNumbers: Set, width: "50%" | "100%", + inlineHighlights?: Map, ): void { const sideRef = side === "left" ? this.leftSide : this.rightSide const addedFlag = side === "left" ? this.leftSideAdded : this.rightSideAdded @@ -379,6 +592,7 @@ export class DiffRenderable extends Renderable { lineNumbers, lineNumberOffset: 0, hideLineNumbers, + inlineHighlights, width, height: "100%", }) @@ -399,6 +613,11 @@ export class DiffRenderable extends Renderable { sideRef.setLineSigns(lineSigns) sideRef.setLineNumbers(lineNumbers) sideRef.setHideLineNumbers(hideLineNumbers) + if (inlineHighlights) { + sideRef.setInlineHighlights(inlineHighlights) + } else { + sideRef.clearInlineHighlights() + } // Ensure side is added if not already if (!addedFlag) { @@ -436,6 +655,7 @@ export class DiffRenderable extends Renderable { const lineColors = new Map() const lineSigns = new Map() const lineNumbers = new Map() + const inlineHighlights = new Map() let lineIndex = 0 @@ -444,57 +664,18 @@ export class DiffRenderable extends Renderable { let oldLineNum = hunk.oldStart let newLineNum = hunk.newStart - for (const line of hunk.lines) { + let i = 0 + while (i < hunk.lines.length) { + const line = hunk.lines[i] const firstChar = line[0] const content = line.slice(1) - if (firstChar === "+") { - // Added line - contentLines.push(content) - const config: LineColorConfig = { - gutter: this._addedLineNumberBg, - } - // If explicit content background is set, use it; otherwise use gutter color (will be darkened) - if (this._addedContentBg) { - config.content = this._addedContentBg - } else { - config.content = this._addedBg - } - lineColors.set(lineIndex, config) - lineSigns.set(lineIndex, { - after: " +", - afterColor: this._addedSignColor, - }) - lineNumbers.set(lineIndex, newLineNum) - newLineNum++ - lineIndex++ - } else if (firstChar === "-") { - // Removed line - contentLines.push(content) - const config: LineColorConfig = { - gutter: this._removedLineNumberBg, - } - // If explicit content background is set, use it; otherwise use gutter color (will be darkened) - if (this._removedContentBg) { - config.content = this._removedContentBg - } else { - config.content = this._removedBg - } - lineColors.set(lineIndex, config) - lineSigns.set(lineIndex, { - after: " -", - afterColor: this._removedSignColor, - }) - lineNumbers.set(lineIndex, oldLineNum) - oldLineNum++ - lineIndex++ - } else if (firstChar === " ") { + if (firstChar === " ") { // Context line contentLines.push(content) const config: LineColorConfig = { gutter: this._lineNumberBg, } - // If explicit content background is set, use it; otherwise use contextBg if (this._contextContentBg) { config.content = this._contextContentBg } else { @@ -505,8 +686,108 @@ export class DiffRenderable extends Renderable { oldLineNum++ newLineNum++ lineIndex++ + i++ + } else if (firstChar === "\\") { + // Skip "\ No newline at end of file" + i++ + } else { + // Collect consecutive removes and adds as a block + const removes: { content: string; lineNum: number }[] = [] + const adds: { content: string; lineNum: number }[] = [] + + while (i < hunk.lines.length) { + const currentLine = hunk.lines[i] + const currentChar = currentLine[0] + + if (currentChar === " " || currentChar === "\\") { + break + } + + const currentContent = currentLine.slice(1) + + if (currentChar === "-") { + removes.push({ content: currentContent, lineNum: oldLineNum }) + oldLineNum++ + } else if (currentChar === "+") { + adds.push({ content: currentContent, lineNum: newLineNum }) + newLineNum++ + } + i++ + } + + // Process the block with word-level highlighting + const processedBlock = this.processChangeBlockWithHighlights(removes, adds) + + // In unified view, output removes first, then adds + // Collect lines from the processed block, preserving their highlights + for (const leftLine of processedBlock.leftLines) { + if (leftLine.type !== "empty") { + contentLines.push(leftLine.content) + const config: LineColorConfig = { + gutter: this._removedLineNumberBg, + } + if (this._removedContentBg) { + config.content = this._removedContentBg + } else { + config.content = this._removedBg + } + lineColors.set(lineIndex, config) + lineSigns.set(lineIndex, { + after: " -", + afterColor: this._removedSignColor, + }) + if (leftLine.lineNum !== undefined) { + lineNumbers.set(lineIndex, leftLine.lineNum) + } + // Add word highlights for this line + if (leftLine.inlineHighlights && leftLine.inlineHighlights.length > 0) { + inlineHighlights.set( + lineIndex, + leftLine.inlineHighlights.map((h) => ({ + startCol: h.startCol, + endCol: h.endCol, + bg: this._removedWordBg, + })), + ) + } + lineIndex++ + } + } + + for (const rightLine of processedBlock.rightLines) { + if (rightLine.type !== "empty") { + contentLines.push(rightLine.content) + const config: LineColorConfig = { + gutter: this._addedLineNumberBg, + } + if (this._addedContentBg) { + config.content = this._addedContentBg + } else { + config.content = this._addedBg + } + lineColors.set(lineIndex, config) + lineSigns.set(lineIndex, { + after: " +", + afterColor: this._addedSignColor, + }) + if (rightLine.lineNum !== undefined) { + lineNumbers.set(lineIndex, rightLine.lineNum) + } + // Add word highlights for this line + if (rightLine.inlineHighlights && rightLine.inlineHighlights.length > 0) { + inlineHighlights.set( + lineIndex, + rightLine.inlineHighlights.map((h) => ({ + startCol: h.startCol, + endCol: h.endCol, + bg: this._addedWordBg, + })), + ) + } + lineIndex++ + } + } } - // Skip "\ No newline at end of file" lines } } @@ -516,7 +797,16 @@ export class DiffRenderable extends Renderable { const codeRenderable = this.createOrUpdateCodeRenderable("left", content, this._wrapMode) // Create or update LineNumberRenderable (leftSide used for unified view) - this.createOrUpdateSide("left", codeRenderable, lineColors, lineSigns, lineNumbers, new Set(), "100%") + this.createOrUpdateSide( + "left", + codeRenderable, + lineColors, + lineSigns, + lineNumbers, + new Set(), + "100%", + inlineHighlights.size > 0 ? inlineHighlights : undefined, + ) // Remove rightSide from render tree for unified view if (this.rightSide && this.rightSideAdded) { @@ -605,47 +895,15 @@ export class DiffRenderable extends Renderable { i++ } - // Align the block: pair up removes and adds, padding as needed - const maxLength = Math.max(removes.length, adds.length) - - for (let j = 0; j < maxLength; j++) { - if (j < removes.length) { - leftLogicalLines.push({ - content: removes[j].content, - lineNum: removes[j].lineNum, - color: this._removedBg, - sign: { - after: " -", - afterColor: this._removedSignColor, - }, - type: "remove", - }) - } else { - leftLogicalLines.push({ - content: "", - hideLineNumber: true, - type: "empty", - }) - } + // Process the change block with word-level highlighting + const processedBlock = this.processChangeBlockWithHighlights(removes, adds) - if (j < adds.length) { - rightLogicalLines.push({ - content: adds[j].content, - lineNum: adds[j].lineNum, - color: this._addedBg, - sign: { - after: " +", - afterColor: this._addedSignColor, - }, - type: "add", - }) - } else { - rightLogicalLines.push({ - content: "", - hideLineNumber: true, - type: "empty", - }) - } + // Add processed lines to output + for (const leftLine of processedBlock.leftLines) { + leftLogicalLines.push(leftLine) + } + for (const rightLine of processedBlock.rightLines) { + rightLogicalLines.push(rightLine) } } } @@ -758,6 +1016,8 @@ export class DiffRenderable extends Renderable { const rightHideLineNumbers = new Set() const leftLineNumbers = new Map() const rightLineNumbers = new Map() + const leftInlineHighlights = new Map() + const rightInlineHighlights = new Map() finalLeftLines.forEach((line, index) => { if (line.lineNum !== undefined) { @@ -790,6 +1050,17 @@ export class DiffRenderable extends Renderable { if (line.sign) { leftLineSigns.set(index, line.sign) } + // Add inline highlights for word-level diff + if (line.inlineHighlights && line.inlineHighlights.length > 0) { + leftInlineHighlights.set( + index, + line.inlineHighlights.map((h) => ({ + startCol: h.startCol, + endCol: h.endCol, + bg: this._removedWordBg, + })), + ) + } }) finalRightLines.forEach((line, index) => { @@ -823,6 +1094,17 @@ export class DiffRenderable extends Renderable { if (line.sign) { rightLineSigns.set(index, line.sign) } + // Add inline highlights for word-level diff + if (line.inlineHighlights && line.inlineHighlights.length > 0) { + rightInlineHighlights.set( + index, + line.inlineHighlights.map((h) => ({ + startCol: h.startCol, + endCol: h.endCol, + bg: this._addedWordBg, + })), + ) + } }) const leftContentFinal = finalLeftLines.map((l) => l.content).join("\n") @@ -842,6 +1124,7 @@ export class DiffRenderable extends Renderable { leftLineNumbers, leftHideLineNumbers, "50%", + leftInlineHighlights.size > 0 ? leftInlineHighlights : undefined, ) this.createOrUpdateSide( "right", @@ -851,6 +1134,7 @@ export class DiffRenderable extends Renderable { rightLineNumbers, rightHideLineNumbers, "50%", + rightInlineHighlights.size > 0 ? rightInlineHighlights : undefined, ) } @@ -1113,4 +1397,52 @@ export class DiffRenderable extends Renderable { } } } + + // Word-level highlighting getters and setters + + public get showWordHighlights(): boolean { + return this._showWordHighlights + } + + public set showWordHighlights(value: boolean) { + if (this._showWordHighlights !== value) { + this._showWordHighlights = value + this.rebuildView() + } + } + + public get addedWordBg(): RGBA { + return this._addedWordBg + } + + public set addedWordBg(value: string | RGBA) { + const parsed = parseColor(value) + if (this._addedWordBg !== parsed) { + this._addedWordBg = parsed + this.rebuildView() + } + } + + public get removedWordBg(): RGBA { + return this._removedWordBg + } + + public set removedWordBg(value: string | RGBA) { + const parsed = parseColor(value) + if (this._removedWordBg !== parsed) { + this._removedWordBg = parsed + this.rebuildView() + } + } + + public get lineSimilarityThreshold(): number { + return this._lineSimilarityThreshold + } + + public set lineSimilarityThreshold(value: number) { + if (this._lineSimilarityThreshold !== value) { + this._lineSimilarityThreshold = Math.max(0, Math.min(1, value)) + this.rebuildView() + } + } } diff --git a/packages/core/src/renderables/LineNumberRenderable.ts b/packages/core/src/renderables/LineNumberRenderable.ts index ef58763c0..eb167a31f 100644 --- a/packages/core/src/renderables/LineNumberRenderable.ts +++ b/packages/core/src/renderables/LineNumberRenderable.ts @@ -16,6 +16,18 @@ export interface LineColorConfig { content?: string | RGBA } +/** + * Represents a highlighted span within a line for word-level diff highlighting. + */ +export interface LineInlineHighlight { + /** Starting column (0-based, in display characters) */ + startCol: number + /** Ending column (exclusive, in display characters) */ + endCol: number + /** Background color for this highlight */ + bg: RGBA +} + export interface LineNumberOptions extends RenderableOptions { target?: Renderable & LineInfoProvider fg?: string | RGBA @@ -28,6 +40,8 @@ export interface LineNumberOptions extends RenderableOptions lineNumbers?: Map showLineNumbers?: boolean + /** Inline highlights for word-level diff highlighting (per logical line) */ + inlineHighlights?: Map } class GutterRenderable extends Renderable { @@ -327,6 +341,7 @@ export class LineNumberRenderable extends Renderable { private _lineColorsGutter: Map private _lineColorsContent: Map private _lineSigns: Map + private _inlineHighlights: Map private _fg: RGBA private _bg: RGBA private _minWidth: number @@ -394,6 +409,13 @@ export class LineNumberRenderable extends Renderable { } } + this._inlineHighlights = new Map() + if (options.inlineHighlights) { + for (const [line, highlights] of options.inlineHighlights) { + this._inlineHighlights.set(line, highlights) + } + } + // If target is provided in constructor, set it up immediately if (options.target) { this.setTarget(options.target) @@ -506,6 +528,7 @@ export class LineNumberRenderable extends Renderable { // Calculate the area to fill: from after the gutter (if visible) to the end of our width const gutterWidth = this.gutter.visible ? this.gutter.width : 0 const contentWidth = this.width - gutterWidth + const contentStartX = this.x + gutterWidth // Draw full-width background colors for lines with custom colors for (let i = 0; i < this.height; i++) { @@ -517,7 +540,25 @@ export class LineNumberRenderable extends Renderable { if (lineBg) { // Fill from after gutter to the end of the LineNumberRenderable - buffer.fillRect(this.x + gutterWidth, this.y + i, contentWidth, 1, lineBg) + buffer.fillRect(contentStartX, this.y + i, contentWidth, 1, lineBg) + } + + // Draw inline highlights for this line (word-level diff highlighting) + const inlineHighlights = this._inlineHighlights.get(logicalLine) + if (inlineHighlights && inlineHighlights.length > 0) { + for (const highlight of inlineHighlights) { + const highlightStartX = contentStartX + highlight.startCol + const highlightWidth = highlight.endCol - highlight.startCol + + // Clamp to visible content area + const clampedStartX = Math.max(highlightStartX, contentStartX) + const clampedEndX = Math.min(highlightStartX + highlightWidth, contentStartX + contentWidth) + const clampedWidth = clampedEndX - clampedStartX + + if (clampedWidth > 0) { + buffer.fillRect(clampedStartX, this.y + i, clampedWidth, 1, highlight.bg) + } + } } } } @@ -647,4 +688,48 @@ export class LineNumberRenderable extends Renderable { public getLineNumbers(): Map { return this._lineNumbers } + + /** + * Sets inline highlights for word-level diff highlighting. + * + * @param inlineHighlights - Map from logical line index to array of highlights + */ + public setInlineHighlights(inlineHighlights: Map): void { + this._inlineHighlights = inlineHighlights + this.requestRender() + } + + /** + * Gets the current inline highlights. + */ + public getInlineHighlights(): Map { + return this._inlineHighlights + } + + /** + * Clears all inline highlights. + */ + public clearInlineHighlights(): void { + this._inlineHighlights.clear() + this.requestRender() + } + + /** + * Sets inline highlights for a specific line. + * + * @param line - Logical line index + * @param highlights - Array of highlights for this line + */ + public setLineInlineHighlights(line: number, highlights: LineInlineHighlight[]): void { + this._inlineHighlights.set(line, highlights) + this.requestRender() + } + + /** + * Clears inline highlights for a specific line. + */ + public clearLineInlineHighlights(line: number): void { + this._inlineHighlights.delete(line) + this.requestRender() + } } From c0ceee17aecec87b2b700d136af9b88bdc330085 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 30 Nov 2025 17:07:32 +0100 Subject: [PATCH 02/26] test: add word highlight tests --- packages/core/src/renderables/Diff.test.ts | 244 ++++++++++++++++++++- packages/core/src/renderables/Diff.ts | 4 +- 2 files changed, 244 insertions(+), 4 deletions(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index 35b70d06e..c9bff3aa6 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -1,5 +1,5 @@ -import { test, expect, beforeEach, afterEach } from "bun:test" -import { DiffRenderable } from "./Diff" +import { test, expect, beforeEach, afterEach, describe } from "bun:test" +import { DiffRenderable, computeLineSimilarity, computeInlineHighlights } from "./Diff" import { SyntaxStyle } from "../syntax-style" import { RGBA } from "../lib/RGBA" import { createTestRenderer, type TestRenderer } from "../testing" @@ -2229,3 +2229,243 @@ test("DiffRenderable - properly cleans up listeners on destroy", async () => { expect(rightSide.isDestroyed).toBe(true) } }) + +// ============================================================================= +// Word-level highlight tests +// ============================================================================= + +describe("computeLineSimilarity", () => { + test("returns 1.0 for identical strings", () => { + expect(computeLineSimilarity("hello world", "hello world")).toBe(1.0) + }) + + test("returns 1.0 for both empty strings", () => { + expect(computeLineSimilarity("", "")).toBe(1.0) + }) + + test("returns 0.0 when one string is empty", () => { + expect(computeLineSimilarity("hello", "")).toBe(0.0) + expect(computeLineSimilarity("", "hello")).toBe(0.0) + }) + + test("returns high similarity for small changes", () => { + const similarity = computeLineSimilarity("const x = 1", "const x = 2") + expect(similarity).toBeGreaterThan(0.8) + }) + + test("returns low similarity for completely different strings", () => { + const similarity = computeLineSimilarity("abc", "xyz") + expect(similarity).toBe(0.0) + }) + + test("returns partial similarity for partially matching strings", () => { + const similarity = computeLineSimilarity("hello world", "hello there") + expect(similarity).toBeGreaterThan(0.4) + expect(similarity).toBeLessThan(0.7) + }) +}) + +describe("computeInlineHighlights", () => { + test("returns empty highlights for identical strings", () => { + const result = computeInlineHighlights("hello world", "hello world") + expect(result.oldHighlights).toHaveLength(0) + expect(result.newHighlights).toHaveLength(0) + }) + + test("highlights changed words", () => { + const result = computeInlineHighlights("hello world", "hello there") + + // "world" should be highlighted as removed + expect(result.oldHighlights.length).toBeGreaterThan(0) + expect(result.oldHighlights[0].type).toBe("removed-word") + + // "there" should be highlighted as added + expect(result.newHighlights.length).toBeGreaterThan(0) + expect(result.newHighlights[0].type).toBe("added-word") + }) + + test("computes correct column positions", () => { + const result = computeInlineHighlights("const x = 1", "const x = 2") + + // The "1" is at position 10, "2" is at position 10 + expect(result.oldHighlights[0].startCol).toBe(10) + expect(result.oldHighlights[0].endCol).toBe(11) + expect(result.newHighlights[0].startCol).toBe(10) + expect(result.newHighlights[0].endCol).toBe(11) + }) + + test("handles multiple changes", () => { + const result = computeInlineHighlights("a b c", "x b z") + + // "a" and "c" should be removed, "x" and "z" should be added + expect(result.oldHighlights.length).toBe(2) + expect(result.newHighlights.length).toBe(2) + }) +}) + +describe("DiffRenderable word highlights", () => { + test("word highlight options have correct defaults", async () => { + const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(1, 1, 1, 1) }, + }) + + const diffRenderable = new DiffRenderable(currentRenderer, { + id: "test-diff", + diff: simpleDiff, + view: "split", + syntaxStyle, + }) + + expect(diffRenderable.showWordHighlights).toBe(true) + expect(diffRenderable.lineSimilarityThreshold).toBe(0.4) + expect(diffRenderable.addedWordBg).toBeDefined() + expect(diffRenderable.removedWordBg).toBeDefined() + }) + + test("can disable word highlights", async () => { + const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(1, 1, 1, 1) }, + }) + + const diffRenderable = new DiffRenderable(currentRenderer, { + id: "test-diff", + diff: simpleDiff, + view: "split", + syntaxStyle, + showWordHighlights: false, + }) + + expect(diffRenderable.showWordHighlights).toBe(false) + + // Can update it + diffRenderable.showWordHighlights = true + expect(diffRenderable.showWordHighlights).toBe(true) + }) + + test("can customize word highlight colors", async () => { + const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(1, 1, 1, 1) }, + }) + + const diffRenderable = new DiffRenderable(currentRenderer, { + id: "test-diff", + diff: simpleDiff, + view: "split", + syntaxStyle, + addedWordBg: "#00ff00", + removedWordBg: "#ff0000", + }) + + expect(diffRenderable.addedWordBg).toEqual(RGBA.fromHex("#00ff00")) + expect(diffRenderable.removedWordBg).toEqual(RGBA.fromHex("#ff0000")) + }) + + test("can adjust similarity threshold", async () => { + const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(1, 1, 1, 1) }, + }) + + const diffRenderable = new DiffRenderable(currentRenderer, { + id: "test-diff", + diff: simpleDiff, + view: "split", + syntaxStyle, + lineSimilarityThreshold: 0.8, + }) + + expect(diffRenderable.lineSimilarityThreshold).toBe(0.8) + + // Can update it + diffRenderable.lineSimilarityThreshold = 0.5 + expect(diffRenderable.lineSimilarityThreshold).toBe(0.5) + + // Values are clamped to 0-1 + diffRenderable.lineSimilarityThreshold = 1.5 + expect(diffRenderable.lineSimilarityThreshold).toBe(1.0) + + diffRenderable.lineSimilarityThreshold = -0.5 + expect(diffRenderable.lineSimilarityThreshold).toBe(0.0) + }) + + test("renders correctly with word highlights enabled in split view", async () => { + const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(1, 1, 1, 1) }, + }) + + const diffRenderable = new DiffRenderable(currentRenderer, { + id: "test-diff", + diff: simpleDiff, + view: "split", + syntaxStyle, + showWordHighlights: true, + width: "100%", + height: "100%", + }) + + currentRenderer.root.add(diffRenderable) + await renderOnce() + + const frame = captureFrame() + // Content should still render correctly + expect(frame).toContain("function hello") + expect(frame).toContain("console.log") + }) + + test("renders correctly with word highlights enabled in unified view", async () => { + const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(1, 1, 1, 1) }, + }) + + const diffRenderable = new DiffRenderable(currentRenderer, { + id: "test-diff", + diff: simpleDiff, + view: "unified", + syntaxStyle, + showWordHighlights: true, + width: "100%", + height: "100%", + }) + + currentRenderer.root.add(diffRenderable) + await renderOnce() + + const frame = captureFrame() + // Content should still render correctly + expect(frame).toContain("function hello") + expect(frame).toContain("console.log") + }) + + test("large change blocks skip word highlights for performance", async () => { + const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(1, 1, 1, 1) }, + }) + + // Create a diff with more than 50 lines changed + const manyLines = Array.from({ length: 30 }, (_, i) => `-line${i}`).join("\n") + const manyAdds = Array.from({ length: 30 }, (_, i) => `+newline${i}`).join("\n") + const largeDiff = `--- a/test.js ++++ b/test.js +@@ -1,30 +1,30 @@ +${manyLines} +${manyAdds}` + + const diffRenderable = new DiffRenderable(currentRenderer, { + id: "test-diff", + diff: largeDiff, + view: "split", + syntaxStyle, + showWordHighlights: true, + width: "100%", + height: "100%", + }) + + currentRenderer.root.add(diffRenderable) + + // Should not throw or hang - large blocks are skipped + await renderOnce() + + const frame = captureFrame() + expect(frame).toContain("line0") + expect(frame).toContain("newline0") + }) +}) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index bb9bf7375..ebae545f5 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -20,7 +20,7 @@ interface InlineHighlight { } /** Computes similarity between two strings (0.0 to 1.0) using character-level diff */ -function computeLineSimilarity(a: string, b: string): number { +export function computeLineSimilarity(a: string, b: string): number { if (a === b) return 1.0 if (a.length === 0 && b.length === 0) return 1.0 if (a.length === 0 || b.length === 0) return 0.0 @@ -36,7 +36,7 @@ function computeLineSimilarity(a: string, b: string): number { } /** Computes word-level inline highlights for two strings */ -function computeInlineHighlights( +export function computeInlineHighlights( oldContent: string, newContent: string, ): { oldHighlights: InlineHighlight[]; newHighlights: InlineHighlight[] } { From 85e2afd47598537dbfc61307f75a43e97d918e56 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 30 Nov 2025 17:10:32 +0100 Subject: [PATCH 03/26] format --- packages/core/src/renderables/Diff.test.ts | 10 +++++----- packages/core/src/renderables/Diff.ts | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index c9bff3aa6..c34216879 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -2274,11 +2274,11 @@ describe("computeInlineHighlights", () => { test("highlights changed words", () => { const result = computeInlineHighlights("hello world", "hello there") - + // "world" should be highlighted as removed expect(result.oldHighlights.length).toBeGreaterThan(0) expect(result.oldHighlights[0].type).toBe("removed-word") - + // "there" should be highlighted as added expect(result.newHighlights.length).toBeGreaterThan(0) expect(result.newHighlights[0].type).toBe("added-word") @@ -2286,7 +2286,7 @@ describe("computeInlineHighlights", () => { test("computes correct column positions", () => { const result = computeInlineHighlights("const x = 1", "const x = 2") - + // The "1" is at position 10, "2" is at position 10 expect(result.oldHighlights[0].startCol).toBe(10) expect(result.oldHighlights[0].endCol).toBe(11) @@ -2296,7 +2296,7 @@ describe("computeInlineHighlights", () => { test("handles multiple changes", () => { const result = computeInlineHighlights("a b c", "x b z") - + // "a" and "c" should be removed, "x" and "z" should be added expect(result.oldHighlights.length).toBe(2) expect(result.newHighlights.length).toBe(2) @@ -2460,7 +2460,7 @@ ${manyAdds}` }) currentRenderer.root.add(diffRenderable) - + // Should not throw or hang - large blocks are skipped await renderOnce() diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index ebae545f5..3801b6375 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -227,9 +227,7 @@ export class DiffRenderable extends Renderable { this._showWordHighlights = options.showWordHighlights ?? true this._lineSimilarityThreshold = options.lineSimilarityThreshold ?? 0.4 // Default word highlight colors: brighter versions of the line colors - this._addedWordBg = options.addedWordBg - ? parseColor(options.addedWordBg) - : this.brightenColor(this._addedBg, 1.5) + this._addedWordBg = options.addedWordBg ? parseColor(options.addedWordBg) : this.brightenColor(this._addedBg, 1.5) this._removedWordBg = options.removedWordBg ? parseColor(options.removedWordBg) : this.brightenColor(this._removedBg, 1.5) From 45884fe3fe62c4bcef341b3a2f539b88f7a694c5 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 30 Nov 2025 17:59:34 +0100 Subject: [PATCH 04/26] fix: use Bun.stringWidth for multi-width character support in word highlights --- packages/core/src/renderables/Diff.test.ts | 27 ++++++++++++++++++++++ packages/core/src/renderables/Diff.ts | 16 +++++++------ packages/react/examples/diff.tsx | 11 +++++++-- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index c34216879..6c4ce0950 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -2301,6 +2301,33 @@ describe("computeInlineHighlights", () => { expect(result.oldHighlights.length).toBe(2) expect(result.newHighlights.length).toBe(2) }) + + test("handles multi-width characters (CJK)", () => { + // CJK characters have display width of 2 each + const result = computeInlineHighlights("hello 世界", "hello 你好") + + // "世界" is removed (display width 4), "你好" is added (display width 4) + expect(result.oldHighlights.length).toBe(1) + expect(result.newHighlights.length).toBe(1) + + // "hello " has display width 6, so CJK starts at column 6 + expect(result.oldHighlights[0].startCol).toBe(6) + expect(result.oldHighlights[0].endCol).toBe(10) // 6 + 4 (two CJK chars) + expect(result.newHighlights[0].startCol).toBe(6) + expect(result.newHighlights[0].endCol).toBe(10) + }) + + test("handles emoji characters", () => { + // Emoji typically have display width of 2 + const result = computeInlineHighlights("test 👍", "test 👎") + + expect(result.oldHighlights.length).toBe(1) + expect(result.newHighlights.length).toBe(1) + + // "test " has display width 5 + expect(result.oldHighlights[0].startCol).toBe(5) + expect(result.newHighlights[0].startCol).toBe(5) + }) }) describe("DiffRenderable word highlights", () => { diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index 3801b6375..28ee4a75b 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -48,16 +48,18 @@ export function computeInlineHighlights( let newCol = 0 for (const change of changes) { - const len = change.value.length + // Use Bun.stringWidth for display width instead of .length + // This handles multi-width characters (CJK, emoji) correctly + const displayWidth = Bun.stringWidth(change.value) if (change.added) { - newHighlights.push({ startCol: newCol, endCol: newCol + len, type: "added-word" }) - newCol += len + newHighlights.push({ startCol: newCol, endCol: newCol + displayWidth, type: "added-word" }) + newCol += displayWidth } else if (change.removed) { - oldHighlights.push({ startCol: oldCol, endCol: oldCol + len, type: "removed-word" }) - oldCol += len + oldHighlights.push({ startCol: oldCol, endCol: oldCol + displayWidth, type: "removed-word" }) + oldCol += displayWidth } else { - oldCol += len - newCol += len + oldCol += displayWidth + newCol += displayWidth } } diff --git a/packages/react/examples/diff.tsx b/packages/react/examples/diff.tsx index e58a22f58..2832d651c 100644 --- a/packages/react/examples/diff.tsx +++ b/packages/react/examples/diff.tsx @@ -130,8 +130,10 @@ const themes: DiffTheme[] = [ const exampleDiff = `--- a/calculator.ts +++ b/calculator.ts -@@ -1,13 +1,20 @@ +@@ -1,18 +1,28 @@ class Calculator { +- // Basic math operations 🔢 ++ // Basic math operations ➕➖✖️➗ add(a: number, b: number): number { return a + b; } @@ -148,10 +150,15 @@ const exampleDiff = `--- a/calculator.ts + + divide(a: number, b: number): number { + if (b === 0) { -+ throw new Error("Division by zero"); ++ throw new Error("Division by zero ❌"); + } + return a / b; + } + +- // Status: 👍 working +- // 日本語コメント ++ // Status: ✅ all tests pass ++ // 中文注释 Chinese comment }` const HelpModal = ({ theme, visible }: { theme: DiffTheme; visible: boolean }) => { From ebfc48cbaa43898be5f8aab6265cf6a7a964cc28 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 30 Nov 2025 18:00:26 +0100 Subject: [PATCH 05/26] fix: correct line counts in example diff hunk header --- packages/react/examples/diff.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/examples/diff.tsx b/packages/react/examples/diff.tsx index 2832d651c..3da40d04c 100644 --- a/packages/react/examples/diff.tsx +++ b/packages/react/examples/diff.tsx @@ -130,7 +130,7 @@ const themes: DiffTheme[] = [ const exampleDiff = `--- a/calculator.ts +++ b/calculator.ts -@@ -1,18 +1,28 @@ +@@ -1,17 +1,24 @@ class Calculator { - // Basic math operations 🔢 + // Basic math operations ➕➖✖️➗ From a5a913680945837295bcb39cc2b018b6d4b17191 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 30 Nov 2025 18:02:07 +0100 Subject: [PATCH 06/26] update react example --- packages/react/examples/diff.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/examples/diff.tsx b/packages/react/examples/diff.tsx index 3da40d04c..1d78e19cb 100644 --- a/packages/react/examples/diff.tsx +++ b/packages/react/examples/diff.tsx @@ -133,7 +133,7 @@ const exampleDiff = `--- a/calculator.ts @@ -1,17 +1,24 @@ class Calculator { - // Basic math operations 🔢 -+ // Basic math operations ➕➖✖️➗ ++ // Basic math operations ➕➗ add(a: number, b: number): number { return a + b; } From d663823ee403f6947e7c01f6addd1d137ed964a2 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 30 Nov 2025 18:05:15 +0100 Subject: [PATCH 07/26] rename showWordHighlights to disableWordHighlights --- packages/core/src/renderables/Diff.test.ts | 13 +++++------- packages/core/src/renderables/Diff.ts | 24 +++++++++++----------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index 6c4ce0950..bcbb0a60d 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -2343,7 +2343,7 @@ describe("DiffRenderable word highlights", () => { syntaxStyle, }) - expect(diffRenderable.showWordHighlights).toBe(true) + expect(diffRenderable.disableWordHighlights).toBe(false) expect(diffRenderable.lineSimilarityThreshold).toBe(0.4) expect(diffRenderable.addedWordBg).toBeDefined() expect(diffRenderable.removedWordBg).toBeDefined() @@ -2359,14 +2359,14 @@ describe("DiffRenderable word highlights", () => { diff: simpleDiff, view: "split", syntaxStyle, - showWordHighlights: false, + disableWordHighlights: true, }) - expect(diffRenderable.showWordHighlights).toBe(false) + expect(diffRenderable.disableWordHighlights).toBe(true) // Can update it - diffRenderable.showWordHighlights = true - expect(diffRenderable.showWordHighlights).toBe(true) + diffRenderable.disableWordHighlights = false + expect(diffRenderable.disableWordHighlights).toBe(false) }) test("can customize word highlight colors", async () => { @@ -2424,7 +2424,6 @@ describe("DiffRenderable word highlights", () => { diff: simpleDiff, view: "split", syntaxStyle, - showWordHighlights: true, width: "100%", height: "100%", }) @@ -2448,7 +2447,6 @@ describe("DiffRenderable word highlights", () => { diff: simpleDiff, view: "unified", syntaxStyle, - showWordHighlights: true, width: "100%", height: "100%", }) @@ -2481,7 +2479,6 @@ ${manyAdds}` diff: largeDiff, view: "split", syntaxStyle, - showWordHighlights: true, width: "100%", height: "100%", }) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index 28ee4a75b..1a08f71c7 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -108,11 +108,11 @@ export interface DiffRenderableOptions extends RenderableOptions // Word-level highlighting options /** - * Enable word-level highlighting within modified lines. - * When enabled, individual words/characters that changed are highlighted. - * @default true + * Disable word-level highlighting within modified lines. + * When false (default), individual words/characters that changed are highlighted. + * @default false */ - showWordHighlights?: boolean + disableWordHighlights?: boolean /** * Background color for added words within modified lines. * @default A brighter version of addedBg @@ -163,7 +163,7 @@ export class DiffRenderable extends Renderable { private _removedLineNumberBg: RGBA // Word-level highlighting - private _showWordHighlights: boolean + private _disableWordHighlights: boolean private _addedWordBg: RGBA private _removedWordBg: RGBA private _lineSimilarityThreshold: number @@ -226,7 +226,7 @@ export class DiffRenderable extends Renderable { this._removedLineNumberBg = parseColor(options.removedLineNumberBg ?? "transparent") // Word-level highlighting - this._showWordHighlights = options.showWordHighlights ?? true + this._disableWordHighlights = options.disableWordHighlights ?? false this._lineSimilarityThreshold = options.lineSimilarityThreshold ?? 0.4 // Default word highlight colors: brighter versions of the line colors this._addedWordBg = options.addedWordBg ? parseColor(options.addedWordBg) : this.brightenColor(this._addedBg, 1.5) @@ -283,7 +283,7 @@ export class DiffRenderable extends Renderable { // Skip word highlights for large blocks to prevent CPU spikes const blockSize = removes.length + adds.length const shouldComputeWordHighlights = - this._showWordHighlights && blockSize <= DiffRenderable.MAX_WORD_HIGHLIGHT_BLOCK_SIZE + !this._disableWordHighlights && blockSize <= DiffRenderable.MAX_WORD_HIGHLIGHT_BLOCK_SIZE for (let j = 0; j < maxLength; j++) { const remove = j < removes.length ? removes[j] : null @@ -1400,13 +1400,13 @@ export class DiffRenderable extends Renderable { // Word-level highlighting getters and setters - public get showWordHighlights(): boolean { - return this._showWordHighlights + public get disableWordHighlights(): boolean { + return this._disableWordHighlights } - public set showWordHighlights(value: boolean) { - if (this._showWordHighlights !== value) { - this._showWordHighlights = value + public set disableWordHighlights(value: boolean) { + if (this._disableWordHighlights !== value) { + this._disableWordHighlights = value this.rebuildView() } } From 117e4708abbc9bc38314d1d714d6bbe6414d2bc2 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Fri, 5 Dec 2025 16:49:26 +0100 Subject: [PATCH 08/26] chore: remove verbose comments --- packages/core/src/renderables/Diff.test.ts | 34 +--------------------- packages/core/src/renderables/Diff.ts | 32 +------------------- 2 files changed, 2 insertions(+), 64 deletions(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index bcbb0a60d..73f239ca5 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -2230,10 +2230,6 @@ test("DiffRenderable - properly cleans up listeners on destroy", async () => { } }) -// ============================================================================= -// Word-level highlight tests -// ============================================================================= - describe("computeLineSimilarity", () => { test("returns 1.0 for identical strings", () => { expect(computeLineSimilarity("hello world", "hello world")).toBe(1.0) @@ -2274,20 +2270,14 @@ describe("computeInlineHighlights", () => { test("highlights changed words", () => { const result = computeInlineHighlights("hello world", "hello there") - - // "world" should be highlighted as removed expect(result.oldHighlights.length).toBeGreaterThan(0) expect(result.oldHighlights[0].type).toBe("removed-word") - - // "there" should be highlighted as added expect(result.newHighlights.length).toBeGreaterThan(0) expect(result.newHighlights[0].type).toBe("added-word") }) test("computes correct column positions", () => { const result = computeInlineHighlights("const x = 1", "const x = 2") - - // The "1" is at position 10, "2" is at position 10 expect(result.oldHighlights[0].startCol).toBe(10) expect(result.oldHighlights[0].endCol).toBe(11) expect(result.newHighlights[0].startCol).toBe(10) @@ -2296,35 +2286,24 @@ describe("computeInlineHighlights", () => { test("handles multiple changes", () => { const result = computeInlineHighlights("a b c", "x b z") - - // "a" and "c" should be removed, "x" and "z" should be added expect(result.oldHighlights.length).toBe(2) expect(result.newHighlights.length).toBe(2) }) test("handles multi-width characters (CJK)", () => { - // CJK characters have display width of 2 each const result = computeInlineHighlights("hello 世界", "hello 你好") - - // "世界" is removed (display width 4), "你好" is added (display width 4) expect(result.oldHighlights.length).toBe(1) expect(result.newHighlights.length).toBe(1) - - // "hello " has display width 6, so CJK starts at column 6 expect(result.oldHighlights[0].startCol).toBe(6) - expect(result.oldHighlights[0].endCol).toBe(10) // 6 + 4 (two CJK chars) + expect(result.oldHighlights[0].endCol).toBe(10) expect(result.newHighlights[0].startCol).toBe(6) expect(result.newHighlights[0].endCol).toBe(10) }) test("handles emoji characters", () => { - // Emoji typically have display width of 2 const result = computeInlineHighlights("test 👍", "test 👎") - expect(result.oldHighlights.length).toBe(1) expect(result.newHighlights.length).toBe(1) - - // "test " has display width 5 expect(result.oldHighlights[0].startCol).toBe(5) expect(result.newHighlights[0].startCol).toBe(5) }) @@ -2363,8 +2342,6 @@ describe("DiffRenderable word highlights", () => { }) expect(diffRenderable.disableWordHighlights).toBe(true) - - // Can update it diffRenderable.disableWordHighlights = false expect(diffRenderable.disableWordHighlights).toBe(false) }) @@ -2401,12 +2378,8 @@ describe("DiffRenderable word highlights", () => { }) expect(diffRenderable.lineSimilarityThreshold).toBe(0.8) - - // Can update it diffRenderable.lineSimilarityThreshold = 0.5 expect(diffRenderable.lineSimilarityThreshold).toBe(0.5) - - // Values are clamped to 0-1 diffRenderable.lineSimilarityThreshold = 1.5 expect(diffRenderable.lineSimilarityThreshold).toBe(1.0) @@ -2432,7 +2405,6 @@ describe("DiffRenderable word highlights", () => { await renderOnce() const frame = captureFrame() - // Content should still render correctly expect(frame).toContain("function hello") expect(frame).toContain("console.log") }) @@ -2455,7 +2427,6 @@ describe("DiffRenderable word highlights", () => { await renderOnce() const frame = captureFrame() - // Content should still render correctly expect(frame).toContain("function hello") expect(frame).toContain("console.log") }) @@ -2465,7 +2436,6 @@ describe("DiffRenderable word highlights", () => { default: { fg: RGBA.fromValues(1, 1, 1, 1) }, }) - // Create a diff with more than 50 lines changed const manyLines = Array.from({ length: 30 }, (_, i) => `-line${i}`).join("\n") const manyAdds = Array.from({ length: 30 }, (_, i) => `+newline${i}`).join("\n") const largeDiff = `--- a/test.js @@ -2484,8 +2454,6 @@ ${manyAdds}` }) currentRenderer.root.add(diffRenderable) - - // Should not throw or hang - large blocks are skipped await renderOnce() const frame = captureFrame() diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index 1a08f71c7..2c8e8045e 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -48,8 +48,6 @@ export function computeInlineHighlights( let newCol = 0 for (const change of changes) { - // Use Bun.stringWidth for display width instead of .length - // This handles multi-width characters (CJK, emoji) correctly const displayWidth = Bun.stringWidth(change.value) if (change.added) { newHighlights.push({ startCol: newCol, endCol: newCol + displayWidth, type: "added-word" }) @@ -241,10 +239,6 @@ export class DiffRenderable extends Renderable { } } - /** - * Brightens a color by a given factor. - * Used to create word highlight colors from line colors. - */ private brightenColor(color: RGBA, factor: number): RGBA { return RGBA.fromValues( Math.min(1, color.r * factor), @@ -254,20 +248,7 @@ export class DiffRenderable extends Renderable { ) } - /** - * Processes a change block (consecutive removes and adds) with word-level highlighting. - * - * This method preserves the original positional pairing behavior for alignment - * (first remove with first add, etc.) while adding word-level highlights for - * lines that are similar enough to be considered modifications. - * - * The approach: - * 1. Use positional pairing for alignment (as the original code did) - * 2. Compute word highlights only when lines are similar enough - */ - // Maximum lines in a change block before skipping word highlights. - // Large blocks likely indicate bulk changes where word-level diffs aren't useful, - // and computing them would cause unnecessary CPU usage. + // Skip word highlights for blocks larger than this private static readonly MAX_WORD_HIGHLIGHT_BLOCK_SIZE = 50 private processChangeBlockWithHighlights( @@ -277,10 +258,7 @@ export class DiffRenderable extends Renderable { const leftLines: LogicalLine[] = [] const rightLines: LogicalLine[] = [] - // Use positional pairing (original behavior) for alignment const maxLength = Math.max(removes.length, adds.length) - - // Skip word highlights for large blocks to prevent CPU spikes const blockSize = removes.length + adds.length const shouldComputeWordHighlights = !this._disableWordHighlights && blockSize <= DiffRenderable.MAX_WORD_HIGHLIGHT_BLOCK_SIZE @@ -292,10 +270,6 @@ export class DiffRenderable extends Renderable { let leftHighlights: InlineHighlight[] = [] let rightHighlights: InlineHighlight[] = [] - // Compute word highlights only when: - // 1. Word highlights are enabled and block is small enough - // 2. Both lines exist (positional pair) - // 3. Lines are similar enough (above threshold) if (shouldComputeWordHighlights && remove && add) { const similarity = computeLineSimilarity(remove.content, add.content) if (similarity >= this._lineSimilarityThreshold) { @@ -305,7 +279,6 @@ export class DiffRenderable extends Renderable { } } - // Build left (old/remove) line if (remove) { leftLines.push({ content: remove.content, @@ -319,7 +292,6 @@ export class DiffRenderable extends Renderable { inlineHighlights: leftHighlights, }) } else { - // Empty placeholder for alignment leftLines.push({ content: "", hideLineNumber: true, @@ -327,7 +299,6 @@ export class DiffRenderable extends Renderable { }) } - // Build right (new/add) line if (add) { rightLines.push({ content: add.content, @@ -341,7 +312,6 @@ export class DiffRenderable extends Renderable { inlineHighlights: rightHighlights, }) } else { - // Empty placeholder for alignment rightLines.push({ content: "", hideLineNumber: true, From 1c2e9860084de5c7ec92ada0663d9b758866e1a5 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Fri, 5 Dec 2025 16:52:03 +0100 Subject: [PATCH 09/26] refactor: simplify word highlights code --- packages/core/src/renderables/Diff.ts | 185 +++++--------------------- 1 file changed, 36 insertions(+), 149 deletions(-) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index 2c8e8045e..2b281f42a 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -71,7 +71,6 @@ interface LogicalLine { color?: string | RGBA sign?: LineSign type: "context" | "add" | "remove" | "empty" - /** Inline highlights for word-level diff */ inlineHighlights?: InlineHighlight[] } @@ -103,8 +102,6 @@ export interface DiffRenderableOptions extends RenderableOptions removedSignColor?: string | RGBA addedLineNumberBg?: string | RGBA removedLineNumberBg?: string | RGBA - - // Word-level highlighting options /** * Disable word-level highlighting within modified lines. * When false (default), individual words/characters that changed are highlighted. @@ -159,8 +156,6 @@ export class DiffRenderable extends Renderable { private _removedSignColor: RGBA private _addedLineNumberBg: RGBA private _removedLineNumberBg: RGBA - - // Word-level highlighting private _disableWordHighlights: boolean private _addedWordBg: RGBA private _removedWordBg: RGBA @@ -222,11 +217,8 @@ export class DiffRenderable extends Renderable { this._removedSignColor = parseColor(options.removedSignColor ?? "#ef4444") this._addedLineNumberBg = parseColor(options.addedLineNumberBg ?? "transparent") this._removedLineNumberBg = parseColor(options.removedLineNumberBg ?? "transparent") - - // Word-level highlighting this._disableWordHighlights = options.disableWordHighlights ?? false this._lineSimilarityThreshold = options.lineSimilarityThreshold ?? 0.4 - // Default word highlight colors: brighter versions of the line colors this._addedWordBg = options.addedWordBg ? parseColor(options.addedWordBg) : this.brightenColor(this._addedBg, 1.5) this._removedWordBg = options.removedWordBg ? parseColor(options.removedWordBg) @@ -248,6 +240,10 @@ export class DiffRenderable extends Renderable { ) } + private toLineHighlights(highlights: InlineHighlight[], bg: RGBA): LineInlineHighlight[] { + return highlights.map((h) => ({ startCol: h.startCol, endCol: h.endCol, bg })) + } + // Skip word highlights for blocks larger than this private static readonly MAX_WORD_HIGHLIGHT_BLOCK_SIZE = 50 @@ -685,77 +681,30 @@ export class DiffRenderable extends Renderable { i++ } - // Process the block with word-level highlighting const processedBlock = this.processChangeBlockWithHighlights(removes, adds) - // In unified view, output removes first, then adds - // Collect lines from the processed block, preserving their highlights - for (const leftLine of processedBlock.leftLines) { - if (leftLine.type !== "empty") { - contentLines.push(leftLine.content) - const config: LineColorConfig = { - gutter: this._removedLineNumberBg, - } - if (this._removedContentBg) { - config.content = this._removedContentBg - } else { - config.content = this._removedBg - } - lineColors.set(lineIndex, config) - lineSigns.set(lineIndex, { - after: " -", - afterColor: this._removedSignColor, - }) - if (leftLine.lineNum !== undefined) { - lineNumbers.set(lineIndex, leftLine.lineNum) - } - // Add word highlights for this line - if (leftLine.inlineHighlights && leftLine.inlineHighlights.length > 0) { - inlineHighlights.set( - lineIndex, - leftLine.inlineHighlights.map((h) => ({ - startCol: h.startCol, - endCol: h.endCol, - bg: this._removedWordBg, - })), - ) - } - lineIndex++ + for (const line of processedBlock.leftLines) { + if (line.type === "empty") continue + contentLines.push(line.content) + lineColors.set(lineIndex, { gutter: this._removedLineNumberBg, content: this._removedContentBg ?? this._removedBg }) + lineSigns.set(lineIndex, { after: " -", afterColor: this._removedSignColor }) + if (line.lineNum !== undefined) lineNumbers.set(lineIndex, line.lineNum) + if (line.inlineHighlights?.length) { + inlineHighlights.set(lineIndex, this.toLineHighlights(line.inlineHighlights, this._removedWordBg)) } + lineIndex++ } - for (const rightLine of processedBlock.rightLines) { - if (rightLine.type !== "empty") { - contentLines.push(rightLine.content) - const config: LineColorConfig = { - gutter: this._addedLineNumberBg, - } - if (this._addedContentBg) { - config.content = this._addedContentBg - } else { - config.content = this._addedBg - } - lineColors.set(lineIndex, config) - lineSigns.set(lineIndex, { - after: " +", - afterColor: this._addedSignColor, - }) - if (rightLine.lineNum !== undefined) { - lineNumbers.set(lineIndex, rightLine.lineNum) - } - // Add word highlights for this line - if (rightLine.inlineHighlights && rightLine.inlineHighlights.length > 0) { - inlineHighlights.set( - lineIndex, - rightLine.inlineHighlights.map((h) => ({ - startCol: h.startCol, - endCol: h.endCol, - bg: this._addedWordBg, - })), - ) - } - lineIndex++ + for (const line of processedBlock.rightLines) { + if (line.type === "empty") continue + contentLines.push(line.content) + lineColors.set(lineIndex, { gutter: this._addedLineNumberBg, content: this._addedContentBg ?? this._addedBg }) + lineSigns.set(lineIndex, { after: " +", afterColor: this._addedSignColor }) + if (line.lineNum !== undefined) lineNumbers.set(lineIndex, line.lineNum) + if (line.inlineHighlights?.length) { + inlineHighlights.set(lineIndex, this.toLineHighlights(line.inlineHighlights, this._addedWordBg)) } + lineIndex++ } } } @@ -990,90 +939,30 @@ export class DiffRenderable extends Renderable { const rightInlineHighlights = new Map() finalLeftLines.forEach((line, index) => { - if (line.lineNum !== undefined) { - leftLineNumbers.set(index, line.lineNum) - } - if (line.hideLineNumber) { - leftHideLineNumbers.add(index) - } + if (line.lineNum !== undefined) leftLineNumbers.set(index, line.lineNum) + if (line.hideLineNumber) leftHideLineNumbers.add(index) if (line.type === "remove") { - const config: LineColorConfig = { - gutter: this._removedLineNumberBg, - } - if (this._removedContentBg) { - config.content = this._removedContentBg - } else { - config.content = this._removedBg - } - leftLineColors.set(index, config) + leftLineColors.set(index, { gutter: this._removedLineNumberBg, content: this._removedContentBg ?? this._removedBg }) } else if (line.type === "context") { - const config: LineColorConfig = { - gutter: this._lineNumberBg, - } - if (this._contextContentBg) { - config.content = this._contextContentBg - } else { - config.content = this._contextBg - } - leftLineColors.set(index, config) + leftLineColors.set(index, { gutter: this._lineNumberBg, content: this._contextContentBg ?? this._contextBg }) } - if (line.sign) { - leftLineSigns.set(index, line.sign) - } - // Add inline highlights for word-level diff - if (line.inlineHighlights && line.inlineHighlights.length > 0) { - leftInlineHighlights.set( - index, - line.inlineHighlights.map((h) => ({ - startCol: h.startCol, - endCol: h.endCol, - bg: this._removedWordBg, - })), - ) + if (line.sign) leftLineSigns.set(index, line.sign) + if (line.inlineHighlights?.length) { + leftInlineHighlights.set(index, this.toLineHighlights(line.inlineHighlights, this._removedWordBg)) } }) finalRightLines.forEach((line, index) => { - if (line.lineNum !== undefined) { - rightLineNumbers.set(index, line.lineNum) - } - if (line.hideLineNumber) { - rightHideLineNumbers.add(index) - } + if (line.lineNum !== undefined) rightLineNumbers.set(index, line.lineNum) + if (line.hideLineNumber) rightHideLineNumbers.add(index) if (line.type === "add") { - const config: LineColorConfig = { - gutter: this._addedLineNumberBg, - } - if (this._addedContentBg) { - config.content = this._addedContentBg - } else { - config.content = this._addedBg - } - rightLineColors.set(index, config) + rightLineColors.set(index, { gutter: this._addedLineNumberBg, content: this._addedContentBg ?? this._addedBg }) } else if (line.type === "context") { - const config: LineColorConfig = { - gutter: this._lineNumberBg, - } - if (this._contextContentBg) { - config.content = this._contextContentBg - } else { - config.content = this._contextBg - } - rightLineColors.set(index, config) - } - if (line.sign) { - rightLineSigns.set(index, line.sign) + rightLineColors.set(index, { gutter: this._lineNumberBg, content: this._contextContentBg ?? this._contextBg }) } - // Add inline highlights for word-level diff - if (line.inlineHighlights && line.inlineHighlights.length > 0) { - rightInlineHighlights.set( - index, - line.inlineHighlights.map((h) => ({ - startCol: h.startCol, - endCol: h.endCol, - bg: this._addedWordBg, - })), - ) + if (line.sign) rightLineSigns.set(index, line.sign) + if (line.inlineHighlights?.length) { + rightInlineHighlights.set(index, this.toLineHighlights(line.inlineHighlights, this._addedWordBg)) } }) @@ -1368,8 +1257,6 @@ export class DiffRenderable extends Renderable { } } - // Word-level highlighting getters and setters - public get disableWordHighlights(): boolean { return this._disableWordHighlights } From 6cc3958a0389f7a6aca22ee3353e4ba02ab3987f Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Fri, 5 Dec 2025 16:54:36 +0100 Subject: [PATCH 10/26] fix: revert unrelated simplifications to split view forEach loops --- packages/core/src/renderables/Diff.ts | 64 ++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index 2b281f42a..8d35c38c1 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -939,28 +939,72 @@ export class DiffRenderable extends Renderable { const rightInlineHighlights = new Map() finalLeftLines.forEach((line, index) => { - if (line.lineNum !== undefined) leftLineNumbers.set(index, line.lineNum) - if (line.hideLineNumber) leftHideLineNumbers.add(index) + if (line.lineNum !== undefined) { + leftLineNumbers.set(index, line.lineNum) + } + if (line.hideLineNumber) { + leftHideLineNumbers.add(index) + } if (line.type === "remove") { - leftLineColors.set(index, { gutter: this._removedLineNumberBg, content: this._removedContentBg ?? this._removedBg }) + const config: LineColorConfig = { + gutter: this._removedLineNumberBg, + } + if (this._removedContentBg) { + config.content = this._removedContentBg + } else { + config.content = this._removedBg + } + leftLineColors.set(index, config) } else if (line.type === "context") { - leftLineColors.set(index, { gutter: this._lineNumberBg, content: this._contextContentBg ?? this._contextBg }) + const config: LineColorConfig = { + gutter: this._lineNumberBg, + } + if (this._contextContentBg) { + config.content = this._contextContentBg + } else { + config.content = this._contextBg + } + leftLineColors.set(index, config) + } + if (line.sign) { + leftLineSigns.set(index, line.sign) } - if (line.sign) leftLineSigns.set(index, line.sign) if (line.inlineHighlights?.length) { leftInlineHighlights.set(index, this.toLineHighlights(line.inlineHighlights, this._removedWordBg)) } }) finalRightLines.forEach((line, index) => { - if (line.lineNum !== undefined) rightLineNumbers.set(index, line.lineNum) - if (line.hideLineNumber) rightHideLineNumbers.add(index) + if (line.lineNum !== undefined) { + rightLineNumbers.set(index, line.lineNum) + } + if (line.hideLineNumber) { + rightHideLineNumbers.add(index) + } if (line.type === "add") { - rightLineColors.set(index, { gutter: this._addedLineNumberBg, content: this._addedContentBg ?? this._addedBg }) + const config: LineColorConfig = { + gutter: this._addedLineNumberBg, + } + if (this._addedContentBg) { + config.content = this._addedContentBg + } else { + config.content = this._addedBg + } + rightLineColors.set(index, config) } else if (line.type === "context") { - rightLineColors.set(index, { gutter: this._lineNumberBg, content: this._contextContentBg ?? this._contextBg }) + const config: LineColorConfig = { + gutter: this._lineNumberBg, + } + if (this._contextContentBg) { + config.content = this._contextContentBg + } else { + config.content = this._contextBg + } + rightLineColors.set(index, config) + } + if (line.sign) { + rightLineSigns.set(index, line.sign) } - if (line.sign) rightLineSigns.set(index, line.sign) if (line.inlineHighlights?.length) { rightInlineHighlights.set(index, this.toLineHighlights(line.inlineHighlights, this._addedWordBg)) } From 88080c0c4e66452519a5f7ad8185b45bf39b48f6 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 14 Dec 2025 17:40:44 +0100 Subject: [PATCH 11/26] chore: format Diff.ts --- packages/core/src/renderables/Diff.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index 9174bf371..a565f2fb6 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -704,7 +704,10 @@ export class DiffRenderable extends Renderable { for (const line of processedBlock.leftLines) { if (line.type === "empty") continue contentLines.push(line.content) - lineColors.set(lineIndex, { gutter: this._removedLineNumberBg, content: this._removedContentBg ?? this._removedBg }) + lineColors.set(lineIndex, { + gutter: this._removedLineNumberBg, + content: this._removedContentBg ?? this._removedBg, + }) lineSigns.set(lineIndex, { after: " -", afterColor: this._removedSignColor }) if (line.lineNum !== undefined) lineNumbers.set(lineIndex, line.lineNum) if (line.inlineHighlights?.length) { @@ -716,7 +719,10 @@ export class DiffRenderable extends Renderable { for (const line of processedBlock.rightLines) { if (line.type === "empty") continue contentLines.push(line.content) - lineColors.set(lineIndex, { gutter: this._addedLineNumberBg, content: this._addedContentBg ?? this._addedBg }) + lineColors.set(lineIndex, { + gutter: this._addedLineNumberBg, + content: this._addedContentBg ?? this._addedBg, + }) lineSigns.set(lineIndex, { after: " +", afterColor: this._addedSignColor }) if (line.lineNum !== undefined) lineNumbers.set(lineIndex, line.lineNum) if (line.inlineHighlights?.length) { From 912849e0ee7dfbaf388ca911ff4b85788b02324c Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 14 Dec 2025 18:04:12 +0100 Subject: [PATCH 12/26] fix: word highlights scroll with content when scrollX is non-zero - Added scrollX offset to inline highlight positions in LineNumberRenderable - Added tests for word highlights with horizontal scrolling in unified and split views --- packages/core/src/renderables/Diff.test.ts | 103 ++++++++++++++++++ .../src/renderables/LineNumberRenderable.ts | 6 +- .../__snapshots__/Diff.test.ts.snap | 96 ++++++++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index fc89d41b2..b0dd9ff6d 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -2898,4 +2898,107 @@ ${manyAdds}` expect(frame).toContain("line0") expect(frame).toContain("newline0") }) + + test("word highlights scroll with content when scrollX is non-zero", async () => { + const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(1, 1, 1, 1) }, + }) + + // Create a diff with long lines that will extend beyond viewport + // The word "OLDVALUE" changes to "NEWVALUE" - this creates word highlights + const longLineDiff = `--- a/test.js ++++ b/test.js +@@ -1,3 +1,3 @@ + const config = { +- someVeryLongPropertyNameThatExtendsBeyondViewport: "OLDVALUE", ++ someVeryLongPropertyNameThatExtendsBeyondViewport: "NEWVALUE", + }` + + const diffRenderable = new DiffRenderable(currentRenderer, { + id: "test-diff", + diff: longLineDiff, + view: "unified", + syntaxStyle, + wrapMode: "none", // No wrapping - content can scroll horizontally + width: 40, // Narrow width to ensure content extends beyond + height: 10, + }) + + currentRenderer.root.add(diffRenderable) + await renderOnce() + + // Get the internal CodeRenderable + const codeRenderable = (diffRenderable as any).leftCodeRenderable + expect(codeRenderable).toBeDefined() + expect(codeRenderable.scrollX).toBe(0) + + // Capture frame at scrollX=0 + const frameAtScroll0 = captureFrame() + expect(frameAtScroll0).toMatchSnapshot("word-highlights-scrollX-0") + + // The beginning of the line should be visible + expect(frameAtScroll0).toContain("someVeryLong") + + // Scroll horizontally to reveal the changed values + codeRenderable.scrollX = 40 + await renderOnce() + + const frameAtScroll40 = captureFrame() + expect(frameAtScroll40).toMatchSnapshot("word-highlights-scrollX-40") + + // After scrolling, different content should be visible + // The "VALUE" part of OLDVALUE/NEWVALUE should now be visible + expect(frameAtScroll40).toContain("VALUE") + + // Frames should be different (content shifted) + expect(frameAtScroll0).not.toBe(frameAtScroll40) + }) + + test("word highlights in split view scroll correctly with scrollX", async () => { + const syntaxStyle = SyntaxStyle.fromStyles({ + default: { fg: RGBA.fromValues(1, 1, 1, 1) }, + }) + + const longLineDiff = `--- a/test.js ++++ b/test.js +@@ -1,3 +1,3 @@ + const config = { +- veryLongPropertyName: "REMOVED_OLD_VALUE_HERE", ++ veryLongPropertyName: "ADDED_NEW_VALUE_HERE", + }` + + const diffRenderable = new DiffRenderable(currentRenderer, { + id: "test-diff", + diff: longLineDiff, + view: "split", + syntaxStyle, + wrapMode: "none", + width: 60, // Each side gets ~30 chars + height: 10, + }) + + currentRenderer.root.add(diffRenderable) + await renderOnce() + + // Get internal CodeRenderables for both sides + const leftCode = (diffRenderable as any).leftCodeRenderable + const rightCode = (diffRenderable as any).rightCodeRenderable + expect(leftCode).toBeDefined() + expect(rightCode).toBeDefined() + + // Capture initial frame + const frameInitial = captureFrame() + expect(frameInitial).toMatchSnapshot("split-word-highlights-initial") + + // Scroll both sides + leftCode.scrollX = 20 + rightCode.scrollX = 20 + await renderOnce() + + const frameScrolled = captureFrame() + expect(frameScrolled).toMatchSnapshot("split-word-highlights-scrolled") + + // Content should have shifted + expect(frameInitial).not.toBe(frameScrolled) + }) }) diff --git a/packages/core/src/renderables/LineNumberRenderable.ts b/packages/core/src/renderables/LineNumberRenderable.ts index f23b60ec2..8e99d61f9 100644 --- a/packages/core/src/renderables/LineNumberRenderable.ts +++ b/packages/core/src/renderables/LineNumberRenderable.ts @@ -552,8 +552,12 @@ export class LineNumberRenderable extends Renderable { // Draw inline highlights for this line (word-level diff highlighting) const inlineHighlights = this._inlineHighlights.get(logicalLine) if (inlineHighlights && inlineHighlights.length > 0) { + // Account for horizontal scrolling - target may have scrollX property + const scrollX = (this.target as any).scrollX ?? 0 + for (const highlight of inlineHighlights) { - const highlightStartX = contentStartX + highlight.startCol + // Adjust highlight position by scrollX so highlights scroll with content + const highlightStartX = contentStartX + highlight.startCol - scrollX const highlightWidth = highlight.endCol - highlight.startCol // Clamp to visible content area diff --git a/packages/core/src/renderables/__snapshots__/Diff.test.ts.snap b/packages/core/src/renderables/__snapshots__/Diff.test.ts.snap index c32f815d0..6537e2777 100644 --- a/packages/core/src/renderables/__snapshots__/Diff.test.ts.snap +++ b/packages/core/src/renderables/__snapshots__/Diff.test.ts.snap @@ -781,5 +781,101 @@ exports[`DiffRenderable - line numbers update correctly after resize causes wrap +" +`; + +exports[`DiffRenderable word highlights word highlights scroll with content when scrollX is non-zero: word-highlights-scrollX-0 1`] = ` +" 1 const config = { + 2 - someVeryLongPropertyNameThatExten + 2 + someVeryLongPropertyNameThatExten + 3 } + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable word highlights word highlights scroll with content when scrollX is non-zero: word-highlights-scrollX-40 1`] = ` +" 1 + 2 - tExtendsBeyondViewport: "OLDVALUE", + 2 + tExtendsBeyondViewport: "NEWVALUE", + 3 + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable word highlights word highlights in split view scroll correctly with scrollX: split-word-highlights-initial 1`] = ` +" 1 const config = { 1 const config = { + 2 - veryLongPropertyName: " 2 + veryLongPropertyName: " + 3 } 3 } + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable word highlights word highlights in split view scroll correctly with scrollX: split-word-highlights-scrolled 1`] = ` +" 1 1 + 2 - me: "REMOVED_OLD_VALUE_HE 2 + me: "ADDED_NEW_VALUE_HERE + 3 3 + + + + + + + + + + + + + + + + + " `; From f437756eef562c38c6d019d391453e93e081fcaa Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 14 Dec 2025 18:12:53 +0100 Subject: [PATCH 13/26] fix: word highlights respect line wrapping When text wraps to multiple visual lines, highlights now only appear on the correct wrapped line(s) by using lineWraps offset to calculate visibility. --- .../src/renderables/LineNumberRenderable.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/core/src/renderables/LineNumberRenderable.ts b/packages/core/src/renderables/LineNumberRenderable.ts index 8e99d61f9..a8f45fdee 100644 --- a/packages/core/src/renderables/LineNumberRenderable.ts +++ b/packages/core/src/renderables/LineNumberRenderable.ts @@ -555,10 +555,29 @@ export class LineNumberRenderable extends Renderable { // Account for horizontal scrolling - target may have scrollX property const scrollX = (this.target as any).scrollX ?? 0 + // Get the wrap offset for this visual line (how many columns into the logical line) + const wrapOffset = lineInfo.lineWraps?.[visualLineIndex] ?? 0 + + // Calculate where the next visual line starts (to know the visible range) + const nextWrapOffset = + visualLineIndex + 1 < sources.length && sources[visualLineIndex + 1] === logicalLine + ? (lineInfo.lineWraps?.[visualLineIndex + 1] ?? contentWidth) + : Infinity + for (const highlight of inlineHighlights) { + // Check if this highlight is visible on this wrapped line + // Highlight must overlap with the range [wrapOffset, nextWrapOffset) + if (highlight.endCol <= wrapOffset || highlight.startCol >= nextWrapOffset) { + continue // Highlight is not on this visual line + } + + // Calculate visible portion of highlight on this wrapped line + const visibleStartCol = Math.max(highlight.startCol, wrapOffset) - wrapOffset + const visibleEndCol = Math.min(highlight.endCol, nextWrapOffset) - wrapOffset + // Adjust highlight position by scrollX so highlights scroll with content - const highlightStartX = contentStartX + highlight.startCol - scrollX - const highlightWidth = highlight.endCol - highlight.startCol + const highlightStartX = contentStartX + visibleStartCol - scrollX + const highlightWidth = visibleEndCol - visibleStartCol // Clamp to visible content area const clampedStartX = Math.max(highlightStartX, contentStartX) From 8989ff0334cbd0d6e5dd9ee4f41faf3fc3b7f90b Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 14 Dec 2025 18:20:17 +0100 Subject: [PATCH 14/26] fix: correctly compute column offset for wrapped line highlights lineWraps contains wrap INDEX (0,1,2...), not column offset. Now properly computes column offset by summing widths of previous wrapped segments belonging to the same logical line. --- .../src/renderables/LineNumberRenderable.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/core/src/renderables/LineNumberRenderable.ts b/packages/core/src/renderables/LineNumberRenderable.ts index a8f45fdee..a2ccc3dc6 100644 --- a/packages/core/src/renderables/LineNumberRenderable.ts +++ b/packages/core/src/renderables/LineNumberRenderable.ts @@ -555,25 +555,31 @@ export class LineNumberRenderable extends Renderable { // Account for horizontal scrolling - target may have scrollX property const scrollX = (this.target as any).scrollX ?? 0 - // Get the wrap offset for this visual line (how many columns into the logical line) - const wrapOffset = lineInfo.lineWraps?.[visualLineIndex] ?? 0 + // lineWraps contains the wrap INDEX (0, 1, 2...), not the column offset + // We need to compute the actual column offset by summing widths of previous wrapped segments + const wrapIndex = lineInfo.lineWraps?.[visualLineIndex] ?? 0 + + // Calculate column offset by summing widths of previous visual lines belonging to this logical line + let columnOffset = 0 + if (wrapIndex > 0 && lineInfo.lineWidths) { + for (let j = visualLineIndex - 1; j >= 0 && sources[j] === logicalLine; j--) { + columnOffset += lineInfo.lineWidths[j] ?? 0 + } + } - // Calculate where the next visual line starts (to know the visible range) - const nextWrapOffset = - visualLineIndex + 1 < sources.length && sources[visualLineIndex + 1] === logicalLine - ? (lineInfo.lineWraps?.[visualLineIndex + 1] ?? contentWidth) - : Infinity + // Get the width of this visual line to determine the visible column range + const thisLineWidth = lineInfo.lineWidths?.[visualLineIndex] ?? contentWidth for (const highlight of inlineHighlights) { // Check if this highlight is visible on this wrapped line - // Highlight must overlap with the range [wrapOffset, nextWrapOffset) - if (highlight.endCol <= wrapOffset || highlight.startCol >= nextWrapOffset) { + // Highlight must overlap with the range [columnOffset, columnOffset + thisLineWidth) + if (highlight.endCol <= columnOffset || highlight.startCol >= columnOffset + thisLineWidth) { continue // Highlight is not on this visual line } // Calculate visible portion of highlight on this wrapped line - const visibleStartCol = Math.max(highlight.startCol, wrapOffset) - wrapOffset - const visibleEndCol = Math.min(highlight.endCol, nextWrapOffset) - wrapOffset + const visibleStartCol = Math.max(highlight.startCol, columnOffset) - columnOffset + const visibleEndCol = Math.min(highlight.endCol, columnOffset + thisLineWidth) - columnOffset // Adjust highlight position by scrollX so highlights scroll with content const highlightStartX = contentStartX + visibleStartCol - scrollX From 8e63649ed39483356c20a63850e9c94c9b3cddaf Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 14 Dec 2025 18:27:06 +0100 Subject: [PATCH 15/26] cleanup: remove excessive comments --- .../core/src/renderables/LineNumberRenderable.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/core/src/renderables/LineNumberRenderable.ts b/packages/core/src/renderables/LineNumberRenderable.ts index a2ccc3dc6..9a4e16bcd 100644 --- a/packages/core/src/renderables/LineNumberRenderable.ts +++ b/packages/core/src/renderables/LineNumberRenderable.ts @@ -549,17 +549,12 @@ export class LineNumberRenderable extends Renderable { buffer.fillRect(contentStartX, this.y + i, contentWidth, 1, lineBg) } - // Draw inline highlights for this line (word-level diff highlighting) const inlineHighlights = this._inlineHighlights.get(logicalLine) if (inlineHighlights && inlineHighlights.length > 0) { - // Account for horizontal scrolling - target may have scrollX property const scrollX = (this.target as any).scrollX ?? 0 - - // lineWraps contains the wrap INDEX (0, 1, 2...), not the column offset - // We need to compute the actual column offset by summing widths of previous wrapped segments const wrapIndex = lineInfo.lineWraps?.[visualLineIndex] ?? 0 - // Calculate column offset by summing widths of previous visual lines belonging to this logical line + // Sum widths of previous wrapped segments to get column offset let columnOffset = 0 if (wrapIndex > 0 && lineInfo.lineWidths) { for (let j = visualLineIndex - 1; j >= 0 && sources[j] === logicalLine; j--) { @@ -567,25 +562,18 @@ export class LineNumberRenderable extends Renderable { } } - // Get the width of this visual line to determine the visible column range const thisLineWidth = lineInfo.lineWidths?.[visualLineIndex] ?? contentWidth for (const highlight of inlineHighlights) { - // Check if this highlight is visible on this wrapped line - // Highlight must overlap with the range [columnOffset, columnOffset + thisLineWidth) if (highlight.endCol <= columnOffset || highlight.startCol >= columnOffset + thisLineWidth) { - continue // Highlight is not on this visual line + continue } - // Calculate visible portion of highlight on this wrapped line const visibleStartCol = Math.max(highlight.startCol, columnOffset) - columnOffset const visibleEndCol = Math.min(highlight.endCol, columnOffset + thisLineWidth) - columnOffset - - // Adjust highlight position by scrollX so highlights scroll with content const highlightStartX = contentStartX + visibleStartCol - scrollX const highlightWidth = visibleEndCol - visibleStartCol - // Clamp to visible content area const clampedStartX = Math.max(highlightStartX, contentStartX) const clampedEndX = Math.min(highlightStartX + highlightWidth, contentStartX + contentWidth) const clampedWidth = clampedEndX - clampedStartX From cbc18de3c413b534bec1e16bcf0a628bdf7db6dc Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 14 Dec 2025 18:35:02 +0100 Subject: [PATCH 16/26] test: use inline snapshots with trimmed lines for scrollX tests --- packages/core/src/renderables/Diff.test.ts | 79 ++++++++++++---------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index b0dd9ff6d..b3c7f3efc 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -2904,8 +2904,6 @@ ${manyAdds}` default: { fg: RGBA.fromValues(1, 1, 1, 1) }, }) - // Create a diff with long lines that will extend beyond viewport - // The word "OLDVALUE" changes to "NEWVALUE" - this creates word highlights const longLineDiff = `--- a/test.js +++ b/test.js @@ -1,3 +1,3 @@ @@ -2919,39 +2917,43 @@ ${manyAdds}` diff: longLineDiff, view: "unified", syntaxStyle, - wrapMode: "none", // No wrapping - content can scroll horizontally - width: 40, // Narrow width to ensure content extends beyond - height: 10, + wrapMode: "none", + width: 60, + height: 6, }) currentRenderer.root.add(diffRenderable) await renderOnce() - // Get the internal CodeRenderable const codeRenderable = (diffRenderable as any).leftCodeRenderable expect(codeRenderable).toBeDefined() expect(codeRenderable.scrollX).toBe(0) - // Capture frame at scrollX=0 - const frameAtScroll0 = captureFrame() - expect(frameAtScroll0).toMatchSnapshot("word-highlights-scrollX-0") - - // The beginning of the line should be visible - expect(frameAtScroll0).toContain("someVeryLong") + const trim = (s: string) => + s + .split("\n") + .map((l) => l.trimEnd()) + .join("\n") + .trimEnd() + + expect("\n" + trim(captureFrame())).toMatchInlineSnapshot(` + " + 1 const config = { + 2 - someVeryLongPropertyNameThatExtendsBeyondViewport: "O + 2 + someVeryLongPropertyNameThatExtendsBeyondViewport: "N + 3 }" + `) - // Scroll horizontally to reveal the changed values codeRenderable.scrollX = 40 await renderOnce() - const frameAtScroll40 = captureFrame() - expect(frameAtScroll40).toMatchSnapshot("word-highlights-scrollX-40") - - // After scrolling, different content should be visible - // The "VALUE" part of OLDVALUE/NEWVALUE should now be visible - expect(frameAtScroll40).toContain("VALUE") - - // Frames should be different (content shifted) - expect(frameAtScroll0).not.toBe(frameAtScroll40) + expect("\n" + trim(captureFrame())).toMatchInlineSnapshot(` + " + 1 fig = { + 2 - yLongPropertyNameThatExtendsBeyondViewport: "OLDVALUE", + 2 + yLongPropertyNameThatExtendsBeyondViewport: "NEWVALUE", + 3" + `) }) test("word highlights in split view scroll correctly with scrollX", async () => { @@ -2973,32 +2975,41 @@ ${manyAdds}` view: "split", syntaxStyle, wrapMode: "none", - width: 60, // Each side gets ~30 chars - height: 10, + width: 70, + height: 5, }) currentRenderer.root.add(diffRenderable) await renderOnce() - // Get internal CodeRenderables for both sides const leftCode = (diffRenderable as any).leftCodeRenderable const rightCode = (diffRenderable as any).rightCodeRenderable expect(leftCode).toBeDefined() expect(rightCode).toBeDefined() - // Capture initial frame - const frameInitial = captureFrame() - expect(frameInitial).toMatchSnapshot("split-word-highlights-initial") + const trim = (s: string) => + s + .split("\n") + .map((l) => l.trimEnd()) + .join("\n") + .trimEnd() + + expect("\n" + trim(captureFrame())).toMatchInlineSnapshot(` + " + 1 const config = { 1 const config = { + 2 - veryLongPropertyName: "REMOV 2 + veryLongPropertyName: "ADDED + 3 } 3 }" + `) - // Scroll both sides leftCode.scrollX = 20 rightCode.scrollX = 20 await renderOnce() - const frameScrolled = captureFrame() - expect(frameScrolled).toMatchSnapshot("split-word-highlights-scrolled") - - // Content should have shifted - expect(frameInitial).not.toBe(frameScrolled) + expect("\n" + trim(captureFrame())).toMatchInlineSnapshot(` + " + 1 1 + 2 - ame: "REMOVED_OLD_VALUE_HERE", 2 + yName: "ADDED_NEW_VALUE_HERE", + 3 3" + `) }) }) From 212bec30512830fc840cbde7a7d697a0ef0817d5 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 14 Dec 2025 18:35:31 +0100 Subject: [PATCH 17/26] test: remove obsolete external snapshots --- .../__snapshots__/Diff.test.ts.snap | 288 ++++++------------ 1 file changed, 96 insertions(+), 192 deletions(-) diff --git a/packages/core/src/renderables/__snapshots__/Diff.test.ts.snap b/packages/core/src/renderables/__snapshots__/Diff.test.ts.snap index 6537e2777..449377ca0 100644 --- a/packages/core/src/renderables/__snapshots__/Diff.test.ts.snap +++ b/packages/core/src/renderables/__snapshots__/Diff.test.ts.snap @@ -1,101 +1,5 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots -exports[`DiffRenderable - can toggle conceal with markdown diff: markdown diff with conceal enabled 1`] = ` -" 1 First line - 2 - Some text old** - 2 + So text**boldext** and *italic* - 3 End line - - - - - - - - - - - - - - - - -" -`; - -exports[`DiffRenderable - can toggle conceal with markdown diff: markdown diff with conceal disabled 1`] = ` -" 1 First line - 2 - Some text **old** - 2 + Some text **boldtext** and *italic* - 3 End line - - - - - - - - - - - - - - - - -" -`; - -exports[`DiffRenderable - conceal works in split view: split view markdown diff with conceal enabled 1`] = ` -" 1 First line 1 First line - 2 - Some old text 2 + Some new text - 3 End line 3 End line - - - - - - - - - - - - - - - - - -" -`; - -exports[`DiffRenderable - conceal works in split view: split view markdown diff with conceal disabled 1`] = ` -" 1 First line 1 First line - 2 - Some **old** text 2 + Some **new** text - 3 End line 3 End line - - - - - - - - - - - - - - - - - -" -`; - exports[`DiffRenderable - unified view renders correctly: unified view simple diff 1`] = ` " 1 function hello() { 2 - console.log("Hello"); @@ -693,6 +597,102 @@ exports[`DiffRenderable - diff with only context lines (no changes): diff with o +" +`; + +exports[`DiffRenderable - can toggle conceal with markdown diff: markdown diff with conceal enabled 1`] = ` +" 1 First line + 2 - Some text old** + 2 + So text**boldext** and *italic* + 3 End line + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - can toggle conceal with markdown diff: markdown diff with conceal disabled 1`] = ` +" 1 First line + 2 - Some text **old** + 2 + Some text **boldtext** and *italic* + 3 End line + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - conceal works in split view: split view markdown diff with conceal enabled 1`] = ` +" 1 First line 1 First line + 2 - Some old text 2 + Some new text + 3 End line 3 End line + + + + + + + + + + + + + + + + + +" +`; + +exports[`DiffRenderable - conceal works in split view: split view markdown diff with conceal disabled 1`] = ` +" 1 First line 1 First line + 2 - Some **old** text 2 + Some **new** text + 3 End line 3 End line + + + + + + + + + + + + + + + + + " `; @@ -781,101 +781,5 @@ exports[`DiffRenderable - line numbers update correctly after resize causes wrap -" -`; - -exports[`DiffRenderable word highlights word highlights scroll with content when scrollX is non-zero: word-highlights-scrollX-0 1`] = ` -" 1 const config = { - 2 - someVeryLongPropertyNameThatExten - 2 + someVeryLongPropertyNameThatExten - 3 } - - - - - - - - - - - - - - - - -" -`; - -exports[`DiffRenderable word highlights word highlights scroll with content when scrollX is non-zero: word-highlights-scrollX-40 1`] = ` -" 1 - 2 - tExtendsBeyondViewport: "OLDVALUE", - 2 + tExtendsBeyondViewport: "NEWVALUE", - 3 - - - - - - - - - - - - - - - - -" -`; - -exports[`DiffRenderable word highlights word highlights in split view scroll correctly with scrollX: split-word-highlights-initial 1`] = ` -" 1 const config = { 1 const config = { - 2 - veryLongPropertyName: " 2 + veryLongPropertyName: " - 3 } 3 } - - - - - - - - - - - - - - - - - -" -`; - -exports[`DiffRenderable word highlights word highlights in split view scroll correctly with scrollX: split-word-highlights-scrolled 1`] = ` -" 1 1 - 2 - me: "REMOVED_OLD_VALUE_HE 2 + me: "ADDED_NEW_VALUE_HERE - 3 3 - - - - - - - - - - - - - - - - - " `; From d3175decc9a1f76b761512b4df48d30f156a0990 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 14 Dec 2025 18:47:11 +0100 Subject: [PATCH 18/26] test: remove useless word highlight tests that don't test anything meaningful --- packages/core/src/renderables/Diff.test.ts | 74 ---------------------- 1 file changed, 74 deletions(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index b3c7f3efc..cf69533c0 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -2825,80 +2825,6 @@ describe("DiffRenderable word highlights", () => { expect(diffRenderable.lineSimilarityThreshold).toBe(0.0) }) - test("renders correctly with word highlights enabled in split view", async () => { - const syntaxStyle = SyntaxStyle.fromStyles({ - default: { fg: RGBA.fromValues(1, 1, 1, 1) }, - }) - - const diffRenderable = new DiffRenderable(currentRenderer, { - id: "test-diff", - diff: simpleDiff, - view: "split", - syntaxStyle, - width: "100%", - height: "100%", - }) - - currentRenderer.root.add(diffRenderable) - await renderOnce() - - const frame = captureFrame() - expect(frame).toContain("function hello") - expect(frame).toContain("console.log") - }) - - test("renders correctly with word highlights enabled in unified view", async () => { - const syntaxStyle = SyntaxStyle.fromStyles({ - default: { fg: RGBA.fromValues(1, 1, 1, 1) }, - }) - - const diffRenderable = new DiffRenderable(currentRenderer, { - id: "test-diff", - diff: simpleDiff, - view: "unified", - syntaxStyle, - width: "100%", - height: "100%", - }) - - currentRenderer.root.add(diffRenderable) - await renderOnce() - - const frame = captureFrame() - expect(frame).toContain("function hello") - expect(frame).toContain("console.log") - }) - - test("large change blocks skip word highlights for performance", async () => { - const syntaxStyle = SyntaxStyle.fromStyles({ - default: { fg: RGBA.fromValues(1, 1, 1, 1) }, - }) - - const manyLines = Array.from({ length: 30 }, (_, i) => `-line${i}`).join("\n") - const manyAdds = Array.from({ length: 30 }, (_, i) => `+newline${i}`).join("\n") - const largeDiff = `--- a/test.js -+++ b/test.js -@@ -1,30 +1,30 @@ -${manyLines} -${manyAdds}` - - const diffRenderable = new DiffRenderable(currentRenderer, { - id: "test-diff", - diff: largeDiff, - view: "split", - syntaxStyle, - width: "100%", - height: "100%", - }) - - currentRenderer.root.add(diffRenderable) - await renderOnce() - - const frame = captureFrame() - expect(frame).toContain("line0") - expect(frame).toContain("newline0") - }) - test("word highlights scroll with content when scrollX is non-zero", async () => { const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: RGBA.fromValues(1, 1, 1, 1) }, From 9a100ea088c71f2e0fe510f73b2c0d9e2c29398f Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 14 Dec 2025 18:48:44 +0100 Subject: [PATCH 19/26] test: remove scrollX tests that can't verify highlight rendering --- packages/core/src/renderables/Diff.test.ts | 113 --------------------- 1 file changed, 113 deletions(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index cf69533c0..a1700d1ab 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -2825,117 +2825,4 @@ describe("DiffRenderable word highlights", () => { expect(diffRenderable.lineSimilarityThreshold).toBe(0.0) }) - test("word highlights scroll with content when scrollX is non-zero", async () => { - const syntaxStyle = SyntaxStyle.fromStyles({ - default: { fg: RGBA.fromValues(1, 1, 1, 1) }, - }) - - const longLineDiff = `--- a/test.js -+++ b/test.js -@@ -1,3 +1,3 @@ - const config = { -- someVeryLongPropertyNameThatExtendsBeyondViewport: "OLDVALUE", -+ someVeryLongPropertyNameThatExtendsBeyondViewport: "NEWVALUE", - }` - - const diffRenderable = new DiffRenderable(currentRenderer, { - id: "test-diff", - diff: longLineDiff, - view: "unified", - syntaxStyle, - wrapMode: "none", - width: 60, - height: 6, - }) - - currentRenderer.root.add(diffRenderable) - await renderOnce() - - const codeRenderable = (diffRenderable as any).leftCodeRenderable - expect(codeRenderable).toBeDefined() - expect(codeRenderable.scrollX).toBe(0) - - const trim = (s: string) => - s - .split("\n") - .map((l) => l.trimEnd()) - .join("\n") - .trimEnd() - - expect("\n" + trim(captureFrame())).toMatchInlineSnapshot(` - " - 1 const config = { - 2 - someVeryLongPropertyNameThatExtendsBeyondViewport: "O - 2 + someVeryLongPropertyNameThatExtendsBeyondViewport: "N - 3 }" - `) - - codeRenderable.scrollX = 40 - await renderOnce() - - expect("\n" + trim(captureFrame())).toMatchInlineSnapshot(` - " - 1 fig = { - 2 - yLongPropertyNameThatExtendsBeyondViewport: "OLDVALUE", - 2 + yLongPropertyNameThatExtendsBeyondViewport: "NEWVALUE", - 3" - `) - }) - - test("word highlights in split view scroll correctly with scrollX", async () => { - const syntaxStyle = SyntaxStyle.fromStyles({ - default: { fg: RGBA.fromValues(1, 1, 1, 1) }, - }) - - const longLineDiff = `--- a/test.js -+++ b/test.js -@@ -1,3 +1,3 @@ - const config = { -- veryLongPropertyName: "REMOVED_OLD_VALUE_HERE", -+ veryLongPropertyName: "ADDED_NEW_VALUE_HERE", - }` - - const diffRenderable = new DiffRenderable(currentRenderer, { - id: "test-diff", - diff: longLineDiff, - view: "split", - syntaxStyle, - wrapMode: "none", - width: 70, - height: 5, - }) - - currentRenderer.root.add(diffRenderable) - await renderOnce() - - const leftCode = (diffRenderable as any).leftCodeRenderable - const rightCode = (diffRenderable as any).rightCodeRenderable - expect(leftCode).toBeDefined() - expect(rightCode).toBeDefined() - - const trim = (s: string) => - s - .split("\n") - .map((l) => l.trimEnd()) - .join("\n") - .trimEnd() - - expect("\n" + trim(captureFrame())).toMatchInlineSnapshot(` - " - 1 const config = { 1 const config = { - 2 - veryLongPropertyName: "REMOV 2 + veryLongPropertyName: "ADDED - 3 } 3 }" - `) - - leftCode.scrollX = 20 - rightCode.scrollX = 20 - await renderOnce() - - expect("\n" + trim(captureFrame())).toMatchInlineSnapshot(` - " - 1 1 - 2 - ame: "REMOVED_OLD_VALUE_HERE", 2 + yName: "ADDED_NEW_VALUE_HERE", - 3 3" - `) - }) }) From 570e06741d476c36d77e74b16af951b46edd0075 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sun, 14 Dec 2025 18:49:59 +0100 Subject: [PATCH 20/26] chore: format --- packages/core/src/renderables/Diff.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index a1700d1ab..d3de32efc 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -2824,5 +2824,4 @@ describe("DiffRenderable word highlights", () => { diffRenderable.lineSimilarityThreshold = -0.5 expect(diffRenderable.lineSimilarityThreshold).toBe(0.0) }) - }) From 0b2ef8b137a13a1d30b2b9295ac6532c08971eee Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 16 Dec 2025 18:16:28 +0100 Subject: [PATCH 21/26] use diff with words for similarity --- packages/core/src/renderables/Diff.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index a565f2fb6..7f1918c3c 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -9,7 +9,7 @@ import { } from "./LineNumberRenderable" import { RGBA, parseColor } from "../lib/RGBA" import { SyntaxStyle } from "../syntax-style" -import { parsePatch, diffChars, diffWordsWithSpace, type StructuredPatch } from "diff" +import { parsePatch, diffWordsWithSpace, type StructuredPatch } from "diff" import { TextRenderable } from "./Text" import type { TreeSitterClient } from "../lib/tree-sitter" @@ -20,13 +20,13 @@ interface InlineHighlight { type: "added-word" | "removed-word" } -/** Computes similarity between two strings (0.0 to 1.0) using character-level diff */ +/** Computes similarity between two strings (0.0 to 1.0) using word-level diff */ export function computeLineSimilarity(a: string, b: string): number { if (a === b) return 1.0 if (a.length === 0 && b.length === 0) return 1.0 if (a.length === 0 || b.length === 0) return 0.0 - const changes = diffChars(a, b) + const changes = diffWordsWithSpace(a, b) let unchangedLength = 0 for (const change of changes) { if (!change.added && !change.removed) { From 802361d12aee78a7c7685c2d8813ceb8d2ee3a81 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 16 Dec 2025 18:17:45 +0100 Subject: [PATCH 22/26] increase default lineSimilarityThreshold to 0.5 --- packages/core/src/renderables/Diff.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index 7f1918c3c..aa55bfaeb 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -226,7 +226,7 @@ export class DiffRenderable extends Renderable { this._addedLineNumberBg = parseColor(options.addedLineNumberBg ?? "transparent") this._removedLineNumberBg = parseColor(options.removedLineNumberBg ?? "transparent") this._disableWordHighlights = options.disableWordHighlights ?? false - this._lineSimilarityThreshold = options.lineSimilarityThreshold ?? 0.4 + this._lineSimilarityThreshold = options.lineSimilarityThreshold ?? 0.5 this._addedWordBg = options.addedWordBg ? parseColor(options.addedWordBg) : this.brightenColor(this._addedBg, 1.5) this._removedWordBg = options.removedWordBg ? parseColor(options.removedWordBg) From e74a3fa2cdf86e63344022ed69e21eccd9694bec Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Tue, 16 Dec 2025 18:30:05 +0100 Subject: [PATCH 23/26] fix test: update expected default lineSimilarityThreshold to 0.5 --- packages/core/src/renderables/Diff.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index d3de32efc..73d51eb77 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -2761,7 +2761,7 @@ describe("DiffRenderable word highlights", () => { }) expect(diffRenderable.disableWordHighlights).toBe(false) - expect(diffRenderable.lineSimilarityThreshold).toBe(0.4) + expect(diffRenderable.lineSimilarityThreshold).toBe(0.5) expect(diffRenderable.addedWordBg).toBeDefined() expect(diffRenderable.removedWordBg).toBeDefined() }) From a8ceb5e7bf34c7257713473a07daff13d11dfcbd Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Fri, 19 Dec 2025 00:45:03 +0100 Subject: [PATCH 24/26] derive word highlight colors from hunk colors with brighten + opacity increase --- packages/core/src/renderables/Diff.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index aa55bfaeb..5c234af37 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -113,12 +113,12 @@ export interface DiffRenderableOptions extends RenderableOptions disableWordHighlights?: boolean /** * Background color for added words within modified lines. - * @default A brighter version of addedBg + * @default addedBg brightened 1.5x with +0.15 opacity */ addedWordBg?: string | RGBA /** * Background color for removed words within modified lines. - * @default A brighter version of removedBg + * @default removedBg brightened 1.5x with +0.15 opacity */ removedWordBg?: string | RGBA /** @@ -227,10 +227,12 @@ export class DiffRenderable extends Renderable { this._removedLineNumberBg = parseColor(options.removedLineNumberBg ?? "transparent") this._disableWordHighlights = options.disableWordHighlights ?? false this._lineSimilarityThreshold = options.lineSimilarityThreshold ?? 0.5 - this._addedWordBg = options.addedWordBg ? parseColor(options.addedWordBg) : this.brightenColor(this._addedBg, 1.5) + this._addedWordBg = options.addedWordBg + ? parseColor(options.addedWordBg) + : this.brightenAndIncreaseOpacity(this._addedBg, 1.5, 0.15) this._removedWordBg = options.removedWordBg ? parseColor(options.removedWordBg) - : this.brightenColor(this._removedBg, 1.5) + : this.brightenAndIncreaseOpacity(this._removedBg, 1.5, 0.15) // Only parse and build if diff is provided if (this._diff) { @@ -239,12 +241,12 @@ export class DiffRenderable extends Renderable { } } - private brightenColor(color: RGBA, factor: number): RGBA { + private brightenAndIncreaseOpacity(color: RGBA, brightenFactor: number, opacityIncrease: number): RGBA { return RGBA.fromValues( - Math.min(1, color.r * factor), - Math.min(1, color.g * factor), - Math.min(1, color.b * factor), - color.a, + Math.min(1, color.r * brightenFactor), + Math.min(1, color.g * brightenFactor), + Math.min(1, color.b * brightenFactor), + Math.min(1, color.a + opacityIncrease), ) } From 2d2ed77142988b0858e51f5e6d03915ab3c62b58 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Fri, 16 Jan 2026 12:12:05 +0100 Subject: [PATCH 25/26] refactor: use GitHub Desktop-style word highlights algorithm - Replace diffWordsWithSpace with prefix/suffix elimination algorithm - Only highlight when adds.length === removes.length (matching GitHub.com) - Add MaxIntraLineDiffStringLength (1024 chars) limit - Remove lineSimilarityThreshold option (not used by GitHub Desktop) - Update tests for new behavior --- packages/core/src/renderables/Diff.test.ts | 159 +++++++++++------- packages/core/src/renderables/Diff.ts | 177 ++++++++++++--------- 2 files changed, 208 insertions(+), 128 deletions(-) diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index 273081c73..945f066d1 100644 --- a/packages/core/src/renderables/Diff.test.ts +++ b/packages/core/src/renderables/Diff.test.ts @@ -1,5 +1,5 @@ import { test, expect, beforeEach, afterEach, describe } from "bun:test" -import { DiffRenderable, computeLineSimilarity, computeInlineHighlights } from "./Diff" +import { DiffRenderable, computeInlineHighlights, relativeChanges, MaxIntraLineDiffStringLength } from "./Diff" import { SyntaxStyle } from "../syntax-style" import { RGBA } from "../lib/RGBA" import { createTestRenderer, type TestRenderer } from "../testing" @@ -2668,82 +2668,127 @@ test("DiffRenderable - fg prop accepts RGBA directly", async () => { expect(leftCodeRenderable.fg).toEqual(customFg) }) -describe("computeLineSimilarity", () => { - test("returns 1.0 for identical strings", () => { - expect(computeLineSimilarity("hello world", "hello world")).toBe(1.0) +describe("relativeChanges", () => { + test("returns empty ranges for identical strings", () => { + const result = relativeChanges("hello world", "hello world") + expect(result.stringARange.length).toBe(0) + expect(result.stringBRange.length).toBe(0) }) - test("returns 1.0 for both empty strings", () => { - expect(computeLineSimilarity("", "")).toBe(1.0) + test("returns full ranges for completely different strings", () => { + const result = relativeChanges("abc", "xyz") + expect(result.stringARange).toEqual({ location: 0, length: 3 }) + expect(result.stringBRange).toEqual({ location: 0, length: 3 }) }) - test("returns 0.0 when one string is empty", () => { - expect(computeLineSimilarity("hello", "")).toBe(0.0) - expect(computeLineSimilarity("", "hello")).toBe(0.0) + test("finds changed region with common prefix", () => { + const result = relativeChanges("hello world", "hello there") + // Common prefix: "hello " (6 chars) + expect(result.stringARange.location).toBe(6) + expect(result.stringARange.length).toBe(5) // "world" + expect(result.stringBRange.location).toBe(6) + expect(result.stringBRange.length).toBe(5) // "there" }) - test("returns high similarity for small changes", () => { - const similarity = computeLineSimilarity("const x = 1", "const x = 2") - expect(similarity).toBeGreaterThan(0.8) + test("finds changed region with common suffix", () => { + const result = relativeChanges("const x = 1", "const x = 2") + // Common prefix: "const x = " (10 chars), Common suffix: "" (0 chars) + expect(result.stringARange.location).toBe(10) + expect(result.stringARange.length).toBe(1) // "1" + expect(result.stringBRange.location).toBe(10) + expect(result.stringBRange.length).toBe(1) // "2" }) - test("returns low similarity for completely different strings", () => { - const similarity = computeLineSimilarity("abc", "xyz") - expect(similarity).toBe(0.0) + test("handles empty strings", () => { + const result1 = relativeChanges("hello", "") + expect(result1.stringARange).toEqual({ location: 0, length: 5 }) + expect(result1.stringBRange).toEqual({ location: 0, length: 0 }) + + const result2 = relativeChanges("", "hello") + expect(result2.stringARange).toEqual({ location: 0, length: 0 }) + expect(result2.stringBRange).toEqual({ location: 0, length: 5 }) }) - test("returns partial similarity for partially matching strings", () => { - const similarity = computeLineSimilarity("hello world", "hello there") - expect(similarity).toBeGreaterThan(0.4) - expect(similarity).toBeLessThan(0.7) + test("finds single contiguous changed region (not multiple)", () => { + // "a b c" -> "x b z" has changes at start and end + // But prefix/suffix algorithm returns single region from first change to last + const result = relativeChanges("a b c", "x b z") + // Common prefix: "" (0 chars), Common suffix: "" (0 chars) + // So everything is different + expect(result.stringARange.location).toBe(0) + expect(result.stringARange.length).toBe(5) + expect(result.stringBRange.location).toBe(0) + expect(result.stringBRange.length).toBe(5) }) }) describe("computeInlineHighlights", () => { - test("returns empty highlights for identical strings", () => { + test("returns null highlights for identical strings", () => { const result = computeInlineHighlights("hello world", "hello world") - expect(result.oldHighlights).toHaveLength(0) - expect(result.newHighlights).toHaveLength(0) + expect(result.oldHighlight).toBeNull() + expect(result.newHighlight).toBeNull() }) - test("highlights changed words", () => { + test("highlights changed region", () => { const result = computeInlineHighlights("hello world", "hello there") - expect(result.oldHighlights.length).toBeGreaterThan(0) - expect(result.oldHighlights[0].type).toBe("removed-word") - expect(result.newHighlights.length).toBeGreaterThan(0) - expect(result.newHighlights[0].type).toBe("added-word") + expect(result.oldHighlight).not.toBeNull() + expect(result.oldHighlight!.type).toBe("removed-word") + expect(result.newHighlight).not.toBeNull() + expect(result.newHighlight!.type).toBe("added-word") }) test("computes correct column positions", () => { const result = computeInlineHighlights("const x = 1", "const x = 2") - expect(result.oldHighlights[0].startCol).toBe(10) - expect(result.oldHighlights[0].endCol).toBe(11) - expect(result.newHighlights[0].startCol).toBe(10) - expect(result.newHighlights[0].endCol).toBe(11) + expect(result.oldHighlight!.startCol).toBe(10) + expect(result.oldHighlight!.endCol).toBe(11) + expect(result.newHighlight!.startCol).toBe(10) + expect(result.newHighlight!.endCol).toBe(11) }) - test("handles multiple changes", () => { + test("returns single contiguous region for multiple changes (GitHub Desktop behavior)", () => { + // Unlike word-level diffing, prefix/suffix algorithm returns single region const result = computeInlineHighlights("a b c", "x b z") - expect(result.oldHighlights.length).toBe(2) - expect(result.newHighlights.length).toBe(2) + // Single highlight covering the entire changed region + expect(result.oldHighlight).not.toBeNull() + expect(result.newHighlight).not.toBeNull() + expect(result.oldHighlight!.startCol).toBe(0) + expect(result.oldHighlight!.endCol).toBe(5) // "a b c" + expect(result.newHighlight!.startCol).toBe(0) + expect(result.newHighlight!.endCol).toBe(5) // "x b z" }) test("handles multi-width characters (CJK)", () => { const result = computeInlineHighlights("hello 世界", "hello 你好") - expect(result.oldHighlights.length).toBe(1) - expect(result.newHighlights.length).toBe(1) - expect(result.oldHighlights[0].startCol).toBe(6) - expect(result.oldHighlights[0].endCol).toBe(10) - expect(result.newHighlights[0].startCol).toBe(6) - expect(result.newHighlights[0].endCol).toBe(10) + expect(result.oldHighlight).not.toBeNull() + expect(result.newHighlight).not.toBeNull() + expect(result.oldHighlight!.startCol).toBe(6) + expect(result.oldHighlight!.endCol).toBe(10) // 2 CJK chars = 4 display width + expect(result.newHighlight!.startCol).toBe(6) + expect(result.newHighlight!.endCol).toBe(10) }) test("handles emoji characters", () => { const result = computeInlineHighlights("test 👍", "test 👎") - expect(result.oldHighlights.length).toBe(1) - expect(result.newHighlights.length).toBe(1) - expect(result.oldHighlights[0].startCol).toBe(5) - expect(result.newHighlights[0].startCol).toBe(5) + expect(result.oldHighlight).not.toBeNull() + expect(result.newHighlight).not.toBeNull() + expect(result.oldHighlight!.startCol).toBe(5) + expect(result.newHighlight!.startCol).toBe(5) + }) + + test("handles insertion (no removal)", () => { + const result = computeInlineHighlights("hello", "hello world") + expect(result.oldHighlight).toBeNull() // nothing removed + expect(result.newHighlight).not.toBeNull() + expect(result.newHighlight!.startCol).toBe(5) + expect(result.newHighlight!.endCol).toBe(11) // " world" + }) + + test("handles deletion (no addition)", () => { + const result = computeInlineHighlights("hello world", "hello") + expect(result.oldHighlight).not.toBeNull() + expect(result.oldHighlight!.startCol).toBe(5) + expect(result.oldHighlight!.endCol).toBe(11) // " world" + expect(result.newHighlight).toBeNull() // nothing added }) }) @@ -2761,7 +2806,6 @@ describe("DiffRenderable word highlights", () => { }) expect(diffRenderable.disableWordHighlights).toBe(false) - expect(diffRenderable.lineSimilarityThreshold).toBe(0.5) expect(diffRenderable.addedWordBg).toBeDefined() expect(diffRenderable.removedWordBg).toBeDefined() }) @@ -2802,27 +2846,32 @@ describe("DiffRenderable word highlights", () => { expect(diffRenderable.removedWordBg).toEqual(RGBA.fromHex("#ff0000")) }) - test("can adjust similarity threshold", async () => { + test("only highlights when equal number of adds and removes (GitHub Desktop behavior)", async () => { const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: RGBA.fromValues(1, 1, 1, 1) }, }) + // This diff has 1 remove and 1 add - should highlight + const equalDiff = `--- a/test.js ++++ b/test.js +@@ -1 +1 @@ +-const x = 1 ++const x = 2 +` + const diffRenderable = new DiffRenderable(currentRenderer, { id: "test-diff", - diff: simpleDiff, + diff: equalDiff, view: "split", syntaxStyle, - lineSimilarityThreshold: 0.8, }) - expect(diffRenderable.lineSimilarityThreshold).toBe(0.8) - diffRenderable.lineSimilarityThreshold = 0.5 - expect(diffRenderable.lineSimilarityThreshold).toBe(0.5) - diffRenderable.lineSimilarityThreshold = 1.5 - expect(diffRenderable.lineSimilarityThreshold).toBe(1.0) + // The diff should be rendered (we can't easily check highlights without more infrastructure) + expect(diffRenderable.disableWordHighlights).toBe(false) + }) - diffRenderable.lineSimilarityThreshold = -0.5 - expect(diffRenderable.lineSimilarityThreshold).toBe(0.0) + test("MaxIntraLineDiffStringLength is exported and has correct value", () => { + expect(MaxIntraLineDiffStringLength).toBe(1024) }) }) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index 0114b8b48..57077f258 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -9,7 +9,7 @@ import { } from "./LineNumberRenderable" import { RGBA, parseColor } from "../lib/RGBA" import { SyntaxStyle } from "../syntax-style" -import { parsePatch, diffWordsWithSpace, type StructuredPatch } from "diff" +import { parsePatch, type StructuredPatch } from "diff" import { TextRenderable } from "./Text" import type { TreeSitterClient } from "../lib/tree-sitter" @@ -20,49 +20,90 @@ interface InlineHighlight { type: "added-word" | "removed-word" } -/** Computes similarity between two strings (0.0 to 1.0) using word-level diff */ -export function computeLineSimilarity(a: string, b: string): number { - if (a === b) return 1.0 - if (a.length === 0 && b.length === 0) return 1.0 - if (a.length === 0 || b.length === 0) return 0.0 - - const changes = diffWordsWithSpace(a, b) - let unchangedLength = 0 - for (const change of changes) { - if (!change.added && !change.removed) { - unchangedLength += change.value.length +/** Represents a range within a string */ +interface IRange { + location: number + length: number +} + +/** + * The longest line for which we'd try to calculate a line diff. + * This matches GitHub.com's behavior. + */ +export const MaxIntraLineDiffStringLength = 1024 + +/** Get the length of the common substring between two strings */ +function commonLength(stringA: string, rangeA: IRange, stringB: string, rangeB: IRange, reverse: boolean): number { + const max = Math.min(rangeA.length, rangeB.length) + const startA = reverse ? rangeA.location + rangeA.length - 1 : rangeA.location + const startB = reverse ? rangeB.location + rangeB.length - 1 : rangeB.location + const stride = reverse ? -1 : 1 + + let length = 0 + while (Math.abs(length) < max) { + if (stringA[startA + length] !== stringB[startB + length]) { + break } + length += stride } - return unchangedLength / Math.max(a.length, b.length) + + return Math.abs(length) +} + +/** + * Get the changed ranges in the strings, relative to each other. + * Uses common prefix/suffix elimination algorithm (matching GitHub Desktop). + */ +export function relativeChanges(stringA: string, stringB: string): { stringARange: IRange; stringBRange: IRange } { + let bRange: IRange = { location: 0, length: stringB.length } + let aRange: IRange = { location: 0, length: stringA.length } + + const prefixLength = commonLength(stringB, bRange, stringA, aRange, false) + bRange = { + location: bRange.location + prefixLength, + length: bRange.length - prefixLength, + } + aRange = { + location: aRange.location + prefixLength, + length: aRange.length - prefixLength, + } + + const suffixLength = commonLength(stringB, bRange, stringA, aRange, true) + bRange = { location: bRange.location, length: bRange.length - suffixLength } + aRange = { location: aRange.location, length: aRange.length - suffixLength } + + return { stringARange: aRange, stringBRange: bRange } } -/** Computes word-level inline highlights for two strings */ +/** + * Computes inline highlights for two strings using prefix/suffix elimination. + * Returns a single changed region per line (matching GitHub Desktop behavior). + */ export function computeInlineHighlights( oldContent: string, newContent: string, -): { oldHighlights: InlineHighlight[]; newHighlights: InlineHighlight[] } { - const changes = diffWordsWithSpace(oldContent, newContent) - - const oldHighlights: InlineHighlight[] = [] - const newHighlights: InlineHighlight[] = [] - let oldCol = 0 - let newCol = 0 - - for (const change of changes) { - const displayWidth = Bun.stringWidth(change.value) - if (change.added) { - newHighlights.push({ startCol: newCol, endCol: newCol + displayWidth, type: "added-word" }) - newCol += displayWidth - } else if (change.removed) { - oldHighlights.push({ startCol: oldCol, endCol: oldCol + displayWidth, type: "removed-word" }) - oldCol += displayWidth - } else { - oldCol += displayWidth - newCol += displayWidth - } +): { oldHighlight: InlineHighlight | null; newHighlight: InlineHighlight | null } { + if (oldContent === newContent) { + return { oldHighlight: null, newHighlight: null } } - return { oldHighlights, newHighlights } + const { stringARange, stringBRange } = relativeChanges(oldContent, newContent) + + // Convert character positions to display column positions + const oldPrefix = oldContent.slice(0, stringARange.location) + const oldChanged = oldContent.slice(stringARange.location, stringARange.location + stringARange.length) + const newPrefix = newContent.slice(0, stringBRange.location) + const newChanged = newContent.slice(stringBRange.location, stringBRange.location + stringBRange.length) + + const oldStartCol = Bun.stringWidth(oldPrefix) + const oldEndCol = oldStartCol + Bun.stringWidth(oldChanged) + const newStartCol = Bun.stringWidth(newPrefix) + const newEndCol = newStartCol + Bun.stringWidth(newChanged) + + return { + oldHighlight: stringARange.length > 0 ? { startCol: oldStartCol, endCol: oldEndCol, type: "removed-word" } : null, + newHighlight: stringBRange.length > 0 ? { startCol: newStartCol, endCol: newEndCol, type: "added-word" } : null, + } } interface LogicalLine { @@ -121,12 +162,6 @@ export interface DiffRenderableOptions extends RenderableOptions * @default removedBg brightened 1.5x with +0.15 opacity */ removedWordBg?: string | RGBA - /** - * Minimum similarity threshold (0.0 to 1.0) for pairing lines. - * Lines with similarity below this threshold are treated as separate add/remove. - * @default 0.4 - */ - lineSimilarityThreshold?: number } export class DiffRenderable extends Renderable { @@ -164,7 +199,6 @@ export class DiffRenderable extends Renderable { private _disableWordHighlights: boolean private _addedWordBg: RGBA private _removedWordBg: RGBA - private _lineSimilarityThreshold: number private leftSide: LineNumberRenderable | null = null private rightSide: LineNumberRenderable | null = null @@ -220,7 +254,6 @@ export class DiffRenderable extends Renderable { this._addedLineNumberBg = parseColor(options.addedLineNumberBg ?? "transparent") this._removedLineNumberBg = parseColor(options.removedLineNumberBg ?? "transparent") this._disableWordHighlights = options.disableWordHighlights ?? false - this._lineSimilarityThreshold = options.lineSimilarityThreshold ?? 0.5 this._addedWordBg = options.addedWordBg ? parseColor(options.addedWordBg) : this.brightenAndIncreaseOpacity(this._addedBg, 1.5, 0.15) @@ -247,9 +280,6 @@ export class DiffRenderable extends Renderable { return highlights.map((h) => ({ startCol: h.startCol, endCol: h.endCol, bg })) } - // Skip word highlights for blocks larger than this - private static readonly MAX_WORD_HIGHLIGHT_BLOCK_SIZE = 50 - private processChangeBlockWithHighlights( removes: { content: string; lineNum: number }[], adds: { content: string; lineNum: number }[], @@ -258,25 +288,37 @@ export class DiffRenderable extends Renderable { const rightLines: LogicalLine[] = [] const maxLength = Math.max(removes.length, adds.length) - const blockSize = removes.length + adds.length - const shouldComputeWordHighlights = - !this._disableWordHighlights && blockSize <= DiffRenderable.MAX_WORD_HIGHLIGHT_BLOCK_SIZE - for (let j = 0; j < maxLength; j++) { - const remove = j < removes.length ? removes[j] : null - const add = j < adds.length ? adds[j] : null + // To match the behavior of github.com, we only highlight differences between + // lines on hunks that have the same number of added and deleted lines. + const shouldDisplayDiffInChunk = !this._disableWordHighlights && adds.length === removes.length + + // Pre-compute diff tokens for paired lines (matching GitHub Desktop) + const diffTokensBefore: (InlineHighlight | null)[] = [] + const diffTokensAfter: (InlineHighlight | null)[] = [] - let leftHighlights: InlineHighlight[] = [] - let rightHighlights: InlineHighlight[] = [] + if (shouldDisplayDiffInChunk) { + for (let i = 0; i < removes.length; i++) { + const remove = removes[i] + const add = adds[i] - if (shouldComputeWordHighlights && remove && add) { - const similarity = computeLineSimilarity(remove.content, add.content) - if (similarity >= this._lineSimilarityThreshold) { - const highlights = computeInlineHighlights(remove.content, add.content) - leftHighlights = highlights.oldHighlights - rightHighlights = highlights.newHighlights + if (remove.content.length < MaxIntraLineDiffStringLength && add.content.length < MaxIntraLineDiffStringLength) { + const { oldHighlight, newHighlight } = computeInlineHighlights(remove.content, add.content) + diffTokensBefore[i] = oldHighlight + diffTokensAfter[i] = newHighlight + } else { + diffTokensBefore[i] = null + diffTokensAfter[i] = null } } + } + + for (let j = 0; j < maxLength; j++) { + const remove = j < removes.length ? removes[j] : null + const add = j < adds.length ? adds[j] : null + + const leftHighlight = shouldDisplayDiffInChunk && j < diffTokensBefore.length ? diffTokensBefore[j] : null + const rightHighlight = shouldDisplayDiffInChunk && j < diffTokensAfter.length ? diffTokensAfter[j] : null if (remove) { leftLines.push({ @@ -288,7 +330,7 @@ export class DiffRenderable extends Renderable { afterColor: this._removedSignColor, }, type: "remove", - inlineHighlights: leftHighlights, + inlineHighlights: leftHighlight ? [leftHighlight] : [], }) } else { leftLines.push({ @@ -308,7 +350,7 @@ export class DiffRenderable extends Renderable { afterColor: this._addedSignColor, }, type: "add", - inlineHighlights: rightHighlights, + inlineHighlights: rightHighlight ? [rightHighlight] : [], }) } else { rightLines.push({ @@ -1374,15 +1416,4 @@ export class DiffRenderable extends Renderable { this.rebuildView() } } - - public get lineSimilarityThreshold(): number { - return this._lineSimilarityThreshold - } - - public set lineSimilarityThreshold(value: number) { - if (this._lineSimilarityThreshold !== value) { - this._lineSimilarityThreshold = Math.max(0, Math.min(1, value)) - this.rebuildView() - } - } } From 7a09804a3897eac9293bd8da0f0816216ae0216d Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Fri, 16 Jan 2026 16:57:52 +0100 Subject: [PATCH 26/26] tweak word highlights --- packages/core/src/renderables/Diff.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index 57077f258..bf0d5c538 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -256,10 +256,10 @@ export class DiffRenderable extends Renderable { this._disableWordHighlights = options.disableWordHighlights ?? false this._addedWordBg = options.addedWordBg ? parseColor(options.addedWordBg) - : this.brightenAndIncreaseOpacity(this._addedBg, 1.5, 0.15) + : this.brightenAndIncreaseOpacity(this._addedBg, 1.4, 0.1) this._removedWordBg = options.removedWordBg ? parseColor(options.removedWordBg) - : this.brightenAndIncreaseOpacity(this._removedBg, 1.5, 0.15) + : this.brightenAndIncreaseOpacity(this._removedBg, 1.4, 0.1) if (this._diff) { this.parseDiff()