Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ec432ca
add word highlights
remorses Nov 30, 2025
c0ceee1
test: add word highlight tests
remorses Nov 30, 2025
85e2afd
format
remorses Nov 30, 2025
45884fe
fix: use Bun.stringWidth for multi-width character support in word hi…
remorses Nov 30, 2025
ebfc48c
fix: correct line counts in example diff hunk header
remorses Nov 30, 2025
a5a9136
update react example
remorses Nov 30, 2025
d663823
rename showWordHighlights to disableWordHighlights
remorses Nov 30, 2025
117e470
chore: remove verbose comments
remorses Dec 5, 2025
1c2e986
refactor: simplify word highlights code
remorses Dec 5, 2025
6cc3958
fix: revert unrelated simplifications to split view forEach loops
remorses Dec 5, 2025
4a96d11
Merge upstream/main into word-highlights
remorses Dec 14, 2025
88080c0
chore: format Diff.ts
remorses Dec 14, 2025
912849e
fix: word highlights scroll with content when scrollX is non-zero
remorses Dec 14, 2025
f437756
fix: word highlights respect line wrapping
remorses Dec 14, 2025
8989ff0
fix: correctly compute column offset for wrapped line highlights
remorses Dec 14, 2025
8e63649
cleanup: remove excessive comments
remorses Dec 14, 2025
cbc18de
test: use inline snapshots with trimmed lines for scrollX tests
remorses Dec 14, 2025
212bec3
test: remove obsolete external snapshots
remorses Dec 14, 2025
d3175de
test: remove useless word highlight tests that don't test anything me…
remorses Dec 14, 2025
9a100ea
test: remove scrollX tests that can't verify highlight rendering
remorses Dec 14, 2025
570e067
chore: format
remorses Dec 14, 2025
4da4af0
Merge branch 'main' into word-highlights
remorses Dec 16, 2025
0b2ef8b
use diff with words for similarity
remorses Dec 16, 2025
b00521e
Merge branch 'word-highlights' of https://github.com/remorses/opentui…
remorses Dec 16, 2025
802361d
increase default lineSimilarityThreshold to 0.5
remorses Dec 16, 2025
e74a3fa
fix test: update expected default lineSimilarityThreshold to 0.5
remorses Dec 16, 2025
c650652
Merge branch 'main' into word-highlights
remorses Dec 18, 2025
a8ceb5e
derive word highlight colors from hunk colors with brighten + opacity…
remorses Dec 18, 2025
35e6ad4
Merge anomalyco/main into word-highlights
remorses Jan 7, 2026
e268e3d
Merge remote-tracking branch 'upstream/main' into word-highlights
remorses Jan 16, 2026
2d2ed77
refactor: use GitHub Desktop-style word highlights algorithm
remorses Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 209 additions & 2 deletions packages/core/src/renderables/Diff.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading