diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index 8a0a3a0e..73d51eb7 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" @@ -2667,3 +2667,161 @@ test("DiffRenderable - fg prop accepts RGBA directly", async () => { const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable expect(leftCodeRenderable.fg).toEqual(customFg) }) + +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") + 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") + }) + + 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) + }) + + test("handles multiple changes", () => { + const result = computeInlineHighlights("a b c", "x b z") + expect(result.oldHighlights.length).toBe(2) + expect(result.newHighlights.length).toBe(2) + }) + + 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) + }) + + 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) + }) +}) + +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.disableWordHighlights).toBe(false) + expect(diffRenderable.lineSimilarityThreshold).toBe(0.5) + 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, + disableWordHighlights: true, + }) + + expect(diffRenderable.disableWordHighlights).toBe(true) + diffRenderable.disableWordHighlights = false + expect(diffRenderable.disableWordHighlights).toBe(false) + }) + + 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) + diffRenderable.lineSimilarityThreshold = 0.5 + expect(diffRenderable.lineSimilarityThreshold).toBe(0.5) + diffRenderable.lineSimilarityThreshold = 1.5 + expect(diffRenderable.lineSimilarityThreshold).toBe(1.0) + + diffRenderable.lineSimilarityThreshold = -0.5 + expect(diffRenderable.lineSimilarityThreshold).toBe(0.0) + }) +}) diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index 6286e809..5c234af3 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -1,13 +1,70 @@ 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, diffWordsWithSpace, type StructuredPatch } from "diff" import { TextRenderable } from "./Text" import type { TreeSitterClient } from "../lib/tree-sitter" +/** 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 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 + } + } + return unchangedLength / Math.max(a.length, b.length) +} + +/** Computes word-level inline highlights for two strings */ +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 + } + } + + return { oldHighlights, newHighlights } +} + interface LogicalLine { content: string lineNum?: number @@ -15,6 +72,7 @@ interface LogicalLine { color?: string | RGBA sign?: LineSign type: "context" | "add" | "remove" | "empty" + inlineHighlights?: InlineHighlight[] } export interface DiffRenderableOptions extends RenderableOptions { @@ -47,6 +105,28 @@ export interface DiffRenderableOptions extends RenderableOptions removedSignColor?: string | RGBA addedLineNumberBg?: string | RGBA removedLineNumberBg?: string | RGBA + /** + * Disable word-level highlighting within modified lines. + * When false (default), individual words/characters that changed are highlighted. + * @default false + */ + disableWordHighlights?: boolean + /** + * Background color for added words within modified lines. + * @default addedBg brightened 1.5x with +0.15 opacity + */ + addedWordBg?: string | RGBA + /** + * Background color for removed words within modified lines. + * @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 { @@ -81,6 +161,10 @@ export class DiffRenderable extends Renderable { private _removedSignColor: RGBA private _addedLineNumberBg: RGBA private _removedLineNumberBg: RGBA + private _disableWordHighlights: 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 @@ -141,6 +225,14 @@ export class DiffRenderable extends Renderable { this._removedSignColor = parseColor(options.removedSignColor ?? "#ef4444") 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) + this._removedWordBg = options.removedWordBg + ? parseColor(options.removedWordBg) + : this.brightenAndIncreaseOpacity(this._removedBg, 1.5, 0.15) // Only parse and build if diff is provided if (this._diff) { @@ -149,6 +241,94 @@ export class DiffRenderable extends Renderable { } } + private brightenAndIncreaseOpacity(color: RGBA, brightenFactor: number, opacityIncrease: number): RGBA { + return RGBA.fromValues( + Math.min(1, color.r * brightenFactor), + Math.min(1, color.g * brightenFactor), + Math.min(1, color.b * brightenFactor), + Math.min(1, color.a + opacityIncrease), + ) + } + + 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 + + private processChangeBlockWithHighlights( + removes: { content: string; lineNum: number }[], + adds: { content: string; lineNum: number }[], + ): { leftLines: LogicalLine[]; rightLines: LogicalLine[] } { + const leftLines: LogicalLine[] = [] + 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 + + let leftHighlights: InlineHighlight[] = [] + let rightHighlights: InlineHighlight[] = [] + + 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) { + leftLines.push({ + content: remove.content, + lineNum: remove.lineNum, + color: this._removedBg, + sign: { + after: " -", + afterColor: this._removedSignColor, + }, + type: "remove", + inlineHighlights: leftHighlights, + }) + } else { + leftLines.push({ + content: "", + hideLineNumber: true, + type: "empty", + }) + } + + if (add) { + rightLines.push({ + content: add.content, + lineNum: add.lineNum, + color: this._addedBg, + sign: { + after: " +", + afterColor: this._addedSignColor, + }, + type: "add", + inlineHighlights: rightHighlights, + }) + } else { + rightLines.push({ + content: "", + hideLineNumber: true, + type: "empty", + }) + } + } + + return { leftLines, rightLines } + } + private parseDiff(): void { if (!this._diff) { this._parsedDiff = null @@ -381,6 +561,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 @@ -397,6 +578,7 @@ export class DiffRenderable extends Renderable { lineNumbers, lineNumberOffset: 0, hideLineNumbers, + inlineHighlights, width, height: "100%", }) @@ -417,6 +599,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) { @@ -454,6 +641,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 @@ -462,57 +650,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 { @@ -523,8 +672,67 @@ 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++ + } + + const processedBlock = this.processChangeBlockWithHighlights(removes, adds) + + 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 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++ + } } - // Skip "\ No newline at end of file" lines } } @@ -534,7 +742,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) { @@ -623,47 +840,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) } } } @@ -787,6 +972,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) { @@ -819,6 +1006,9 @@ export class DiffRenderable extends Renderable { 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) => { @@ -852,6 +1042,9 @@ export class DiffRenderable extends Renderable { if (line.sign) { rightLineSigns.set(index, line.sign) } + if (line.inlineHighlights?.length) { + rightInlineHighlights.set(index, this.toLineHighlights(line.inlineHighlights, this._addedWordBg)) + } }) const leftContentFinal = finalLeftLines.map((l) => l.content).join("\n") @@ -871,6 +1064,7 @@ export class DiffRenderable extends Renderable { leftLineNumbers, leftHideLineNumbers, "50%", + leftInlineHighlights.size > 0 ? leftInlineHighlights : undefined, ) this.createOrUpdateSide( "right", @@ -880,6 +1074,7 @@ export class DiffRenderable extends Renderable { rightLineNumbers, rightHideLineNumbers, "50%", + rightInlineHighlights.size > 0 ? rightInlineHighlights : undefined, ) } @@ -1170,4 +1365,50 @@ export class DiffRenderable extends Renderable { } } } + + public get disableWordHighlights(): boolean { + return this._disableWordHighlights + } + + public set disableWordHighlights(value: boolean) { + if (this._disableWordHighlights !== value) { + this._disableWordHighlights = 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 e7ddd55d..9a4e16bc 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) @@ -512,6 +534,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++) { @@ -523,7 +546,42 @@ 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) + } + + const inlineHighlights = this._inlineHighlights.get(logicalLine) + if (inlineHighlights && inlineHighlights.length > 0) { + const scrollX = (this.target as any).scrollX ?? 0 + const wrapIndex = lineInfo.lineWraps?.[visualLineIndex] ?? 0 + + // 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--) { + columnOffset += lineInfo.lineWidths[j] ?? 0 + } + } + + const thisLineWidth = lineInfo.lineWidths?.[visualLineIndex] ?? contentWidth + + for (const highlight of inlineHighlights) { + if (highlight.endCol <= columnOffset || highlight.startCol >= columnOffset + thisLineWidth) { + continue + } + + const visibleStartCol = Math.max(highlight.startCol, columnOffset) - columnOffset + const visibleEndCol = Math.min(highlight.endCol, columnOffset + thisLineWidth) - columnOffset + const highlightStartX = contentStartX + visibleStartCol - scrollX + const highlightWidth = visibleEndCol - visibleStartCol + + 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) + } + } } } } @@ -653,4 +711,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() + } } diff --git a/packages/core/src/renderables/__snapshots__/Diff.test.ts.snap b/packages/core/src/renderables/__snapshots__/Diff.test.ts.snap index c32f815d..449377ca 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 + + + + + + + + + + + + + + + + + " `; diff --git a/packages/react/examples/diff.tsx b/packages/react/examples/diff.tsx index e58a22f5..1d78e19c 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,17 +1,24 @@ 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 }) => {