1import type { CodeViewScrollTarget } from "@pierre/diffs";
2import type { FileTree as FileTreeModel } from "@pierre/trees";
3import { FileTree } from "@pierre/trees/react";
4import { act, type MouseEvent, type ReactNode } from "react";
5import { create, type ReactTestRenderer } from "react-test-renderer";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8import type { FileDiffMetadata } from "@pierre/diffs";
9
10import { DiffFileTree, type DiffFileTreeEntry } from "./DiffFileTree";
11import { diffFileTreeEntries } from "./diffFileTree.logic";
12import { useCodeViewFileReveal } from "./useCodeViewFileReveal";
13
14vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
15// Tooltip positioning is unrelated to the tree's actual model and activation path.
16vi.mock("../ui/tooltip", () => ({
17 Tooltip: ({ children }: { children: ReactNode }) => children,
18 TooltipTrigger: ({ render }: { render: ReactNode }) => render,
19 TooltipPopup: () => null,
20}));
21
22const entries: DiffFileTreeEntry[] = [
23 { path: "01-tall.ts", status: "modified" },
24 { path: "02-short.ts", status: "modified" },
25 { path: "03-medium.ts", status: "modified" },
26];
27
28class TreeRow {
29 constructor(readonly path: string) {}
30
31 getAttribute(name: string) {
32 return name === "data-item-path" ? this.path : null;
33 }
34}
35
36describe("diff tree file activation", () => {
37 let renderer: ReactTestRenderer | undefined;
38 const targets: CodeViewScrollTarget[] = [];
39 const viewer = {
40 getInstance: () => viewer,
41 scrollTo: (target: CodeViewScrollTarget) => targets.push(target),
42 };
43
44 function Panel({
45 files = entries,
46 selectedPath = null,
47 }: {
48 files?: DiffFileTreeEntry[];
49 selectedPath?: string | null;
50 }) {
51 const reveal = useCodeViewFileReveal(viewer, "working-tree");
52 return (
53 <DiffFileTree
54 entries={files}
55 ariaLabel="Working tree files"
56 selectedPath={selectedPath}
57 onSelectFile={(path) => reveal(`${path}\0${path}`)}
58 />
59 );
60 }
61
62 const model = (): FileTreeModel => renderer!.root.findByType(FileTree).props.model;
63
64 async function mount(props: Parameters<typeof Panel>[0] = {}) {
65 await act(async () => {
66 renderer = create(<Panel {...props} />);
67 });
68 }
69
70 // Exercise T3's capture handler before the real Pierre model's selection transition.
71 // Only DOM hit testing is represented here; native pointer/keyboard dispatch and diff
72 // geometry are verified separately in the integrated client.
73 async function activate(path: string, modifiers: Partial<MouseEvent<HTMLElement>> = {}) {
74 const event = {
75 button: 0,
76 ctrlKey: false,
77 metaKey: false,
78 shiftKey: false,
79 altKey: false,
80 defaultPrevented: false,
81 nativeEvent: { composedPath: () => [{}, new TreeRow(path), {}] },
82 ...modifiers,
83 } as MouseEvent<HTMLElement>;
84 await act(async () => {
85 renderer!.root
86 .find((node) => String(node.type) === "file-tree-container")
87 .props.onClickCapture?.(event);
88 const tree = model();
89 const item = tree.getItem(path)!;
90 if (event.ctrlKey || event.metaKey) {
91 item.toggleSelect();
92 } else {
93 for (const selected of tree.getSelectedPaths()) {
94 if (selected !== path) tree.getItem(selected)?.deselect();
95 }
96 item.select();
97 }
98 item.focus();
99 if ("toggle" in item && !event.ctrlKey && !event.metaKey && !event.shiftKey) item.toggle();
100 });
101 }
102
103 beforeEach(() => {
104 targets.length = 0;
105 vi.useFakeTimers();
106 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
107 vi.stubGlobal("HTMLElement", TreeRow);
108 });
109
110 afterEach(async () => {
111 await act(async () => renderer?.unmount());
112 renderer = undefined;
113 vi.runOnlyPendingTimers();
114 vi.useRealTimers();
115 vi.unstubAllGlobals();
116 });
117
118 it("mounts a file-to-symlink type change as one tree row", async () => {
119 // Git carries a type change as a deletion and an addition of the same path.
120 const typeChange = diffFileTreeEntries([
121 { type: "deleted", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata,
122 { type: "new", name: "AGENTS.md", prevName: "AGENTS.md" } as FileDiffMetadata,
123 { type: "change", name: "CLAUDE.md", prevName: "CLAUDE.md" } as FileDiffMetadata,
124 ]);
125 await mount({ files: [...typeChange] });
126
127 const tree = model();
128 expect(tree.getItem("AGENTS.md")?.isDirectory()).toBe(false);
129 expect(tree.getItem("CLAUDE.md")?.isDirectory()).toBe(false);
130 await activate("AGENTS.md");
131 expect(targets).toEqual([{ type: "item", id: "AGENTS.md\u0000AGENTS.md", align: "start" }]);
132 });
133
134 it("reissues the reveal when the sole selected file is activated again", async () => {
135 await mount();
136 await activate("02-short.ts");
137 expect(model().getSelectedPaths()).toEqual(["02-short.ts"]);
138 await activate("02-short.ts");
139 expect(targets).toEqual([
140 { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" },
141 { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" },
142 ]);
143 });
144
145 it("reveals newly selected files once in either direction", async () => {
146 await mount();
147 await activate("02-short.ts");
148 await activate("01-tall.ts");
149 await activate("03-medium.ts");
150 expect(targets.map((target) => ("id" in target ? target.id : null))).toEqual(
151 ["02-short.ts", "01-tall.ts", "03-medium.ts"].map((path) => `${path}\0${path}`),
152 );
153 });
154
155 it("keeps focus-only navigation separate from button activation", async () => {
156 await mount();
157 await activate("02-short.ts");
158 await act(async () => model().getItem("01-tall.ts")!.focus());
159 expect(model().getSelectedPaths()).toEqual(["02-short.ts"]);
160 expect(targets).toHaveLength(1);
161 await activate("01-tall.ts", { detail: 0 });
162 await activate("01-tall.ts", { detail: 0 });
163 expect(targets).toHaveLength(3);
164 });
165
166 it.each(["ctrlKey", "metaKey"] as const)(
167 "does not reveal a selected file that a %s click deselects",
168 async (modifier) => {
169 await mount();
170 await activate("02-short.ts");
171 await activate("02-short.ts", { [modifier]: true });
172 expect(model().getSelectedPaths()).toEqual([]);
173 expect(targets).toHaveLength(1);
174 },
175 );
176
177 it("lets a click narrow multiple selected files without a second reveal", async () => {
178 await mount();
179 await activate("02-short.ts");
180 await act(async () => model().getItem("01-tall.ts")!.select());
181 expect(model().getSelectedPaths()).toHaveLength(2);
182 targets.length = 0;
183 await activate("02-short.ts");
184 expect(model().getSelectedPaths()).toEqual(["02-short.ts"]);
185 expect(targets).toHaveLength(1);
186 });
187
188 it("leaves directory selection and expansion to the tree", async () => {
189 await mount({ files: [{ path: "src/app.ts", status: "modified" }] });
190 const directory = model().getItem("src/")!;
191 if (!("isExpanded" in directory)) throw new Error("Expected the directory handle");
192 expect(directory.isExpanded()).toBe(true);
193 await activate("src/");
194 expect(directory.isExpanded()).toBe(false);
195 await activate("src/");
196 expect(directory.isExpanded()).toBe(true);
197 expect(targets).toEqual([]);
198 });
199
200 it("reorders refreshed files without reopening a collapsed folder", async () => {
201 const files: DiffFileTreeEntry[] = [
202 { path: "src/state/shell.ts", status: "modified" },
203 { path: "src/features/route.ts", status: "added" },
204 ];
205 await mount({ files });
206 const initialFolder = model().getItem("src/features/")!;
207 if (!("collapse" in initialFolder)) throw new Error("Expected the directory handle");
208 await act(async () => initialFolder.collapse());
209 await act(async () => {
210 renderer!.update(<Panel files={[files[1]!, files[0]!]} />);
211 });
212 const folder = model().getItem("src/features/")!;
213 if (!("isExpanded" in folder)) throw new Error("Expected the directory handle");
214 expect(folder.isExpanded()).toBe(false);
215 });
216
217 it("does not echo controlled selection, but lets the reader activate it", async () => {
218 await mount({ selectedPath: "02-short.ts" });
219 expect(model().getSelectedPaths()).toEqual(["02-short.ts"]);
220 expect(targets).toEqual([]);
221 await activate("02-short.ts");
222 expect(targets).toHaveLength(1);
223 await act(async () => {
224 renderer!.update(<Panel selectedPath="03-medium.ts" />);
225 });
226 expect(model().getSelectedPaths()).toEqual(["03-medium.ts"]);
227 expect(targets).toHaveLength(1);
228 });
229});