Add intraline diff highlighting

This commit is contained in:
murashit 2026-05-27 16:08:06 +09:00
parent c9a6f87ddb
commit 8aa34cd982
8 changed files with 345 additions and 27 deletions

View file

@ -1,7 +1,7 @@
import { toolResultView, type ToolResultDetailSection, type ToolResultDisplayItem, type ToolResultView } from "../display/tool-view";
import { createMetaPair } from "../../../shared/ui/components";
import { applyExecutionStateClass } from "./execution-state";
import { diffLineClassFromText, displayDiffLineText } from "../../../shared/diff/unified";
import { renderRawDiffLines } from "../../../shared/diff/render";
export interface ToolResultRenderContext {
workspaceRoot?: string | null;
@ -95,9 +95,5 @@ function renderOutputSection(parent: HTMLElement, title: string, className: stri
}
function renderDiff(parent: HTMLElement, diff: string): void {
const pre = parent.createEl("pre", { cls: "codex-panel-diff" });
for (const line of diff.split("\n")) {
const cls = diffLineClassFromText(line);
pre.createEl("span", { cls: `codex-panel-diff__line codex-panel-diff__line--${cls}`, text: displayDiffLineText(line, cls) });
}
renderRawDiffLines(parent, diff);
}

View file

@ -1,4 +1,5 @@
import { displayDiffLines, diffLineClass, displayDiffLineText } from "../../../shared/diff/unified";
import { renderDisplayDiffLines } from "../../../shared/diff/render";
import { displayDiffLines } from "../../../shared/diff/unified";
import { createIconButton } from "../../../shared/ui/components";
import { shortThreadId } from "../../../utils";
@ -90,15 +91,7 @@ function renderTurnDiffHeader(
}
export function renderUnifiedDiff(parent: HTMLElement, diff: string): HTMLElement {
const pre = parent.createEl("pre", { cls: "codex-panel-diff codex-panel-chat-turn-diff__diff" });
for (const line of displayDiffLines(diff)) {
const lineClass = diffLineClass(line);
pre.createEl("span", {
cls: `codex-panel-diff__line codex-panel-diff__line--${lineClass}`,
text: displayDiffLineText(line.text, lineClass),
});
}
return pre;
return renderDisplayDiffLines(parent, displayDiffLines(diff), { className: "codex-panel-chat-turn-diff__diff" });
}
function fileCountLabel(files: string[]): string {

View file

@ -39,7 +39,7 @@ function lineChanges(originalLines: string[], replacementLines: string[]): DiffL
newIndex += 1;
} else if (
newIndex < replacementLines.length &&
(oldIndex === originalLines.length || (lengths[oldIndex]?.[newIndex + 1] ?? 0) >= (lengths[oldIndex + 1]?.[newIndex] ?? 0))
(oldIndex === originalLines.length || (lengths[oldIndex]?.[newIndex + 1] ?? 0) > (lengths[oldIndex + 1]?.[newIndex] ?? 0))
) {
changes.push({ prefix: "+", text: replacementLines[newIndex] ?? "" });
newIndex += 1;

View file

@ -1,6 +1,7 @@
import { Notice, type Editor } from "obsidian";
import { diffLineClass, displayDiffLineText, displayDiffLines } from "../../shared/diff/unified";
import { renderDisplayDiffLines } from "../../shared/diff/render";
import { displayDiffLines } from "../../shared/diff/unified";
import { createIconButton } from "../../shared/ui/components";
import { syncTextareaHeight } from "../../shared/ui/textarea-autogrow";
import { buildSelectionUnifiedDiff } from "./diff";
@ -362,13 +363,9 @@ export class SelectionRewritePopover {
}
function renderSelectionRewriteDiff(parent: HTMLElement, diff: string): void {
const pre = parent.createEl("pre", { cls: "codex-panel-diff codex-panel-selection-rewrite__diff-body" });
for (const line of displayDiffLines(diff)) {
if (line.kind === "file" || line.text.startsWith("@@")) continue;
const lineClass = diffLineClass(line);
pre.createEl("span", {
cls: `codex-panel-diff__line codex-panel-diff__line--${lineClass}`,
text: displayDiffLineText(line.text, lineClass),
});
}
renderDisplayDiffLines(
parent,
displayDiffLines(diff).filter((line) => line.kind !== "file" && !line.text.startsWith("@@")),
{ className: "codex-panel-selection-rewrite__diff-body" },
);
}

220
src/shared/diff/render.ts Normal file
View file

@ -0,0 +1,220 @@
import { diffLineClass, diffLineClassFromText, displayDiffLineText, type DiffLineClass, type DisplayDiffLine } from "./unified";
const MAX_INLINE_DIFF_CHARS = 4000;
const MAX_INLINE_DIFF_TOKENS = 500;
interface InlineDiffPart {
text: string;
changed: boolean;
}
interface InlineDiff {
added: InlineDiffPart[];
removed: InlineDiffPart[];
}
interface RenderDiffLine {
text: string;
className: DiffLineClass;
}
type ChangeLineClass = "added" | "removed";
export interface RenderDiffLinesOptions {
className?: string;
}
export function renderDisplayDiffLines(parent: HTMLElement, lines: DisplayDiffLine[], options: RenderDiffLinesOptions = {}): HTMLElement {
return renderDiffLines(
parent,
lines.map((line) => ({ text: line.text, className: diffLineClass(line) })),
options,
);
}
export function renderRawDiffLines(parent: HTMLElement, diff: string, options: RenderDiffLinesOptions = {}): HTMLElement {
return renderDiffLines(
parent,
diff.split("\n").map((line) => ({ text: line, className: diffLineClassFromText(line) })),
options,
);
}
function renderDiffLines(parent: HTMLElement, lines: RenderDiffLine[], options: RenderDiffLinesOptions): HTMLElement {
const className = ["codex-panel-diff", options.className].filter(Boolean).join(" ");
const pre = parent.createEl("pre", { cls: className });
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (!line) continue;
const changeClass = changeLineClass(line.className);
if (!changeClass) {
renderLine(pre, line);
continue;
}
const firstRun = collectChangeRun(lines, index, changeClass);
const secondRun = collectChangeRun(lines, firstRun.endIndex, oppositeChangeLineClass(changeClass));
if (secondRun.lines.length > 0) {
renderChangeRuns(pre, firstRun.lines, secondRun.lines);
index = secondRun.endIndex - 1;
continue;
}
for (const runLine of firstRun.lines) {
renderLine(pre, runLine);
}
index = firstRun.endIndex - 1;
}
return pre;
}
function collectChangeRun(
lines: RenderDiffLine[],
startIndex: number,
className: ChangeLineClass,
): { lines: RenderDiffLine[]; endIndex: number } {
const run: RenderDiffLine[] = [];
let index = startIndex;
while (index < lines.length && lines[index]?.className === className) {
const line = lines[index];
if (line) run.push(line);
index += 1;
}
return { lines: run, endIndex: index };
}
function renderChangeRuns(parent: HTMLElement, firstRun: RenderDiffLine[], secondRun: RenderDiffLine[]): void {
const inlineDiffs = pairedInlineDiffs(firstRun, secondRun);
for (let index = 0; index < firstRun.length; index += 1) {
const line = firstRun[index];
if (!line) continue;
renderLine(parent, line, inlinePartsForLine(line.className, inlineDiffs[index] ?? null));
}
for (let index = 0; index < secondRun.length; index += 1) {
const line = secondRun[index];
if (!line) continue;
renderLine(parent, line, inlinePartsForLine(line.className, inlineDiffs[index] ?? null));
}
}
function pairedInlineDiffs(firstRun: RenderDiffLine[], secondRun: RenderDiffLine[]): (InlineDiff | null)[] {
const pairCount = Math.min(firstRun.length, secondRun.length);
const inlineDiffs: (InlineDiff | null)[] = [];
for (let index = 0; index < pairCount; index += 1) {
const firstLine = firstRun[index];
const secondLine = secondRun[index];
if (!firstLine || !secondLine) {
inlineDiffs.push(null);
} else {
inlineDiffs.push(inlineDiffForLines(firstLine, secondLine));
}
}
return inlineDiffs;
}
function inlineDiffForLines(firstLine: RenderDiffLine, secondLine: RenderDiffLine): InlineDiff | null {
const removedLine = firstLine.className === "removed" ? firstLine : secondLine;
const addedLine = firstLine.className === "added" ? firstLine : secondLine;
return inlineDiff(displayDiffLineText(removedLine.text, removedLine.className), displayDiffLineText(addedLine.text, addedLine.className));
}
function inlinePartsForLine(className: DiffLineClass, inlineDiff: InlineDiff | null): InlineDiffPart[] | null {
if (!inlineDiff) return null;
if (className === "removed") return inlineDiff.removed;
if (className === "added") return inlineDiff.added;
return null;
}
function changeLineClass(className: DiffLineClass): ChangeLineClass | null {
return className === "added" || className === "removed" ? className : null;
}
function oppositeChangeLineClass(className: ChangeLineClass): ChangeLineClass {
return className === "added" ? "removed" : "added";
}
function renderLine(parent: HTMLElement, line: RenderDiffLine, inlineParts: InlineDiffPart[] | null = null): void {
const lineEl = parent.createEl("span", { cls: `codex-panel-diff__line codex-panel-diff__line--${line.className}` });
if (!inlineParts) {
lineEl.textContent = displayDiffLineText(line.text, line.className);
return;
}
for (const part of inlineParts) {
if (part.changed) {
lineEl.createSpan({ cls: `codex-panel-diff__word codex-panel-diff__word--${line.className}`, text: part.text });
} else {
lineEl.createSpan({ text: part.text });
}
}
}
function inlineDiff(removedText: string, addedText: string): InlineDiff | null {
if (removedText.length + addedText.length > MAX_INLINE_DIFF_CHARS) return null;
const removedTokens = segmentWords(removedText);
const addedTokens = segmentWords(addedText);
if (removedTokens.length + addedTokens.length > MAX_INLINE_DIFF_TOKENS) return null;
const lengths = lcsLengths(removedTokens, addedTokens);
const removed: InlineDiffPart[] = [];
const added: InlineDiffPart[] = [];
let removedIndex = 0;
let addedIndex = 0;
while (removedIndex < removedTokens.length || addedIndex < addedTokens.length) {
const removedToken = removedTokens[removedIndex];
const addedToken = addedTokens[addedIndex];
if (removedToken !== undefined && addedToken !== undefined && removedToken === addedToken) {
removed.push({ text: removedToken, changed: false });
added.push({ text: addedToken, changed: false });
removedIndex += 1;
addedIndex += 1;
} else if (
addedToken !== undefined &&
(removedToken === undefined || (lengths[removedIndex]?.[addedIndex + 1] ?? 0) >= (lengths[removedIndex + 1]?.[addedIndex] ?? 0))
) {
added.push({ text: addedToken, changed: true });
addedIndex += 1;
} else if (removedToken !== undefined) {
removed.push({ text: removedToken, changed: true });
removedIndex += 1;
}
}
return { added: mergeInlineParts(added), removed: mergeInlineParts(removed) };
}
function segmentWords(text: string): string[] {
const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
return Array.from(segmenter.segment(text), (segment) => segment.segment);
}
function lcsLengths(left: string[], right: string[]): number[][] {
const rows = Array.from({ length: left.length + 1 }, () => Array.from({ length: right.length + 1 }, () => 0));
for (let leftIndex = left.length - 1; leftIndex >= 0; leftIndex -= 1) {
for (let rightIndex = right.length - 1; rightIndex >= 0; rightIndex -= 1) {
const row = rows[leftIndex];
if (!row) continue;
row[rightIndex] =
left[leftIndex] === right[rightIndex]
? (rows[leftIndex + 1]?.[rightIndex + 1] ?? 0) + 1
: Math.max(rows[leftIndex + 1]?.[rightIndex] ?? 0, rows[leftIndex]?.[rightIndex + 1] ?? 0);
}
}
return rows;
}
function mergeInlineParts(parts: InlineDiffPart[]): InlineDiffPart[] {
const merged: InlineDiffPart[] = [];
for (const part of parts) {
const previous = merged.at(-1);
if (previous?.changed === part.changed) {
previous.text += part.text;
} else {
merged.push({ ...part });
}
}
return merged;
}

View file

@ -1883,6 +1883,19 @@
color: var(--codex-panel-text-muted);
}
.codex-panel-diff__word {
border-radius: var(--codex-panel-control-gap);
padding: 0 var(--codex-panel-panel-gap);
}
.codex-panel-diff__word--added {
background: color-mix(in srgb, var(--codex-panel-color-success) 20%, transparent);
}
.codex-panel-diff__word--removed {
background: color-mix(in srgb, var(--codex-panel-color-danger) 20%, transparent);
}
.codex-panel-chat-turn-diff {
display: flex;
flex-direction: column;

View file

@ -1051,6 +1051,86 @@ describe("chat turn diff view decisions", () => {
expect(copyDiff).toHaveBeenCalled();
});
it("highlights changed English words inside adjacent removed and added lines", () => {
const parent = document.createElement("div");
renderChatTurnDiffView(parent, {
threadId: "thread",
turnId: "turn",
cwd: "/vault/project",
files: ["Note.md"],
diff: "diff --git a/Note.md b/Note.md\n@@\n-The quick brown fox\n+The quick red fox",
});
expect(parent.textContent).toContain("The quick brown fox");
expect(parent.textContent).toContain("The quick red fox");
expect(parent.querySelector(".codex-panel-diff__word--removed")?.textContent).toBe("brown");
expect(parent.querySelector(".codex-panel-diff__word--added")?.textContent).toBe("red");
});
it("highlights changed Japanese words with Intl.Segmenter", () => {
const parent = document.createElement("div");
renderChatTurnDiffView(parent, {
threadId: "thread",
turnId: "turn",
cwd: "/vault/project",
files: ["Note.md"],
diff: "diff --git a/Note.md b/Note.md\n@@\n-吾輩は猫である\n+吾輩は犬である",
});
expect(parent.textContent).toContain("吾輩は猫である");
expect(parent.textContent).toContain("吾輩は犬である");
expect(parent.querySelector(".codex-panel-diff__word--removed")?.textContent).toBe("猫");
expect(parent.querySelector(".codex-panel-diff__word--added")?.textContent).toBe("犬");
});
it("pairs changed words by line inside multi-line replacement blocks", () => {
const parent = document.createElement("div");
renderChatTurnDiffView(parent, {
threadId: "thread",
turnId: "turn",
cwd: "/vault/project",
files: ["Note.md"],
diff: [
"diff --git a/Note.md b/Note.md",
"@@",
"-これはdiffのテストです。",
"-今日は元気です。",
"-とても元気です。",
"+これはdiffのてすとです。",
"+きょうはげんきです。",
"+とてもげんきです。",
].join("\n"),
});
const removedHighlights = Array.from(parent.querySelectorAll(".codex-panel-diff__word--removed"), (element) => element.textContent);
const addedHighlights = Array.from(parent.querySelectorAll(".codex-panel-diff__word--added"), (element) => element.textContent);
expect(removedHighlights).toEqual(["テスト", "今日", "元気", "元気"]);
expect(addedHighlights).toEqual(["てすと", "きょう", "げんき", "げんき"]);
expect(removedHighlights).not.toContain("これはdiffのテスト");
});
it("falls back to line-level rendering for large intraline candidates", () => {
const parent = document.createElement("div");
const oldText = `start ${"old ".repeat(600)}end`;
const newText = `start ${"new ".repeat(600)}end`;
renderChatTurnDiffView(parent, {
threadId: "thread",
turnId: "turn",
cwd: "/vault/project",
files: ["Note.md"],
diff: `diff --git a/Note.md b/Note.md\n@@\n-${oldText}\n+${newText}`,
});
expect(parent.textContent).toContain(oldText);
expect(parent.textContent).toContain(newText);
expect(parent.querySelector(".codex-panel-diff__word")).toBeNull();
});
it("keeps unified diff text out of persisted turn diff view state", () => {
const persisted = persistedChatTurnDiffViewState({
threadId: "thread",

View file

@ -81,6 +81,25 @@ describe("selection rewrite diff", () => {
expect(diff).toContain("+gamma");
});
it("orders full replacement blocks as removals before additions", () => {
const diff = buildSelectionUnifiedDiff(
"Note.md",
"これはdiffのテストです。\n今日は元気です。\nとても元気です。",
"これはdiffのてすとです。\nきょうはげんきです。\nとてもげんきです。",
);
expect(diff).toContain(
[
"-これはdiffのテストです。",
"-今日は元気です。",
"-とても元気です。",
"+これはdiffのてすとです。",
"+きょうはげんきです。",
"+とてもげんきです。",
].join("\n"),
);
});
it("renders additions and deletions", () => {
expect(buildSelectionUnifiedDiff("Note.md", "", "added")).toContain("+added");
expect(buildSelectionUnifiedDiff("Note.md", "removed", "")).toContain("-removed");