From 3f2b9b7d9ca4c9666f8bad95c80ac5269fe58e3a Mon Sep 17 00:00:00 2001 From: murashit Date: Thu, 28 May 2026 07:39:38 +0900 Subject: [PATCH] Render turn diff view with React --- src/features/chat/chat-turn-diff-view.ts | 5 + src/features/chat/ui/turn-diff.ts | 99 ------------- src/features/chat/ui/turn-diff.tsx | 138 ++++++++++++++++++ tests/features/chat/ui/view-renderers.test.ts | 21 +++ tests/mocks/obsidian.ts | 6 + 5 files changed, 170 insertions(+), 99 deletions(-) delete mode 100644 src/features/chat/ui/turn-diff.ts create mode 100644 src/features/chat/ui/turn-diff.tsx diff --git a/src/features/chat/chat-turn-diff-view.ts b/src/features/chat/chat-turn-diff-view.ts index 8bfd2b9f..3144bfec 100644 --- a/src/features/chat/chat-turn-diff-view.ts +++ b/src/features/chat/chat-turn-diff-view.ts @@ -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 { + unmountReactRoot(this.contentEl); + } + setDiffPayload(payload: ChatTurnDiffViewState): void { this.metadata = persistedChatTurnDiffViewState(payload); this.payload = payload; diff --git a/src/features/chat/ui/turn-diff.ts b/src/features/chat/ui/turn-diff.ts deleted file mode 100644 index a6f361a5..00000000 --- a/src/features/chat/ui/turn-diff.ts +++ /dev/null @@ -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; - -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; - 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`; -} diff --git a/src/features/chat/ui/turn-diff.tsx b/src/features/chat/ui/turn-diff.tsx new file mode 100644 index 00000000..fde1f643 --- /dev/null +++ b/src/features/chat/ui/turn-diff.tsx @@ -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; + +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; + 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, ); +} + +function ChatTurnDiffView({ + state, + actions, + metadata, +}: { + state: ChatTurnDiffViewState | null; + actions: ChatTurnDiffViewActions; + metadata: PersistedChatTurnDiffViewState | null; +}): ReactNode { + if (!state) { + if (metadata) { + return ( + <> + +
Turn diff is no longer available.
+ + ); + } + return
No turn diff selected.
; + } + + return ( + <> + + {state.files.length > 0 ? : null} + + + ); +} + +function TurnDiffHeader({ + state, + copyDiff, +}: { + state: PersistedChatTurnDiffViewState; + copyDiff: ChatTurnDiffViewActions["copyDiff"] | null; +}): ReactNode { + return ( +
+
+
Turn diff
+
+ {shortThreadId(state.threadId)} / {shortThreadId(state.turnId)} · {fileCountLabel(state.files)} +
+
+ {copyDiff ? ( + + ) : null} +
+ ); +} + +function ChangedFiles({ files }: { files: string[] }): ReactNode { + return ( +
+ Changed files +
    + {files.map((file) => ( +
  • {file}
  • + ))} +
+
+ ); +} + +function UnifiedDiff({ diff }: { diff: string }): ReactNode { + const ref = useRef(null); + useLayoutEffect(() => { + const element = ref.current; + if (!element) return; + element.replaceChildren(); + renderUnifiedDiff(element, diff); + }, [diff]); + return
; +} + +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`; +} diff --git a/tests/features/chat/ui/view-renderers.test.ts b/tests/features/chat/ui/view-renderers.test.ts index bf6a08c9..7d324bb1 100644 --- a/tests/features/chat/ui/view-renderers.test.ts +++ b/tests/features/chat/ui/view-renderers.test.ts @@ -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( diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index a7494d96..d261dcff 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -157,12 +157,14 @@ export abstract class SuggestModal 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(element: Document, type: K, callback: (event: DocumentEventMap[K]) => void): void { @@ -180,6 +182,10 @@ export class ItemView { setState(_state: unknown, _result: unknown): Promise { return Promise.resolve(); } + + onClose(): Promise | void { + // Test mock placeholder. + } } export class MarkdownView {