diff --git a/packages/core/src/renderables/Diff.test.ts b/packages/core/src/renderables/Diff.test.ts index 58e599bf1..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 } from "bun:test" -import { DiffRenderable } from "./Diff" +import { test, expect, beforeEach, afterEach, describe } from "bun:test" +import { DiffRenderable, computeInlineHighlights, relativeChanges, MaxIntraLineDiffStringLength } from "./Diff" import { SyntaxStyle } from "../syntax-style" import { RGBA } from "../lib/RGBA" import { createTestRenderer, type TestRenderer } from "../testing" @@ -2668,6 +2668,213 @@ test("DiffRenderable - fg prop accepts RGBA directly", async () => { expect(leftCodeRenderable.fg).toEqual(customFg) }) +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 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("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("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("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("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 null highlights for identical strings", () => { + const result = computeInlineHighlights("hello world", "hello world") + expect(result.oldHighlight).toBeNull() + expect(result.newHighlight).toBeNull() + }) + + test("highlights changed region", () => { + const result = computeInlineHighlights("hello world", "hello there") + 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.oldHighlight!.startCol).toBe(10) + expect(result.oldHighlight!.endCol).toBe(11) + expect(result.newHighlight!.startCol).toBe(10) + expect(result.newHighlight!.endCol).toBe(11) + }) + + 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") + // 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.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.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 + }) +}) + +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.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("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: equalDiff, + view: "split", + syntaxStyle, + }) + + // The diff should be rendered (we can't easily check highlights without more infrastructure) + expect(diffRenderable.disableWordHighlights).toBe(false) + }) + + test("MaxIntraLineDiffStringLength is exported and has correct value", () => { + expect(MaxIntraLineDiffStringLength).toBe(1024) + }) +}) + test("DiffRenderable - split view with word wrapping: changing diff content should not misalign sides", async () => { const { BoxRenderable } = await import("./Box") const { parseColor } = await import("../lib/RGBA") diff --git a/packages/core/src/renderables/Diff.ts b/packages/core/src/renderables/Diff.ts index f7ba43691..bf0d5c538 100644 --- a/packages/core/src/renderables/Diff.ts +++ b/packages/core/src/renderables/Diff.ts @@ -1,13 +1,111 @@ 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 { 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" +} + +/** 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 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 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, +): { oldHighlight: InlineHighlight | null; newHighlight: InlineHighlight | null } { + if (oldContent === newContent) { + return { oldHighlight: null, newHighlight: null } + } + + 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 { content: string lineNum?: number @@ -15,6 +113,7 @@ interface LogicalLine { color?: string | RGBA sign?: LineSign type: "context" | "add" | "remove" | "empty" + inlineHighlights?: InlineHighlight[] } export interface DiffRenderableOptions extends RenderableOptions { @@ -47,6 +146,22 @@ 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 } export class DiffRenderable extends Renderable { @@ -81,6 +196,9 @@ export class DiffRenderable extends Renderable { private _removedSignColor: RGBA private _addedLineNumberBg: RGBA private _removedLineNumberBg: RGBA + private _disableWordHighlights: boolean + private _addedWordBg: RGBA + private _removedWordBg: RGBA private leftSide: LineNumberRenderable | null = null private rightSide: LineNumberRenderable | null = null @@ -135,6 +253,13 @@ 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._addedWordBg = options.addedWordBg + ? parseColor(options.addedWordBg) + : this.brightenAndIncreaseOpacity(this._addedBg, 1.4, 0.1) + this._removedWordBg = options.removedWordBg + ? parseColor(options.removedWordBg) + : this.brightenAndIncreaseOpacity(this._removedBg, 1.4, 0.1) if (this._diff) { this.parseDiff() @@ -142,6 +267,103 @@ 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 })) + } + + 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) + + // 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)[] = [] + + if (shouldDisplayDiffInChunk) { + for (let i = 0; i < removes.length; i++) { + const remove = removes[i] + const add = adds[i] + + 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({ + content: remove.content, + lineNum: remove.lineNum, + color: this._removedBg, + sign: { + after: " -", + afterColor: this._removedSignColor, + }, + type: "remove", + inlineHighlights: leftHighlight ? [leftHighlight] : [], + }) + } 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: rightHighlight ? [rightHighlight] : [], + }) + } else { + rightLines.push({ + content: "", + hideLineNumber: true, + type: "empty", + }) + } + } + + return { leftLines, rightLines } + } + private parseDiff(): void { if (!this._diff) { this._parsedDiff = null @@ -384,6 +606,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 @@ -399,6 +622,7 @@ export class DiffRenderable extends Renderable { lineNumbers, lineNumberOffset: 0, hideLineNumbers, + inlineHighlights, width, height: "100%", }) @@ -418,6 +642,11 @@ export class DiffRenderable extends Renderable { sideRef.setLineSigns(lineSigns) sideRef.setLineNumbers(lineNumbers) sideRef.setHideLineNumbers(hideLineNumbers) + if (inlineHighlights) { + sideRef.setInlineHighlights(inlineHighlights) + } else { + sideRef.clearInlineHighlights() + } if (!addedFlag) { super.add(sideRef) @@ -452,6 +681,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 @@ -459,47 +689,14 @@ 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 === "+") { - contentLines.push(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, - }) - lineNumbers.set(lineIndex, newLineNum) - newLineNum++ - lineIndex++ - } else if (firstChar === "-") { - contentLines.push(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, - }) - lineNumbers.set(lineIndex, oldLineNum) - oldLineNum++ - lineIndex++ - } else if (firstChar === " ") { + if (firstChar === " ") { + // Context line contentLines.push(content) const config: LineColorConfig = { gutter: this._lineNumberBg, @@ -514,6 +711,66 @@ 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++ + } } } } @@ -522,7 +779,17 @@ export class DiffRenderable extends Renderable { const codeRenderable = this.createOrUpdateCodeRenderable("left", content, this._wrapMode) - this.createOrUpdateSide("left", codeRenderable, lineColors, lineSigns, lineNumbers, new Set(), "100%") + // Create or update LineNumberRenderable (leftSide used for unified view) + this.createOrUpdateSide( + "left", + codeRenderable, + lineColors, + lineSigns, + lineNumbers, + new Set(), + "100%", + inlineHighlights.size > 0 ? inlineHighlights : undefined, + ) if (this.rightSide && this.rightSideAdded) { super.remove(this.rightSide.id) @@ -603,46 +870,15 @@ export class DiffRenderable extends Renderable { i++ } - 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 +994,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 +1028,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) => { @@ -823,6 +1064,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") @@ -839,6 +1083,7 @@ export class DiffRenderable extends Renderable { leftLineNumbers, leftHideLineNumbers, "50%", + leftInlineHighlights.size > 0 ? leftInlineHighlights : undefined, ) this.createOrUpdateSide( "right", @@ -848,6 +1093,7 @@ export class DiffRenderable extends Renderable { rightLineNumbers, rightHideLineNumbers, "50%", + rightInlineHighlights.size > 0 ? rightInlineHighlights : undefined, ) } @@ -1135,4 +1381,39 @@ 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() + } + } } diff --git a/packages/core/src/renderables/LineNumberRenderable.ts b/packages/core/src/renderables/LineNumberRenderable.ts index e7ddd55d5..9a4e16bcd 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/react/examples/diff.tsx b/packages/react/examples/diff.tsx index e58a22f58..1d78e19cb 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 }) => {