mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Add intraline diff highlighting
This commit is contained in:
parent
c9a6f87ddb
commit
8aa34cd982
8 changed files with 345 additions and 27 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
220
src/shared/diff/render.ts
Normal 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;
|
||||
}
|
||||
13
styles.css
13
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue