1import { RegistryContext } from "@effect/atom-react";
2import { scopedThreadKey } from "@t3tools/client-runtime/environment";
3import {
4 DEFAULT_CLIENT_SETTINGS,
5 EnvironmentId,
6 ProjectId,
7 RunId,
8 ThreadId,
9 type ClientSettings,
10 type ReviewBranchCommitsResult,
11 type ReviewDiffPreviewResult,
12 type ReviewDiffPreviewSource,
13 type ScopedThreadRef,
14 type VcsListRefsResult,
15 type VcsStatusResult,
16} from "@t3tools/contracts";
17import * as DateTime from "effect/DateTime";
18import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
19import { expect, it, vi } from "vite-plus/test";
20import { page, userEvent } from "vite-plus/test/browser";
21
22import "~/index.css";
23import { act, render } from "../../test/render";
24import { makeThreadFixture } from "~/test-fixtures";
25
26type ResultAtom<A> = Atom.Atom<AsyncResult.AsyncResult<A, Error>>;
27
28const state = vi.hoisted(() => ({
29 thread: null as ReturnType<typeof makeThreadFixture> | null,
30 threadRef: null as ScopedThreadRef | null,
31 previews: new Map<string | null, ResultAtom<ReviewDiffPreviewResult>>(),
32 patch: null as unknown as ResultAtom<ReviewDiffPreviewSource>,
33 diffFilesCollapsed: false,
34 config: null as unknown as Atom.Atom<null>,
35 status: null as unknown as ResultAtom<VcsStatusResult>,
36 localRefs: null as unknown as ResultAtom<VcsListRefsResult>,
37 remoteRefs: null as unknown as ResultAtom<VcsListRefsResult>,
38 commits: null as unknown as ResultAtom<ReviewBranchCommitsResult>,
39 checkpoints: [] as unknown[],
40 checkpointDiff: null as { diff: string } | null,
41}));
42
43vi.mock("~/state/server", () => ({
44 serverEnvironment: { configValueAtom: () => state.config },
45}));
46vi.mock("~/state/review", () => ({
47 reviewEnvironment: {
48 diffPreview: ({ input }: { input: { baseRef?: string } }) =>
49 state.previews.get(input.baseRef ?? null)!,
50 branchCommits: () => state.commits,
51 diffFilePatch: () => state.patch,
52 diffFileContents: {},
53 },
54}));
55vi.mock("~/state/vcs", () => ({
56 vcsEnvironment: {
57 status: () => state.status,
58 listRefs: ({ input }: { input: { refKind: "local" | "remote" } }) =>
59 input.refKind === "local" ? state.localRefs : state.remoteRefs,
60 },
61}));
62vi.mock("~/state/entities", () => ({
63 useQuietThreadShell: () => state.thread,
64 useProject: () => ({ workspaceRoot: "/project", repositoryIdentity: null }),
65 useThreadCheckpointSummaries: () => state.checkpoints,
66 useQuietThreadVisibleTurnItems: () => [],
67}));
68vi.mock("~/state/filesystem", () => ({
69 useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }),
70}));
71vi.mock("~/hooks/useThreadRouteTarget", () => ({ useThreadRouteRef: () => state.threadRef }));
72vi.mock("~/hooks/useSettings", () => ({
73 useClientSettings: (select: (settings: ClientSettings) => unknown) =>
74 select({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: state.diffFilesCollapsed }),
75 updateClientSettings: async () => {},
76}));
77vi.mock("~/hooks/useTheme", () => ({
78 useRenderedAppearance: () => "dark",
79 getRenderedAppearance: () => "dark",
80 subscribeRenderedAppearance: () => () => {},
81}));
82vi.mock("~/state/queries", () => ({
83 useCheckpointDiff: () => ({ data: state.checkpointDiff, error: null, isPending: false }),
84 useTurnItemDetail: () => ({ data: null, error: null, isPending: false }),
85 useDebouncedValue: <A,>(value: A) => value,
86}));
87vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => {} }));
88vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => async () => {} }));
89vi.mock("~/fileContextMenu", () => ({ useFileContextMenuHandler: () => () => {} }));
90vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => async () => {} }));
91vi.mock("./diffs/DiffCommentAnnotation", () => ({ DiffCommentAnnotation: () => null }));
92vi.mock("./diffs/DiffFileTree", () => ({ DiffFileTree: () => null }));
93vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null }));
94
95import DiffPanel from "./DiffPanel";
96import { useDiffPanelSessionStore, useDiffPanelStore } from "~/diffPanelStore";
97
98const cwd = "/project-worktree";
99const errorMessage = "Could not find a common commit between 'origin/orphan' and HEAD.";
100
101function preview(): ReviewDiffPreviewResult {
102 return {
103 cwd,
104 generatedAt: DateTime.makeUnsafe("2026-10-30T00:00:00Z"),
105 sources: [
106 {
107 id: "branch",
108 kind: "branch-range",
109 title: "Changes",
110 baseRef: "main",
111 headRef: "dev",
112 diff: "",
113 diffHash: "empty",
114 truncated: false,
115 patchesDeferred: true,
116 files: [],
117 },
118 ],
119 };
120}
121
122function setup(failedBaseRef: string | null) {
123 const reset = () => {
124 useDiffPanelStore.setState({
125 byThreadKey: {},
126 branchBaseRefByThreadKey: {},
127 });
128 useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} });
129 };
130 reset();
131 state.diffFilesCollapsed = false;
132 state.checkpoints = [];
133 state.checkpointDiff = null;
134 const patch = Atom.make<AsyncResult.AsyncResult<ReviewDiffPreviewSource, Error>>(
135 AsyncResult.initial(),
136 );
137 state.patch = patch;
138 state.threadRef = {
139 environmentId: EnvironmentId.make("environment"),
140 threadId: ThreadId.make("thread"),
141 };
142 state.thread = makeThreadFixture({
143 ...state.threadRef,
144 id: state.threadRef.threadId,
145 projectId: ProjectId.make("project"),
146 branch: "dev",
147 worktreePath: cwd,
148 });
149 state.previews.clear();
150 state.previews.set(null, Atom.make(AsyncResult.success(preview())));
151 state.previews.set("main", Atom.make(AsyncResult.success(preview())));
152 state.previews.set(failedBaseRef, Atom.make(AsyncResult.fail(new Error(errorMessage))));
153 state.config = Atom.make(null);
154 state.status = Atom.make(
155 AsyncResult.success({
156 isRepo: true,
157 hasPrimaryRemote: true,
158 isDefaultRef: false,
159 refName: "dev",
160 hasWorkingTreeChanges: false,
161 workingTree: { rootPath: cwd, files: [], insertions: 0, deletions: 0 },
162 hasUpstream: true,
163 aheadCount: 0,
164 behindCount: 0,
165 pr: null,
166 }),
167 );
168 const refs = (names: string[], isRemote: boolean): VcsListRefsResult => ({
169 isRepo: true,
170 hasPrimaryRemote: true,
171 nextCursor: null,
172 totalCount: names.length,
173 refs: names.map((name) => ({
174 name,
175 isRemote,
176 ...(isRemote ? { remoteName: "origin" } : {}),
177 current: name === "dev",
178 isDefault: name === "main",
179 worktreePath: null,
180 })),
181 });
182 state.localRefs = Atom.make(AsyncResult.success(refs(["dev", "main"], false)));
183 state.remoteRefs = Atom.make(AsyncResult.success(refs(["origin/orphan"], true)));
184 state.commits = Atom.make(AsyncResult.success({ commits: [], truncated: false }));
185 useDiffPanelStore.getState().selectBranchBaseRef(state.threadRef, failedBaseRef);
186 const registry = AtomRegistry.make();
187 return {
188 registry,
189 patch,
190 threadRef: state.threadRef,
191 [Symbol.dispose]() {
192 registry.dispose();
193 reset();
194 },
195 };
196}
197
198it.each([
199 {
200 name: "a saved unrelated branch",
201 failedBaseRef: "origin/orphan",
202 recovery: "Automatic",
203 baseRef: null,
204 },
205 {
206 name: "an automatic base with unrelated history",
207 failedBaseRef: null,
208 recovery: "main",
209 baseRef: "main",
210 },
211])(
212 "recovers from $name without leaving the diff panel",
213 async ({ failedBaseRef, recovery, baseRef }) => {
214 using context = setup(failedBaseRef);
215 await using screen = await render(
216 <RegistryContext.Provider value={context.registry}>
217 <DiffPanel composerDraftTarget={context.threadRef} workspaceMutationId={null} visible />
218 </RegistryContext.Provider>,
219 );
220 await expect.element(screen.getByText(errorMessage, { exact: true })).toBeVisible();
221 const selector = screen.getByRole("combobox", { name: /Change comparison target/ });
222 await vi.waitFor(() => expect(selector.query()).not.toBeNull());
223 await userEvent.click(selector);
224 await expect.element(page.getByRole("option", { name: "main", exact: true })).toBeVisible();
225 await userEvent.click(page.getByRole("option", { name: recovery, exact: true }));
226 await expect.element(screen.getByText("No net changes in this selection.")).toBeVisible();
227 await expect.element(screen.getByText(errorMessage, { exact: true })).not.toBeInTheDocument();
228 const key = scopedThreadKey(context.threadRef);
229 await vi.waitFor(() => {
230 expect(useDiffPanelStore.getState().byThreadKey[key]).toEqual({ kind: "branch", baseRef });
231 expect(useDiffPanelStore.getState().branchBaseRefByThreadKey[key]).toBe(baseRef);
232 });
233 },
234);
235
236it.each([false, true])(
237 "expands an unloaded file on the first click with default collapse %s",
238 async (collapsed) => {
239 using context = setup("origin/orphan");
240 state.diffFilesCollapsed = collapsed;
241 const path = `pending-${collapsed}.txt`;
242 const metadata = preview();
243 const source = {
244 ...metadata.sources[0]!,
245 files: [{ path, previousPath: null, additions: 1, deletions: 1 }],
246 };
247 state.previews.set("main", Atom.make(AsyncResult.success({ ...metadata, sources: [source] })));
248 useDiffPanelStore.getState().selectBranchBaseRef(context.threadRef, "main");
249 await using screen = await render(
250 <RegistryContext.Provider value={context.registry}>
251 <div style={{ height: 600, display: "flex", flexDirection: "column" }}>
252 <DiffPanel composerDraftTarget={context.threadRef} workspaceMutationId={null} visible />
253 </div>
254 </RegistryContext.Provider>,
255 );
256 const toggle = screen.getByRole("button", { name: `Expand ${path}`, exact: true });
257 await expect.element(toggle).toHaveAttribute("aria-expanded", "false");
258 await userEvent.click(toggle);
259 await act(() =>
260 context.registry.set(
261 context.patch,
262 AsyncResult.success({
263 ...source,
264 diff: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-before\n+after\n`,
265 }),
266 ),
267 );
268 const loadedToggle = screen.getByRole("button", { name: `Collapse ${path}`, exact: true });
269 await expect.element(loadedToggle).toHaveAttribute("aria-expanded", "true");
270 await expect.element(screen.getByText("after", { exact: true })).toBeVisible();
271 await userEvent.click(loadedToggle);
272 await expect.element(toggle).toHaveAttribute("aria-expanded", "false");
273 },
274);
275
276it("says a turn diff cut off at the server's size limit is incomplete", async () => {
277 using context = setup("origin/orphan");
278 state.checkpoints = [
279 {
280 runId: "run",
281 checkpointTurnCount: 1,
282 checkpointRef: "ref",
283 status: "ready",
284 files: [],
285 assistantMessageId: null,
286 completedAt: "2026-01-01T00:00:00Z",
287 },
288 ];
289 state.checkpointDiff = {
290 diff: "diff --git a/first.txt b/first.txt\n--- a/first.txt\n+++ b/first.txt\n@@ -0,0 +1 @@\n+visible\n\n[truncated]",
291 };
292 useDiffPanelStore.getState().selectTurn(context.threadRef, RunId.make("run"));
293 await using screen = await render(
294 <RegistryContext.Provider value={context.registry}>
295 <div style={{ height: 600, display: "flex", flexDirection: "column" }}>
296 <DiffPanel composerDraftTarget={context.threadRef} workspaceMutationId={null} visible />
297 </div>
298 </RegistryContext.Provider>,
299 );
300 await expect.element(screen.getByText("first.txt", { exact: true }).first()).toBeVisible();
301 await expect
302 .element(screen.getByText("This preview exceeds the size limit. Changes shown are incomplete."))
303 .toBeVisible();
304});