Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ All notable user-visible changes to Hunk are documented in this file.

### Fixed

- Balanced Pierre word-level highlights so split-view inline changes stay visible without overpowering the surrounding diff row.

## [0.9.3] - 2026-04-13

### Fixed
Expand Down
94 changes: 94 additions & 0 deletions src/ui/components/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { act, createRef, useEffect, useState, type ReactNode } from "react";
import type { AppBootstrap, DiffFile } from "../../core/types";
import { createTestGitAppBootstrap } from "../../../test/helpers/app-bootstrap";
import { createTestDiffFile as buildTestDiffFile, lines } from "../../../test/helpers/diff-helpers";
import { hexColorDistance } from "../lib/color";
import { resolveTheme } from "../themes";
import { measureDiffSectionGeometry } from "../lib/diffSectionGeometry";
import { buildFileSectionLayouts, buildInStreamFileHeaderHeights } from "../lib/fileSectionLayout";
Expand Down Expand Up @@ -351,6 +352,44 @@ function frameHasHighlightedMarker(
});
}

/** Convert captured RGBA output back into a #rrggbb color string for contrast assertions. */
function capturedColorToHex(color: { buffer?: ArrayLike<number> } | undefined) {
const buffer = color?.buffer;
if (!buffer || buffer[0] == null || buffer[1] == null || buffer[2] == null) {
return null;
}

const componentToHex = (value: number) =>
Math.max(0, Math.min(255, Math.round(value * 255)))
.toString(16)
.padStart(2, "0");

return `#${componentToHex(buffer[0])}${componentToHex(buffer[1])}${componentToHex(buffer[2])}`;
}

/** Measure the rendered background contrast between one word-diff span and its surrounding line. */
function renderedWordDiffBackgroundDistance(
frame: { lines: Array<{ spans: Array<{ text: string; bg?: { buffer?: ArrayLike<number> } }> }> },
marker: string,
) {
for (const line of frame.lines) {
const spanIndex = line.spans.findIndex((span) => span.text.includes(marker));
if (spanIndex <= 0) {
continue;
}

const wordBg = capturedColorToHex(line.spans[spanIndex]?.bg);
const surroundingBg = capturedColorToHex(line.spans[spanIndex - 1]?.bg);
if (!wordBg || !surroundingBg) {
continue;
}

return hexColorDistance(wordBg, surroundingBg);
}

return null;
}

describe("UI components", () => {
test("SidebarPane renders grouped file rows with indented filenames and right-aligned stats", async () => {
const theme = resolveTheme("midnight", null);
Expand Down Expand Up @@ -1901,6 +1940,61 @@ describe("UI components", () => {
expect(binaryFileFrame).toContain("Binary file skipped");
});

test("PierreDiffView renders word-diff spans with a visibly different background in split view", async () => {
const file = createTestDiffFile(
"word-diff",
"word-diff.ts",
"export const answer = 41;\nexport const stable = true;\n",
"export const answer = 42;\nexport const stable = true;\n",
);
const theme = resolveTheme("graphite", null);
const setup = await testRender(
<PierreDiffView
file={file}
layout="split"
theme={theme}
width={120}
selectedHunkIndex={0}
scrollable={false}
/>,
{ width: 124, height: 10 },
);

try {
let removedBackgroundDistance: number | null = null;
let addedBackgroundDistance: number | null = null;

for (let iteration = 0; iteration < 200; iteration += 1) {
await act(async () => {
await setup.renderOnce();
await Bun.sleep(0);
await setup.renderOnce();
await Bun.sleep(0);
});

const frame = setup.captureSpans();
removedBackgroundDistance = renderedWordDiffBackgroundDistance(frame, "41");
addedBackgroundDistance = renderedWordDiffBackgroundDistance(frame, "42");

if (
removedBackgroundDistance !== null &&
addedBackgroundDistance !== null &&
removedBackgroundDistance > 0 &&
addedBackgroundDistance > 0
) {
break;
}
}

expect(removedBackgroundDistance).toBeGreaterThanOrEqual(28);
expect(addedBackgroundDistance).toBeGreaterThanOrEqual(28);
} finally {
await act(async () => {
setup.renderer.destroy();
});
}
});

test("PierreDiffView reuses highlighted rows after unmounting and remounting a file section", async () => {
const file = createTestDiffFile(
"cache",
Expand Down
17 changes: 13 additions & 4 deletions src/ui/diff/pierre.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,19 @@ describe("Pierre diff rows", () => {
throw new Error("Expected a split-line change row");
}

expect(changedRow.left.spans.some((span) => span.text.includes("41"))).toBe(true);
expect(changedRow.right.spans.some((span) => span.text.includes("42"))).toBe(true);
expect(changedRow.left.spans.some((span) => span.bg === theme.removedContentBg)).toBe(true);
expect(changedRow.right.spans.some((span) => span.bg === theme.addedContentBg)).toBe(true);
const removedWordSpan = changedRow.left.spans.find((span) => span.text.includes("41"));
const addedWordSpan = changedRow.right.spans.find((span) => span.text.includes("42"));

expect(removedWordSpan).toBeDefined();
expect(addedWordSpan).toBeDefined();
expect(removedWordSpan?.bg).toBeDefined();
expect(addedWordSpan?.bg).toBeDefined();
expect(changedRow.left.spans.some((span) => span.text.includes("export") && span.bg)).toBe(
false,
);
expect(changedRow.right.spans.some((span) => span.text.includes("export") && span.bg)).toBe(
false,
);
expect(
changedRow.right.spans.some(
(span) => span.text.includes("export") && typeof span.fg === "string",
Expand Down
68 changes: 50 additions & 18 deletions src/ui/diff/pierre.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@pierre/diffs";
import { formatHunkHeader } from "../../core/hunkHeader";
import type { DiffFile } from "../../core/types";
import { blendHex, hexColorDistance } from "../lib/color";
import type { AppTheme } from "../themes";
import { expandDiffTabs } from "./codeColumns";

Expand Down Expand Up @@ -165,6 +166,53 @@ const normalizedColorCache = new Map<string, Map<string, string>>();
// into terminal spans. The same highlighted line objects are reused when files remount or when
// we build both split and stack rows, so memoize flattened spans by line node + theme/background.
const flattenedHighlightedLineCache = new WeakMap<HastNode, Map<string, RenderSpan[]>>();
const MIN_WORD_DIFF_BG_DISTANCE = 28;
const WORD_DIFF_BLEND_STEP = 0.005;
const WORD_DIFF_MAX_BLEND = 0.2;
const wordDiffBackgroundCache = new Map<string, Record<SplitLineCell["kind"], string>>();

/** Blend toward the semantic sign color just enough to hit the minimum visible contrast. */
function strengthenWordDiffBg(lineBg: string, signColor: string) {
let strongestCandidate = lineBg;
const maxSteps = Math.floor(WORD_DIFF_MAX_BLEND / WORD_DIFF_BLEND_STEP);

for (let step = 1; step <= maxSteps; step += 1) {
const blendRatio = step * WORD_DIFF_BLEND_STEP;
const candidate = blendHex(signColor, lineBg, blendRatio);
strongestCandidate = candidate;

if (hexColorDistance(candidate, lineBg) >= MIN_WORD_DIFF_BG_DISTANCE) {
return candidate;
}
}

return strongestCandidate;
}

/** Resolve the inline word-diff background, strengthening theme colors that are too subtle to see. */
function wordDiffHighlightBg(kind: SplitLineCell["kind"], theme: AppTheme) {
let cached = wordDiffBackgroundCache.get(theme.id);
if (!cached) {
const addition =
hexColorDistance(theme.addedContentBg, theme.addedBg) >= MIN_WORD_DIFF_BG_DISTANCE
? theme.addedContentBg
: strengthenWordDiffBg(theme.addedBg, theme.addedSignColor);
const deletion =
hexColorDistance(theme.removedContentBg, theme.removedBg) >= MIN_WORD_DIFF_BG_DISTANCE
? theme.removedContentBg
: strengthenWordDiffBg(theme.removedBg, theme.removedSignColor);

cached = {
addition,
context: theme.contextContentBg,
deletion,
empty: theme.panelAlt,
};
wordDiffBackgroundCache.set(theme.id, cached);
}

return cached[kind];
}

/** Remap Pierre token hues that collide with diff add/remove semantics into theme-safe syntax colors. */
function normalizeHighlightedColor(color: string | undefined, theme: AppTheme) {
Expand Down Expand Up @@ -301,15 +349,7 @@ function makeSplitCell(
const fallbackText = cleanDiffLine(rawLine);
spans = fallbackText.length > 0 ? [{ text: fallbackText }] : [];
} else {
spans = flattenHighlightedLine(
highlightedLine,
theme,
kind === "addition"
? theme.addedContentBg
: kind === "deletion"
? theme.removedContentBg
: theme.contextContentBg,
);
spans = flattenHighlightedLine(highlightedLine, theme, wordDiffHighlightBg(kind, theme));

if (spans.length === 0) {
const fallbackText = cleanDiffLine(rawLine);
Expand Down Expand Up @@ -341,15 +381,7 @@ function makeStackCell(
const fallbackText = cleanDiffLine(rawLine);
spans = fallbackText.length > 0 ? [{ text: fallbackText }] : [];
} else {
spans = flattenHighlightedLine(
highlightedLine,
theme,
kind === "addition"
? theme.addedContentBg
: kind === "deletion"
? theme.removedContentBg
: theme.contextContentBg,
);
spans = flattenHighlightedLine(highlightedLine, theme, wordDiffHighlightBg(kind, theme));

if (spans.length === 0) {
const fallbackText = cleanDiffLine(rawLine);
Expand Down
18 changes: 1 addition & 17 deletions src/ui/diff/renderRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
resolveStackCellGeometry,
} from "./codeColumns";
import type { DiffRow, RenderSpan, SplitLineCell, StackLineCell } from "./pierre";
import { blendHex } from "../lib/color";

/** Clamp a label to one terminal row with an ellipsis. */
export function fitText(text: string, width: number) {
Expand Down Expand Up @@ -79,23 +80,6 @@ function sliceSpansWindow(spans: RenderSpan[], offset: number, width: number) {
};
}

/** Parse a hex color string into RGB components. */
function hexToRgb(hex: string) {
const n = parseInt(hex.slice(1), 16);
return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff };
}

/** Blend a color toward a background at a given ratio (0 = bg, 1 = fg). */
function blendHex(fg: string, bg: string, ratio: number) {
const f = hexToRgb(fg);
const b = hexToRgb(bg);
const mix = (a: number, z: number) => Math.round(z + (a - z) * ratio);
const r = mix(f.r, b.r);
const g = mix(f.g, b.g);
const bl = mix(f.b, b.b);
return `#${((r << 16) | (g << 8) | bl).toString(16).padStart(6, "0")}`;
}

const INACTIVE_RAIL_BLEND = 0.35;

/** Dim a rail color for inactive hunks by blending toward the panel background. */
Expand Down
40 changes: 40 additions & 0 deletions src/ui/lib/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** One parsed RGB triplet from a #rrggbb hex color. */
interface RgbColor {
r: number;
g: number;
b: number;
}

/** Parse a #rrggbb color into RGB components. Falls back to black for invalid input. */
function hexToRgb(hex: string): RgbColor {
const normalized = /^#?[0-9a-f]{6}$/i.test(hex) ? hex.replace(/^#/, "") : "000000";
const value = parseInt(normalized, 16);
return {
r: (value >> 16) & 0xff,
g: (value >> 8) & 0xff,
b: value & 0xff,
};
}

/** Blend one foreground color toward a background color at a fixed ratio. */
export function blendHex(fg: string, bg: string, ratio: number) {
const foreground = hexToRgb(fg);
const background = hexToRgb(bg);
const mix = (front: number, back: number) =>
Math.max(0, Math.min(255, Math.round(back + (front - back) * ratio)));

return `#${(
(mix(foreground.r, background.r) << 16) |
(mix(foreground.g, background.g) << 8) |
mix(foreground.b, background.b)
)
.toString(16)
.padStart(6, "0")}`;
}

/** Measure how visually separated two #rrggbb colors are using channel deltas. */
export function hexColorDistance(left: string, right: string) {
const a = hexToRgb(left);
const b = hexToRgb(right);
return Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b);
}
Loading