Render diffs with Preact components

This commit is contained in:
murashit 2026-06-25 11:32:56 +09:00
parent a23ccfbd82
commit 649dfba360
5 changed files with 57 additions and 79 deletions

View file

@ -1,7 +1,6 @@
import type { ComponentChild as UiNode } from "preact";
import { useLayoutEffect, useRef } from "preact/hooks";
import { renderRawDiffLines } from "../../../../shared/diff/render.dom";
import { RawDiffLines } from "../../../../shared/diff/render";
import type { DetailSection, DetailView } from "../../presentation/message-stream/detail-view";
import type { MessageStreamDisclosureState } from "./context";
@ -142,12 +141,5 @@ function OutputSection({ title, className, children }: { title: string; classNam
}
function DiffLines({ diff }: { diff: string }): UiNode {
const ref = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
element.replaceChildren();
renderRawDiffLines(element, diff);
}, [diff]);
return <div ref={ref} />;
return <RawDiffLines diff={diff} />;
}

View file

@ -6,7 +6,7 @@ import type {
MessageStreamViewBlock,
} from "../../presentation/message-stream/view-model";
import type { MessageStreamContext, PendingRequestBlockContext } from "./context";
import { detailNode } from "./detail.dom";
import { detailNode } from "./detail";
import { MessageStreamFlowFrame, type MessageStreamScrollControllerBinding } from "./flow-scroll.measure";
import { pendingRequestBlockNode } from "./pending-request-block";
import { agentRunSummaryNode, statusNode } from "./status";

View file

@ -1,7 +1,6 @@
import type { ComponentChild as UiNode } from "preact";
import { useLayoutEffect, useRef } from "preact/hooks";
import { renderDisplayDiffLines } from "../../../../shared/diff/render.dom";
import { DisplayDiffLines } from "../../../../shared/diff/render";
import { displayDiffLines } from "../../../../shared/diff/unified";
import { shortThreadId } from "../../../../shared/id/thread-id";
import { IconButton } from "../../../../shared/ui/components.obsidian";
@ -88,18 +87,7 @@ function ChangedFiles({ files }: { files: string[] }): UiNode {
}
function UnifiedDiff({ diff }: { diff: string }): UiNode {
const ref = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
element.replaceChildren();
renderUnifiedDiff(element, diff);
}, [diff]);
return <div ref={ref} />;
}
function renderUnifiedDiff(parent: HTMLElement, diff: string): HTMLElement {
return renderDisplayDiffLines(parent, displayDiffLines(diff), { className: "codex-panel-chat-turn-diff__diff" });
return <DisplayDiffLines lines={displayDiffLines(diff)} className="codex-panel-chat-turn-diff__diff" />;
}
function fileCountLabel(files: string[]): string {

View file

@ -1,8 +1,7 @@
import { type Editor, Notice } from "obsidian";
import type { TargetedKeyboardEvent, ComponentChild as UiNode } from "preact";
import { useLayoutEffect, useRef } from "preact/hooks";
import { renderDisplayDiffLines } from "../../shared/diff/render.dom";
import { DisplayDiffLines } from "../../shared/diff/render";
import { displayDiffLines } from "../../shared/diff/unified";
import { IconButton } from "../../shared/ui/components.obsidian";
import { isComposerSendKey, type SendShortcut } from "../../shared/ui/keyboard";
@ -415,20 +414,10 @@ function SelectionRewriteStatus({ status }: { status: SelectionRewriteSessionSta
}
function SelectionRewriteDiff({ diff }: { diff: string }): UiNode {
const ref = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
element.replaceChildren();
renderSelectionRewriteDiff(element, diff);
}, [diff]);
return <div ref={ref} />;
}
function renderSelectionRewriteDiff(parent: HTMLElement, diff: string): void {
renderDisplayDiffLines(
parent,
displayDiffLines(diff).filter((line) => line.kind !== "file" && !line.text.startsWith("@@")),
{ className: "codex-panel-selection-rewrite__diff-body" },
return (
<DisplayDiffLines
lines={displayDiffLines(diff).filter((line) => line.kind !== "file" && !line.text.startsWith("@@"))}
className="codex-panel-selection-rewrite__diff-body"
/>
);
}

View file

@ -1,3 +1,5 @@
import type { ComponentChild as UiNode } from "preact";
import { type DiffLineClass, type DisplayDiffLine, diffLineClass, diffLineClassFromText, displayDiffLineText } from "./unified";
const MAX_INLINE_DIFF_CHARS = 4000;
@ -18,60 +20,63 @@ interface RenderDiffLine {
className: DiffLineClass;
}
interface DiffLineView extends RenderDiffLine {
inlineParts: InlineDiffPart[] | null;
}
type ChangeLineClass = "added" | "removed";
export interface RenderDiffLinesOptions {
className?: string;
export function DisplayDiffLines({ lines, className }: { lines: readonly DisplayDiffLine[]; className?: string | undefined }): UiNode {
return <DiffLines lines={lines.map((line) => ({ text: line.text, className: diffLineClass(line) }))} className={className} />;
}
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 RawDiffLines({ diff, className }: { diff: string; className?: string | undefined }): UiNode {
return (
<DiffLines lines={diff.split("\n").map((line) => ({ text: line, className: diffLineClassFromText(line) }))} className={className} />
);
}
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 DiffLines({ lines, className }: { lines: readonly RenderDiffLine[]; className?: string | undefined }): UiNode {
const preClassName = ["codex-panel-diff", className].filter(Boolean).join(" ");
return (
<pre className={preClassName}>
{diffLineViews(lines).map((line, index) => (
<DiffLine key={`${String(index)}:${line.className}:${line.text}`} line={line} />
))}
</pre>
);
}
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 });
function diffLineViews(lines: readonly RenderDiffLine[]): DiffLineView[] {
const views: DiffLineView[] = [];
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);
views.push({ ...line, inlineParts: null });
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);
views.push(...changeRunViews(firstRun.lines, secondRun.lines));
index = secondRun.endIndex - 1;
continue;
}
for (const runLine of firstRun.lines) {
renderLine(pre, runLine);
views.push({ ...runLine, inlineParts: null });
}
index = firstRun.endIndex - 1;
}
return pre;
return views;
}
function collectChangeRun(
lines: RenderDiffLine[],
lines: readonly RenderDiffLine[],
startIndex: number,
className: ChangeLineClass,
): { lines: RenderDiffLine[]; endIndex: number } {
@ -85,18 +90,20 @@ function collectChangeRun(
return { lines: run, endIndex: index };
}
function renderChangeRuns(parent: HTMLElement, firstRun: RenderDiffLine[], secondRun: RenderDiffLine[]): void {
function changeRunViews(firstRun: RenderDiffLine[], secondRun: RenderDiffLine[]): DiffLineView[] {
const inlineDiffs = pairedInlineDiffs(firstRun, secondRun);
const views: DiffLineView[] = [];
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));
views.push({ ...line, inlineParts: 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));
views.push({ ...line, inlineParts: inlinePartsForLine(line.className, inlineDiffs[index] ?? null) });
}
return views;
}
function pairedInlineDiffs(firstRun: RenderDiffLine[], secondRun: RenderDiffLine[]): (InlineDiff | null)[] {
@ -135,19 +142,21 @@ 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: diffLineClassName(line.className) });
if (!inlineParts) {
lineEl.textContent = displayDiffLineText(line.text, line.className);
return;
}
for (const part of inlineParts) {
if (part.changed) {
lineEl.createSpan({ cls: diffWordClassName(line.className), text: part.text });
} else {
lineEl.createSpan({ text: part.text });
}
}
function DiffLine({ line }: { line: DiffLineView }): UiNode {
return (
<span className={diffLineClassName(line.className)}>
{line.inlineParts
? line.inlineParts.map((part, index) => (
<span
key={`${String(index)}:${part.changed ? "changed" : "same"}:${part.text}`}
className={part.changed ? diffWordClassName(line.className) : undefined}
>
{part.text}
</span>
))
: displayDiffLineText(line.text, line.className)}
</span>
);
}
function diffLineClassName(className: DiffLineClass): string {