1import type { CodeViewDiffItem, FileDiffMetadata, SelectedLineRange } from "@pierre/diffs";
2import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
3import { StrictMode, type ComponentProps } from "react";
4import { describe, expect, it, vi } from "vite-plus/test";
5
6import { act, render } from "../../../test/render";
7
8import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
9import { buildFileDiffIdentityKey } from "~/lib/diffRendering";
10
11type ViewerProps = Omit<ComponentProps<typeof StyledDiffCodeView<unknown>>, "options"> & {
12 options: {
13 enableLineSelection: boolean;
14 enableGutterUtility: boolean;
15 onGutterUtilityClick: (
16 range: SelectedLineRange,
17 context: { item: CodeViewDiffItem<unknown> },
18 ) => void;
19 };
20};
21
22// Replace only the virtualized viewer; comment editing and submission use the real components.
23vi.mock("./StyledDiffCodeView", () => ({
24 StyledDiffCodeView: (props: ViewerProps) =>
25 props.items
26 ?.filter((item) => item.type === "diff")
27 .map((item) => (
28 <section key={item.id}>
29 <button
30 disabled={!props.options?.enableLineSelection || !props.options?.enableGutterUtility}
31 onClick={() => {
32 props.onSelectedLinesChange?.({ id: item.id, range });
33 props.options?.onGutterUtilityClick?.(range, { item });
34 }}
35 >
36 Comment on {item.fileDiff.name}
37 </button>
38 {props.selectedLines?.id === item.id ? (
39 <mark>Line {props.selectedLines.range.start}</mark>
40 ) : null}
41 {item.annotations?.map((annotation) => (
42 <div key={`${annotation.side}:${annotation.lineNumber}`}>
43 {props.renderAnnotation?.(annotation, item.fileDiff, item)}
44 </div>
45 ))}
46 </section>
47 )),
48}));
49
50import { AnnotatableCodeView } from "./AnnotatableCodeView";
51import type { StyledDiffCodeView } from "./StyledDiffCodeView";
52
53const target = DraftId.make("diff-draft-test");
54const range: SelectedLineRange = { start: 1, end: 1, side: "additions" };
55let container: HTMLDivElement;
56
57function file(
58 name: string,
59 fileVersion = 1,
60 type: FileDiffMetadata["type"] = "change",
61 prevName?: string,
62) {
63 const fileDiff = parsePatchFiles(
64 `diff --git a/${name} b/${name}\n--- a/${name}\n+++ b/${name}\n@@ -1 +1 @@\n-old\n+new\n`,
65 { cacheKeyPrefix: name },
66 )[0]!.files[0]!;
67 fileDiff.type = type;
68 if (prevName) fileDiff.prevName = prevName;
69 return {
70 fileDiff,
71 filePath: name,
72 fileKey: buildFileDiffIdentityKey(fileDiff),
73 fileVersion,
74 collapsed: false,
75 };
76}
77
78function view(
79 files: ReturnType<typeof file>[],
80 codeViewKey = "initial",
81 pendingFiles: FileDiffMetadata[] = [],
82) {
83 return (
84 <StrictMode>
85 <AnnotatableCodeView
86 codeViewKey={codeViewKey}
87 files={files}
88 pendingFiles={pendingFiles}
89 sectionId="unstaged"
90 sectionTitle="Uncommitted"
91 composerDraftTarget={target}
92 options={{}}
93 renderHeaderFilenameSuffix={() => null}
94 renderHeaderPrefix={() => null}
95 onRevealSearchMatch={() => {}}
96 />
97 </StrictMode>
98 );
99}
100
101function click(label: string) {
102 const button = Array.from(container.querySelectorAll("button")).find(
103 (node) => node.textContent === label,
104 );
105 if (!button) throw new Error(`Missing button: ${label}`);
106 return act(() => button.click());
107}
108
109function editor() {
110 const textarea = container.querySelector("textarea");
111 if (!textarea) throw new Error("Comment editor is not available");
112 return textarea;
113}
114
115function typeComment(text: string) {
116 return act(() => {
117 Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(
118 editor(),
119 text,
120 );
121 editor().dispatchEvent(new Event("input", { bubbles: true }));
122 });
123}
124
125function setup(): Disposable {
126 const reset = () => useComposerDraftStore.setState(useComposerDraftStore.getInitialState(), true);
127 reset();
128 container = document.createElement("div");
129 return { [Symbol.dispose]: reset };
130}
131
132describe("diff comment drafts", () => {
133 it("keeps the text and selected line through refresh and a provisional lazy patch", async () => {
134 using _drafts = setup();
135 const original = file("shared.ts", 1, "rename-pure", "old.ts");
136 await using screen = await render(view([original]), { container });
137 await click("Comment on shared.ts");
138 await typeComment("Keep this draft");
139 const refreshed = file("shared.ts", 2, "rename-pure", "old.ts");
140 await screen.rerender(view([refreshed], "refreshed"));
141 expect(editor().value).toBe("Keep this draft");
142 expect(container.querySelector("mark")?.textContent).toBe("Line 1");
143
144 await screen.rerender(
145 view([], "loading", [
146 { ...refreshed.fileDiff, type: "rename-changed", hunks: [], cacheKey: "shared:pending" },
147 ]),
148 );
149 expect(container.querySelector("textarea")).toBeNull();
150 await screen.rerender(view([refreshed], "loaded"));
151 expect(editor().value).toBe("Keep this draft");
152 expect(container.querySelector("mark")?.textContent).toBe("Line 1");
153 await click("Comment");
154 expect(useComposerDraftStore.getState().getComposerDraft(target)?.reviewComments).toEqual([
155 expect.objectContaining({
156 filePath: "shared.ts",
157 text: "Keep this draft",
158 selection: { ...range, endSide: "additions" },
159 }),
160 ]);
161 expect(container.querySelector("p")?.textContent).toBe("Keep this draft");
162 });
163});