Open History to see the last commit
1import type { CodeViewScrollTarget } from "@pierre/diffs";
2import { describe, expect, it } from "vite-plus/test";
3
4import { createCodeViewFileReveal } from "./useCodeViewFileReveal";
5
6function viewer(initialItems: ReadonlyArray<string>) {
7 const items = new Set(initialItems);
8 const scrolls: CodeViewScrollTarget[] = [];
9 const collapsed = new Set<string>();
10 return {
11 items,
12 collapsed,
13 scrolls,
14 getInstance: () => ({}),
15 getItem: (id: string) => (items.has(id) ? { collapsed: collapsed.has(id) } : undefined),
16 scrollTo: (target: CodeViewScrollTarget) => scrolls.push(target),
17 };
18}
19
20describe("createCodeViewFileReveal", () => {
21 it("scrolls at once to a file the viewer holds", () => {
22 const reveal = createCodeViewFileReveal();
23 const view = viewer(["a"]);
24 reveal.attach(view);
25
26 reveal.request("a");
27
28 expect(view.scrolls).toEqual([{ type: "item", id: "a", align: "start" }]);
29 const range = { start: 12, side: "deletions", end: 15, endSide: "additions" } as const;
30 view.collapsed.add("a");
31 reveal.request("a", range);
32 expect(view.scrolls).toHaveLength(1);
33 view.collapsed.delete("a");
34 reveal.flush();
35 expect(view.scrolls.at(-1)).toEqual({ type: "range", id: "a", range, align: "center" });
36 });
37
38 it("keeps a missing file pending until a flush finds it, then drops it", () => {
39 const reveal = createCodeViewFileReveal();
40 const view = viewer([]);
41 reveal.attach(view);
42
43 reveal.request("a");
44 reveal.flush();
45 expect(view.scrolls).toEqual([]);
46
47 view.items.add("a");
48 reveal.flush();
49 reveal.flush();
50 expect(view.scrolls).toHaveLength(1);
51 });
52
53 it("applies a reveal asked before the viewer mounted once it holds the file", () => {
54 const reveal = createCodeViewFileReveal();
55 const view = viewer([]);
56
57 reveal.request("a");
58 reveal.attach(view);
59 expect(view.scrolls).toEqual([]);
60
61 view.items.add("a");
62 reveal.flush();
63 expect(view.scrolls).toEqual([{ type: "item", id: "a", align: "start" }]);
64 });
65
66 it("renders the destination file and expands context before positioning its range", () => {
67 const reveal = createCodeViewFileReveal();
68 const view = viewer(["a"]);
69 let rendered = false;
70 let expanded = false;
71 const instance = {
72 revealLine: () => {
73 if (expanded) return false;
74 expanded = true;
75 return true;
76 },
77 getLinePosition: () => (expanded ? { top: 100, height: 20 } : undefined),
78 };
79 reveal.attach({
80 ...view,
81 getInstance: () => ({ getRenderedItems: () => (rendered ? [{ id: "a", instance }] : []) }),
82 });
83 const range = { start: 12, end: 15, side: "additions" } as const;
84 reveal.request("a", range);
85 expect(view.scrolls).toEqual([{ type: "item", id: "a", align: "start" }]);
86 rendered = true;
87 reveal.flush();
88 expect(expanded).toBe(true);
89 expect(view.scrolls).toHaveLength(1);
90 reveal.flush();
91 expect(view.scrolls.at(-1)).toEqual({ type: "range", id: "a", range, align: "center" });
92 });
93
94 it("expands a patch-only file to an unchanged line before positioning it", () => {
95 const reveal = createCodeViewFileReveal();
96 const view = viewer(["a"]);
97 let partial = true;
98 const expanded: unknown[] = [];
99 const instance = {
100 getDiffForSearch: () =>
101 ({
102 isPartial: partial,
103 hunks: [{ additionStart: 10, additionCount: 5, deletionStart: 10, deletionCount: 5 }],
104 }) as never,
105 expandHunk: (...args: unknown[]) => expanded.push(args),
106 revealLine: () => false,
107 getLinePosition: () => ({ top: 100, height: 20 }),
108 };
109 reveal.attach({
110 ...view,
111 getInstance: () => ({ getRenderedItems: () => [{ id: "a", instance }] }),
112 });
113 const range = { start: 30, end: 30, side: "additions" } as const;
114 reveal.request("a", range);
115 expect(expanded).toEqual([[1, "up", 26]]);
116 reveal.flush();
117 expect(view.scrolls).toEqual([]);
118 // The expander loads the file; the next render holds the line.
119 partial = false;
120 reveal.flush();
121 expect(view.scrolls).toEqual([{ type: "range", id: "a", range, align: "center" }]);
122
123 // Removed lines always sit in a hunk, and a deleted file has no contents to load.
124 const deleted = viewer(["d"]);
125 const removedRange = { start: 3, end: 4, side: "deletions" } as const;
126 reveal.attach({
127 ...deleted,
128 getInstance: () => ({
129 getRenderedItems: () => [
130 {
131 id: "d",
132 instance: {
133 ...instance,
134 getDiffForSearch: () =>
135 ({
136 isPartial: true,
137 hunks: [
138 { additionStart: 0, additionCount: 0, deletionStart: 1, deletionCount: 8 },
139 ],
140 }) as never,
141 },
142 },
143 ],
144 }),
145 });
146 reveal.request("d", removedRange);
147 expect(expanded).toHaveLength(1);
148 expect(deleted.scrolls).toEqual([
149 { type: "range", id: "d", range: removedRange, align: "center" },
150 ]);
151 });
152
153 it("drops a pending reveal when another diff's viewer replaces it, but repositions a range a remount loses", () => {
154 const reveal = createCodeViewFileReveal();
155 const first = viewer([]);
156 // Item ids carry the diff's scope, so another diff's viewer holds other ids.
157 const second = viewer(["b"]);
158 reveal.attach(first);
159
160 reveal.request("a");
161 reveal.attach(null);
162 reveal.attach(second);
163 second.items.add("a");
164 reveal.flush();
165 expect(second.scrolls).toEqual([]);
166
167 reveal.request("a");
168 expect(second.scrolls).toHaveLength(1);
169
170 // The first diff load remounts the viewer right after a range scroll, so it is repeated once.
171 const range = { start: 3, end: 4, side: "additions" } as const;
172 const remounted = viewer(["a"]);
173 reveal.request("a", range);
174 reveal.attach(null);
175 reveal.attach(remounted);
176 reveal.flush();
177 expect(remounted.scrolls).toEqual([{ type: "range", id: "a", range, align: "center" }]);
178
179 // Scrolling away before that second pass keeps the user where they went.
180 const scrolledAway = viewer(["a"]);
181 reveal.request("a", range);
182 reveal.cancel();
183 reveal.attach(null);
184 reveal.attach(scrolledAway);
185 reveal.flush();
186 expect(scrolledAway.scrolls).toEqual([]);
187 });
188});