Render turn diff view with React

This commit is contained in:
murashit 2026-05-28 07:39:38 +09:00
parent e22214b9d3
commit 3f2b9b7d9c
5 changed files with 170 additions and 99 deletions

View file

@ -2,6 +2,7 @@ import { ItemView, type ViewStateResult } from "obsidian";
import { VIEW_TYPE_CODEX_TURN_DIFF } from "../../constants";
import { copyTextWithNotice } from "../../shared/ui/clipboard";
import { unmountReactRoot } from "../../shared/ui/react-root";
import {
isPersistedChatTurnDiffViewState,
persistedChatTurnDiffViewState,
@ -48,6 +49,10 @@ export class CodexChatTurnDiffView extends ItemView {
this.render();
}
override async onClose(): Promise<void> {
unmountReactRoot(this.contentEl);
}
setDiffPayload(payload: ChatTurnDiffViewState): void {
this.metadata = persistedChatTurnDiffViewState(payload);
this.payload = payload;

View file

@ -1,99 +0,0 @@
import { renderDisplayDiffLines } from "../../../shared/diff/render";
import { displayDiffLines } from "../../../shared/diff/unified";
import { createIconButton } from "../../../shared/ui/components";
import { shortThreadId } from "../../../utils";
export { displayDiffLines } from "../../../shared/diff/unified";
export interface ChatTurnDiffViewState {
threadId: string;
turnId: string;
cwd: string | null;
files: string[];
diff: string;
}
export type PersistedChatTurnDiffViewState = Omit<ChatTurnDiffViewState, "diff">;
export interface ChatTurnDiffViewActions {
copyDiff?: () => void;
}
export function persistedChatTurnDiffViewState(state: ChatTurnDiffViewState): PersistedChatTurnDiffViewState {
return {
threadId: state.threadId,
turnId: state.turnId,
cwd: state.cwd,
files: [...state.files],
};
}
export function isPersistedChatTurnDiffViewState(value: unknown): value is PersistedChatTurnDiffViewState {
if (!value || typeof value !== "object") return false;
const record = value as Partial<PersistedChatTurnDiffViewState>;
return (
typeof record.threadId === "string" &&
typeof record.turnId === "string" &&
(typeof record.cwd === "string" || record.cwd === null) &&
Array.isArray(record.files) &&
record.files.every((file) => typeof file === "string")
);
}
export function renderChatTurnDiffView(
parent: HTMLElement,
state: ChatTurnDiffViewState | null,
actions: ChatTurnDiffViewActions = {},
metadata: PersistedChatTurnDiffViewState | null = null,
): void {
parent.empty();
parent.addClass("codex-panel-chat-turn-diff");
if (!state) {
if (metadata) {
renderTurnDiffHeader(parent, metadata, null);
parent.createDiv({ cls: "codex-panel-chat-turn-diff__empty", text: "Turn diff is no longer available." });
} else {
parent.createDiv({ cls: "codex-panel-chat-turn-diff__empty", text: "No turn diff selected." });
}
return;
}
renderTurnDiffHeader(parent, state, actions.copyDiff ?? null);
if (state.files.length > 0) {
const files = parent.createEl("details", { cls: "codex-panel-chat-turn-diff__files" });
files.createEl("summary", { text: "Changed files" });
const list = files.createEl("ul");
for (const file of state.files) {
list.createEl("li", { text: file });
}
}
renderUnifiedDiff(parent, state.diff);
}
function renderTurnDiffHeader(
parent: HTMLElement,
state: PersistedChatTurnDiffViewState,
copyDiff: ChatTurnDiffViewActions["copyDiff"] | null,
): void {
const header = parent.createDiv({ cls: "codex-panel-chat-turn-diff__header" });
const titleBlock = header.createDiv({ cls: "codex-panel-chat-turn-diff__title-block" });
titleBlock.createDiv({ cls: "codex-panel-chat-turn-diff__title", text: "Turn diff" });
titleBlock.createDiv({
cls: "codex-panel-chat-turn-diff__meta",
text: `${shortThreadId(state.threadId)} / ${shortThreadId(state.turnId)} · ${fileCountLabel(state.files)}`,
});
if (copyDiff) {
const copyButton = createIconButton(header, "copy", "Copy diff", "codex-panel-chat-turn-diff__copy");
copyButton.onclick = copyDiff;
}
}
export function renderUnifiedDiff(parent: HTMLElement, diff: string): HTMLElement {
return renderDisplayDiffLines(parent, displayDiffLines(diff), { className: "codex-panel-chat-turn-diff__diff" });
}
function fileCountLabel(files: string[]): string {
return files.length === 1 ? "Edited 1 file" : `Edited ${String(files.length)} files`;
}

View file

@ -0,0 +1,138 @@
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { renderDisplayDiffLines } from "../../../shared/diff/render";
import { displayDiffLines } from "../../../shared/diff/unified";
import { IconButton } from "../../../shared/ui/react-components";
import { renderReactRoot } from "../../../shared/ui/react-root";
import { shortThreadId } from "../../../utils";
export { displayDiffLines } from "../../../shared/diff/unified";
export interface ChatTurnDiffViewState {
threadId: string;
turnId: string;
cwd: string | null;
files: string[];
diff: string;
}
export type PersistedChatTurnDiffViewState = Omit<ChatTurnDiffViewState, "diff">;
export interface ChatTurnDiffViewActions {
copyDiff?: () => void;
}
export function persistedChatTurnDiffViewState(state: ChatTurnDiffViewState): PersistedChatTurnDiffViewState {
return {
threadId: state.threadId,
turnId: state.turnId,
cwd: state.cwd,
files: [...state.files],
};
}
export function isPersistedChatTurnDiffViewState(value: unknown): value is PersistedChatTurnDiffViewState {
if (!value || typeof value !== "object") return false;
const record = value as Partial<PersistedChatTurnDiffViewState>;
return (
typeof record.threadId === "string" &&
typeof record.turnId === "string" &&
(typeof record.cwd === "string" || record.cwd === null) &&
Array.isArray(record.files) &&
record.files.every((file) => typeof file === "string")
);
}
export function renderChatTurnDiffView(
parent: HTMLElement,
state: ChatTurnDiffViewState | null,
actions: ChatTurnDiffViewActions = {},
metadata: PersistedChatTurnDiffViewState | null = null,
): void {
parent.addClass("codex-panel-chat-turn-diff");
renderReactRoot(parent, <ChatTurnDiffView state={state} actions={actions} metadata={metadata} />);
}
function ChatTurnDiffView({
state,
actions,
metadata,
}: {
state: ChatTurnDiffViewState | null;
actions: ChatTurnDiffViewActions;
metadata: PersistedChatTurnDiffViewState | null;
}): ReactNode {
if (!state) {
if (metadata) {
return (
<>
<TurnDiffHeader state={metadata} copyDiff={null} />
<div className="codex-panel-chat-turn-diff__empty">Turn diff is no longer available.</div>
</>
);
}
return <div className="codex-panel-chat-turn-diff__empty">No turn diff selected.</div>;
}
return (
<>
<TurnDiffHeader state={state} copyDiff={actions.copyDiff ?? null} />
{state.files.length > 0 ? <ChangedFiles files={state.files} /> : null}
<UnifiedDiff diff={state.diff} />
</>
);
}
function TurnDiffHeader({
state,
copyDiff,
}: {
state: PersistedChatTurnDiffViewState;
copyDiff: ChatTurnDiffViewActions["copyDiff"] | null;
}): ReactNode {
return (
<div className="codex-panel-chat-turn-diff__header">
<div className="codex-panel-chat-turn-diff__title-block">
<div className="codex-panel-chat-turn-diff__title">Turn diff</div>
<div className="codex-panel-chat-turn-diff__meta">
{shortThreadId(state.threadId)} / {shortThreadId(state.turnId)} · {fileCountLabel(state.files)}
</div>
</div>
{copyDiff ? (
<IconButton icon="copy" label="Copy diff" className="clickable-icon codex-panel-chat-turn-diff__copy" onClick={copyDiff} />
) : null}
</div>
);
}
function ChangedFiles({ files }: { files: string[] }): ReactNode {
return (
<details className="codex-panel-chat-turn-diff__files">
<summary>Changed files</summary>
<ul>
{files.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
</details>
);
}
function UnifiedDiff({ diff }: { diff: string }): ReactNode {
const ref = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
element.replaceChildren();
renderUnifiedDiff(element, diff);
}, [diff]);
return <div ref={ref} />;
}
export function renderUnifiedDiff(parent: HTMLElement, diff: string): HTMLElement {
return renderDisplayDiffLines(parent, displayDiffLines(diff), { className: "codex-panel-chat-turn-diff__diff" });
}
function fileCountLabel(files: string[]): string {
return files.length === 1 ? "Edited 1 file" : `Edited ${String(files.length)} files`;
}

View file

@ -2,6 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import { act, createElement, type ReactNode } from "react";
import type { WorkspaceLeaf } from "obsidian";
import type { PendingApproval } from "../../../../src/features/chat/approvals/model";
import type { PendingUserInput } from "../../../../src/features/chat/user-input/model";
@ -13,6 +14,7 @@ import {
} from "../../../../src/features/chat/ui/composer";
import { pendingRequestMessageNode } from "../../../../src/features/chat/ui/pending-request-message";
import { renderToolbar, type ToolbarViewModel } from "../../../../src/features/chat/ui/toolbar";
import { CodexChatTurnDiffView } from "../../../../src/features/chat/chat-turn-diff-view";
import { displayItemSignature } from "../../../../src/features/chat/display/signature";
import type { DisplayItem } from "../../../../src/features/chat/display/types";
import { implementPlanCandidateFromState } from "../../../../src/features/chat/chat-message-renderer";
@ -1466,6 +1468,25 @@ describe("chat turn diff view decisions", () => {
expect(parent.querySelector(".codex-panel-chat-turn-diff__diff")).toBeNull();
});
it("unmounts the turn diff React root when the view closes", async () => {
const containerEl = document.createElement("div");
const view = new CodexChatTurnDiffView({ containerEl } as unknown as WorkspaceLeaf);
view.setDiffPayload({
threadId: "thread",
turnId: "turn",
cwd: "/vault/project",
files: ["src/main.ts"],
diff: "diff --git a/src/main.ts b/src/main.ts\n@@\n-old\n+new",
});
expect(view.contentEl.querySelector(".codex-panel-chat-turn-diff__title")?.textContent).toBe("Turn diff");
await view.onClose();
expect(view.contentEl.childElementCount).toBe(0);
});
it("simplifies git diff file headers for turn diff display", () => {
expect(
displayDiffLines(

View file

@ -157,12 +157,14 @@ export abstract class SuggestModal<T> extends Modal {
export class ItemView {
readonly app: App;
readonly contentEl: HTMLElement;
readonly containerEl: HTMLElement;
constructor(readonly leaf: { app?: App; containerEl?: HTMLElement }) {
ensureElementHelpers();
this.app = leaf.app ?? {};
this.containerEl = leaf.containerEl ?? document.createElement("div");
this.contentEl = this.containerEl.createDiv();
}
registerDomEvent<K extends keyof DocumentEventMap>(element: Document, type: K, callback: (event: DocumentEventMap[K]) => void): void {
@ -180,6 +182,10 @@ export class ItemView {
setState(_state: unknown, _result: unknown): Promise<void> {
return Promise.resolve();
}
onClose(): Promise<void> | void {
// Test mock placeholder.
}
}
export class MarkdownView {