@cueloop/client 0.1.0-alpha.92 → 0.1.0-alpha.94
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -10
- package/src/App.deleted-scroll.test.tsx +186 -0
- package/src/App.diff.test.tsx +3 -4
- package/src/App.test.tsx +1 -0
- package/src/App.toast.test.tsx +27 -1
- package/src/App.tsx +52 -12
- package/src/App.workbench.test.tsx +290 -2
- package/src/app-screens.tsx +34 -8
- package/src/components/AppShell.zoom.test.tsx +16 -3
- package/src/components/ChangesColumn.tsx +10 -4
- package/src/components/ChangesFileTree.test.tsx +122 -4
- package/src/components/DiffContentView.scroll.test.tsx +285 -1
- package/src/components/DiffContentView.tsx +214 -56
- package/src/components/EditorGrid.split.test.tsx +118 -0
- package/src/components/EditorGrid.stories.tsx +27 -0
- package/src/components/EditorGrid.tsx +170 -50
- package/src/components/EditorGrid.zoom.test.tsx +65 -1
- package/src/components/GridTabContent.actions.test.tsx +151 -0
- package/src/components/GridTabContent.stories.tsx +2 -2
- package/src/components/GridTabContent.tsx +5 -1
- package/src/components/ProjectTreeView.test.tsx +166 -8
- package/src/components/ProjectTreeView.tsx +53 -27
- package/src/components/ScrollArea.tsx +21 -2
- package/src/components/Toast.tsx +3 -1
- package/src/components/WelcomePlayground.test.tsx +28 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +41 -21
- package/src/components/editor-grid.test.ts +87 -0
- package/src/components/editor-grid.ts +142 -6
- package/src/components/primitives/Tree.tsx +51 -15
- package/src/run.ts +6 -0
- package/src/thread-controller.curate.test.ts +27 -0
- package/src/thread-controller.live-diff.test.ts +30 -0
- package/src/thread-controller.ts +29 -7
- package/src/use-annotation-surface.tsx +86 -19
- package/src/use-changes-workbench.ts +30 -18
- package/src/use-frame-measure.ts +40 -7
- package/src/use-repo-changes.ts +5 -1
- package/src/use-settings-dialog.tsx +1 -13
- package/src/use-shared-keyboard.ts +55 -0
- package/src/use-terminal-virtualizer.ts +8 -2
|
@@ -1,14 +1,26 @@
|
|
|
1
|
-
/** The changed-files tree navigates by keyboard when focused: j/k move the cursor,
|
|
1
|
+
/** The changed-files tree navigates by keyboard when focused: j/k move the cursor, Enter folds a folder
|
|
2
2
|
* or opens a file. */
|
|
3
3
|
|
|
4
4
|
import { test, expect } from "bun:test";
|
|
5
5
|
import React from "react";
|
|
6
6
|
import { testRender } from "@opentui/react/test-utils";
|
|
7
|
+
import { ScrollBoxRenderable, TextRenderable, type Renderable } from "@opentui/core";
|
|
7
8
|
import type { DiffFileContents } from "@cueloop/schema";
|
|
8
9
|
import { ChangesFileTree } from "./ChangesColumn";
|
|
9
10
|
import { settle, waitForText } from "../test-support";
|
|
10
11
|
import { DARK } from "../theme";
|
|
11
12
|
|
|
13
|
+
function findById(node: Renderable, id: string): Renderable | undefined {
|
|
14
|
+
if (node.id === id) return node;
|
|
15
|
+
for (const child of node.getChildren()) {
|
|
16
|
+
const found = findById(child, id);
|
|
17
|
+
|
|
18
|
+
if (found) return found;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
const file = (path: string): DiffFileContents => ({
|
|
13
25
|
path,
|
|
14
26
|
oldContents: "",
|
|
@@ -18,7 +30,7 @@ const file = (path: string): DiffFileContents => ({
|
|
|
18
30
|
|
|
19
31
|
const files = [file("src/a.ts"), file("src/b.ts")];
|
|
20
32
|
|
|
21
|
-
test("j moves onto a file and
|
|
33
|
+
test("j moves onto a file and Enter opens it", async () => {
|
|
22
34
|
const opened: string[] = [];
|
|
23
35
|
const setup = await testRender(
|
|
24
36
|
<ChangesFileTree
|
|
@@ -35,7 +47,7 @@ test("j moves onto a file and tab opens it", async () => {
|
|
|
35
47
|
// folders open by default; cursor starts on the "src" folder, j steps onto a.ts
|
|
36
48
|
setup.mockInput.pressKey("j");
|
|
37
49
|
await settle(setup);
|
|
38
|
-
setup.mockInput.pressKey("
|
|
50
|
+
setup.mockInput.pressKey("RETURN");
|
|
39
51
|
await settle(setup);
|
|
40
52
|
|
|
41
53
|
expect(opened).toEqual(["src/a.ts"]);
|
|
@@ -43,6 +55,62 @@ test("j moves onto a file and tab opens it", async () => {
|
|
|
43
55
|
setup.renderer.destroy();
|
|
44
56
|
});
|
|
45
57
|
|
|
58
|
+
test("Tab leaves the focused tree without opening a file", async () => {
|
|
59
|
+
const opened: string[] = [];
|
|
60
|
+
const setup = await testRender(
|
|
61
|
+
<ChangesFileTree
|
|
62
|
+
files={files}
|
|
63
|
+
onSelectFile={(path) => opened.push(path)}
|
|
64
|
+
focused
|
|
65
|
+
theme={DARK}
|
|
66
|
+
/>,
|
|
67
|
+
{ width: 40, height: 12 },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await waitForText(setup, "a.ts");
|
|
71
|
+
setup.mockInput.pressKey("j");
|
|
72
|
+
setup.mockInput.pressKey("TAB");
|
|
73
|
+
await settle(setup);
|
|
74
|
+
expect(opened).toEqual([]);
|
|
75
|
+
setup.renderer.destroy();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("keyboard navigation reveals the selected changed file", async () => {
|
|
79
|
+
const manyFiles = Array.from({ length: 35 }, (_, index) =>
|
|
80
|
+
file(`file-${String(index).padStart(2, "0")}.ts`),
|
|
81
|
+
);
|
|
82
|
+
const setup = await testRender(
|
|
83
|
+
<ChangesFileTree files={manyFiles} onSelectFile={() => {}} focused theme={DARK} />,
|
|
84
|
+
{ width: 28, height: 8 },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
await waitForText(setup, "file-00.ts");
|
|
88
|
+
const scrollbox = findById(setup.renderer.root, "tree-scroll");
|
|
89
|
+
|
|
90
|
+
if (!(scrollbox instanceof ScrollBoxRenderable)) throw new Error("tree scrollbox missing");
|
|
91
|
+
const scrollSteps: number[] = [];
|
|
92
|
+
|
|
93
|
+
for (const [index, key] of Array.from({ length: 15 }, () => "j").entries()) {
|
|
94
|
+
const before = scrollbox.scrollTop;
|
|
95
|
+
|
|
96
|
+
setup.mockInput.pressKey(key);
|
|
97
|
+
// eslint-disable-next-line no-await-in-loop
|
|
98
|
+
await settle(setup);
|
|
99
|
+
scrollSteps.push(scrollbox.scrollTop - before);
|
|
100
|
+
const row = scrollbox.content.findDescendantById(
|
|
101
|
+
`tree-row-file-${String(index + 1).padStart(2, "0")}.ts`,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(row?.y).toBeGreaterThanOrEqual(scrollbox.viewport.y);
|
|
105
|
+
expect(row?.y).toBeLessThan(scrollbox.viewport.y + scrollbox.viewport.height);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
expect(scrollbox.scrollTop).toBeGreaterThan(0);
|
|
109
|
+
expect(Math.max(...scrollSteps)).toBe(1);
|
|
110
|
+
expect(setup.captureCharFrame()).toContain("file-15.ts");
|
|
111
|
+
setup.renderer.destroy();
|
|
112
|
+
});
|
|
113
|
+
|
|
46
114
|
test("without focus the keyboard opens nothing", async () => {
|
|
47
115
|
const opened: string[] = [];
|
|
48
116
|
const setup = await testRender(
|
|
@@ -53,9 +121,59 @@ test("without focus the keyboard opens nothing", async () => {
|
|
|
53
121
|
await waitForText(setup, "a.ts");
|
|
54
122
|
|
|
55
123
|
setup.mockInput.pressKey("j");
|
|
56
|
-
setup.mockInput.pressKey("
|
|
124
|
+
setup.mockInput.pressKey("RETURN");
|
|
57
125
|
await settle(setup);
|
|
58
126
|
expect(opened).toEqual([]);
|
|
59
127
|
|
|
60
128
|
setup.renderer.destroy();
|
|
61
129
|
});
|
|
130
|
+
|
|
131
|
+
test("a selected long filename has no empty continuation row", async () => {
|
|
132
|
+
for (const width of [33, 32, 31, 30, 29, 28, 27, 26, 25, 24]) {
|
|
133
|
+
const setup = await testRender(
|
|
134
|
+
<ChangesFileTree
|
|
135
|
+
files={[
|
|
136
|
+
file("packages/client/src/App.workbench.test.tsx"),
|
|
137
|
+
file("packages/client/src/next.ts"),
|
|
138
|
+
]}
|
|
139
|
+
onSelectFile={() => {}}
|
|
140
|
+
focused
|
|
141
|
+
theme={DARK}
|
|
142
|
+
/>,
|
|
143
|
+
{ width, height: 12 },
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await settle(setup);
|
|
147
|
+
setup.mockInput.pressKey("j");
|
|
148
|
+
await settle(setup);
|
|
149
|
+
const frame = setup.captureCharFrame().split("\n");
|
|
150
|
+
const selectedLine = frame.findIndex((line) => line.includes("App.wor"));
|
|
151
|
+
const row = findById(
|
|
152
|
+
setup.renderer.root,
|
|
153
|
+
"tree-row-packages/client/src/App.workbench.test.tsx",
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(selectedLine).toBeGreaterThanOrEqual(0);
|
|
157
|
+
expect(frame[selectedLine + 1]).toContain("next.ts");
|
|
158
|
+
expect(row?.height).toBe(1);
|
|
159
|
+
setup.renderer.destroy();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("horizontal name scrolling reveals the final extension beside the status", async () => {
|
|
164
|
+
const path = "src/sidebar-scroll.test.tsx";
|
|
165
|
+
const setup = await testRender(
|
|
166
|
+
<ChangesFileTree files={[file(path)]} onSelectFile={() => {}} focused theme={DARK} />,
|
|
167
|
+
{ width: 24, height: 8 },
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
await waitForText(setup, "src");
|
|
171
|
+
const label = findById(setup.renderer.root, `tree-label-${path}`);
|
|
172
|
+
|
|
173
|
+
if (!(label instanceof TextRenderable)) throw new Error("file label missing");
|
|
174
|
+
expect(label.maxScrollX).toBeGreaterThan(0);
|
|
175
|
+
label.scrollX = label.maxScrollX;
|
|
176
|
+
await settle(setup);
|
|
177
|
+
expect(setup.captureCharFrame()).toContain(".tsx");
|
|
178
|
+
setup.renderer.destroy();
|
|
179
|
+
});
|
|
@@ -107,9 +107,24 @@ test("walking the caret down past wrapped discussion cards keeps it on screen an
|
|
|
107
107
|
await press(setup, "down");
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
//
|
|
110
|
+
// A key advances the viewport by at most one visual line, even across a tall card.
|
|
111
111
|
for (let step = 1; step < scrollTops.length; step++) {
|
|
112
112
|
expect(scrollTops[step]!).toBeGreaterThanOrEqual(scrollTops[step - 1]!);
|
|
113
|
+
expect(scrollTops[step]! - scrollTops[step - 1]!).toBeLessThanOrEqual(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Walk back through the same cards: upward reveal must also stay one row at a time.
|
|
117
|
+
const upwardTops: number[] = [];
|
|
118
|
+
|
|
119
|
+
for (let step = 0; step < codeRowCount + 20; step++) {
|
|
120
|
+
upwardTops.push(scrollbox.scrollTop);
|
|
121
|
+
screenRows.push(caretScreenRow());
|
|
122
|
+
await press(setup, "up");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (let step = 1; step < upwardTops.length; step++) {
|
|
126
|
+
expect(upwardTops[step]!).toBeLessThanOrEqual(upwardTops[step - 1]!);
|
|
127
|
+
expect(upwardTops[step - 1]! - upwardTops[step]!).toBeLessThanOrEqual(1);
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
// Assert - the caret never scrolls off screen while it walks past each wrapped card
|
|
@@ -120,6 +135,220 @@ test("walking the caret down past wrapped discussion cards keeps it on screen an
|
|
|
120
135
|
setup.renderer.destroy();
|
|
121
136
|
}, 25000);
|
|
122
137
|
|
|
138
|
+
test("wheel input accelerates a continuous gesture and settles back to precise steps", async () => {
|
|
139
|
+
const rows = diffRows(tallPatch(80));
|
|
140
|
+
const setup = await testRender(
|
|
141
|
+
<DiffContentView
|
|
142
|
+
rows={rows}
|
|
143
|
+
session={fixtureDiffSession()}
|
|
144
|
+
marks={marksByRows([], rows)}
|
|
145
|
+
quickActions={[]}
|
|
146
|
+
observer={false}
|
|
147
|
+
onAnnotate={noop}
|
|
148
|
+
onReply={noop}
|
|
149
|
+
onUpdateAnnotation={noop}
|
|
150
|
+
onExit={noop}
|
|
151
|
+
/>,
|
|
152
|
+
{ width: 60, height: 12 },
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
await settle(setup);
|
|
156
|
+
const found = findById(setup.renderer.root, "diff-scroll");
|
|
157
|
+
|
|
158
|
+
if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
|
|
159
|
+
const scrollbox = found;
|
|
160
|
+
const acceleration = scrollbox.scrollAcceleration;
|
|
161
|
+
const first = acceleration.tick(1000);
|
|
162
|
+
const burst = Array.from({ length: 10 }, (_, index) => acceleration.tick(1016 + index * 16));
|
|
163
|
+
const tail = acceleration.tick(1400);
|
|
164
|
+
|
|
165
|
+
expect(scrollbox.verticalScrollBar.scrollStep).toBe(1);
|
|
166
|
+
expect(first).toBe(1);
|
|
167
|
+
expect(Math.max(...burst)).toBeGreaterThan(first);
|
|
168
|
+
expect(tail).toBe(1);
|
|
169
|
+
|
|
170
|
+
acceleration.reset();
|
|
171
|
+
await setup.mockMouse.scroll(20, 5, "down");
|
|
172
|
+
await settle(setup);
|
|
173
|
+
expect(scrollbox.scrollTop).toBeGreaterThan(0);
|
|
174
|
+
|
|
175
|
+
setup.renderer.destroy();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("wheel scrolling up keeps the caret on the bottom visible code row", async () => {
|
|
179
|
+
const rows = diffRows(tallPatch(80));
|
|
180
|
+
const setup = await testRender(
|
|
181
|
+
<DiffContentView
|
|
182
|
+
rows={rows}
|
|
183
|
+
session={fixtureDiffSession()}
|
|
184
|
+
marks={marksByRows([], rows)}
|
|
185
|
+
quickActions={[]}
|
|
186
|
+
observer={false}
|
|
187
|
+
onAnnotate={noop}
|
|
188
|
+
onReply={noop}
|
|
189
|
+
onUpdateAnnotation={noop}
|
|
190
|
+
onExit={noop}
|
|
191
|
+
/>,
|
|
192
|
+
{ width: 60, height: 12 },
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
await settle(setup);
|
|
196
|
+
const found = findById(setup.renderer.root, "diff-scroll");
|
|
197
|
+
|
|
198
|
+
if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
|
|
199
|
+
const scrollbox = found;
|
|
200
|
+
|
|
201
|
+
for (const key of Array.from({ length: 30 }, () => "down" as const)) {
|
|
202
|
+
// eslint-disable-next-line no-await-in-loop
|
|
203
|
+
await press(setup, key);
|
|
204
|
+
}
|
|
205
|
+
const before = scrollbox.scrollTop;
|
|
206
|
+
const markerRows = (): number[] =>
|
|
207
|
+
setup
|
|
208
|
+
.captureCharFrame()
|
|
209
|
+
.split("\n")
|
|
210
|
+
.flatMap((line, row) => (line.includes("▎") ? [row] : []));
|
|
211
|
+
const top = scrollbox.viewport.screenY;
|
|
212
|
+
const bottom = top + scrollbox.viewport.height - 1;
|
|
213
|
+
|
|
214
|
+
for (const direction of Array.from({ length: 8 }, () => "up" as const)) {
|
|
215
|
+
// eslint-disable-next-line no-await-in-loop
|
|
216
|
+
await setup.mockMouse.scroll(20, 5, direction);
|
|
217
|
+
// eslint-disable-next-line no-await-in-loop
|
|
218
|
+
await settle(setup);
|
|
219
|
+
expect(markerRows()).toEqual([bottom]);
|
|
220
|
+
}
|
|
221
|
+
expect(scrollbox.scrollTop).toBeLessThan(before);
|
|
222
|
+
for (const direction of Array.from({ length: 20 }, () => "down" as const)) {
|
|
223
|
+
// eslint-disable-next-line no-await-in-loop
|
|
224
|
+
await setup.mockMouse.scroll(20, 5, direction);
|
|
225
|
+
// eslint-disable-next-line no-await-in-loop
|
|
226
|
+
await settle(setup);
|
|
227
|
+
expect(markerRows()).toHaveLength(1);
|
|
228
|
+
}
|
|
229
|
+
expect(markerRows()).toEqual([top]);
|
|
230
|
+
setup.renderer.destroy();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("rapid wheel scrolling through a large wrapped diff does not loop React updates", async () => {
|
|
234
|
+
const filePatches = Array.from({ length: 80 }, (_, fileIndex) => [
|
|
235
|
+
`diff --git a/file-${fileIndex}.ts b/file-${fileIndex}.ts`,
|
|
236
|
+
`--- a/file-${fileIndex}.ts`,
|
|
237
|
+
`+++ b/file-${fileIndex}.ts`,
|
|
238
|
+
"@@ -0,0 +1,12 @@",
|
|
239
|
+
...Array.from(
|
|
240
|
+
{ length: 12 },
|
|
241
|
+
(_, lineIndex) => `+line ${lineIndex} ${"dense source text ".repeat(80)}`,
|
|
242
|
+
),
|
|
243
|
+
]).flat();
|
|
244
|
+
const rows = diffRows([...filePatches, ""].join("\n"));
|
|
245
|
+
const StatefulDiff = (): React.ReactNode => {
|
|
246
|
+
const [, setCursor] = React.useState(0);
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<DiffContentView
|
|
250
|
+
rows={rows}
|
|
251
|
+
session={fixtureDiffSession()}
|
|
252
|
+
marks={marksByRows([], rows)}
|
|
253
|
+
quickActions={[]}
|
|
254
|
+
observer={false}
|
|
255
|
+
onCursorChange={setCursor}
|
|
256
|
+
onAnnotate={noop}
|
|
257
|
+
onReply={noop}
|
|
258
|
+
onUpdateAnnotation={noop}
|
|
259
|
+
onExit={noop}
|
|
260
|
+
/>
|
|
261
|
+
);
|
|
262
|
+
};
|
|
263
|
+
const setup = await testRender(<StatefulDiff />, { width: 60, height: 12 });
|
|
264
|
+
|
|
265
|
+
await settle(setup);
|
|
266
|
+
const found = findById(setup.renderer.root, "diff-scroll");
|
|
267
|
+
|
|
268
|
+
if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
|
|
269
|
+
|
|
270
|
+
for (const [index, direction] of Array.from({ length: 240 }, () => "down" as const).entries()) {
|
|
271
|
+
// eslint-disable-next-line no-await-in-loop
|
|
272
|
+
await setup.mockMouse.scroll(20, 5, direction);
|
|
273
|
+
if (index % 12 === 0) {
|
|
274
|
+
// eslint-disable-next-line no-await-in-loop
|
|
275
|
+
await settle(setup);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
await settle(setup);
|
|
279
|
+
expect(found.scrollTop).toBeGreaterThan(0);
|
|
280
|
+
setup.renderer.destroy();
|
|
281
|
+
}, 60000);
|
|
282
|
+
|
|
283
|
+
test("walking long wrapped code lines advances the viewport one visual row per key", async () => {
|
|
284
|
+
const lines = Array.from(
|
|
285
|
+
{ length: 36 },
|
|
286
|
+
(_, index) =>
|
|
287
|
+
`+line ${String(index).padStart(2, "0")} ${"a paragraph with several wrapped phrases ".repeat(5)}`,
|
|
288
|
+
);
|
|
289
|
+
const rows = diffRows(
|
|
290
|
+
[
|
|
291
|
+
"diff --git a/long.md b/long.md",
|
|
292
|
+
"--- a/long.md",
|
|
293
|
+
"+++ b/long.md",
|
|
294
|
+
"@@ -0,0 +1,36 @@",
|
|
295
|
+
...lines,
|
|
296
|
+
"",
|
|
297
|
+
].join("\n"),
|
|
298
|
+
);
|
|
299
|
+
const setup = await testRender(
|
|
300
|
+
<DiffContentView
|
|
301
|
+
rows={rows}
|
|
302
|
+
session={fixtureDiffSession()}
|
|
303
|
+
marks={marksByRows([], rows)}
|
|
304
|
+
quickActions={[]}
|
|
305
|
+
observer={false}
|
|
306
|
+
onAnnotate={noop}
|
|
307
|
+
onReply={noop}
|
|
308
|
+
onUpdateAnnotation={noop}
|
|
309
|
+
onExit={noop}
|
|
310
|
+
/>,
|
|
311
|
+
{ width: 60, height: 12 },
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
await settle(setup);
|
|
315
|
+
const found = findById(setup.renderer.root, "diff-scroll");
|
|
316
|
+
|
|
317
|
+
if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
|
|
318
|
+
const scrollbox = found;
|
|
319
|
+
const steps: number[] = [];
|
|
320
|
+
const caretCell = annotationPaletteFor(DARK).caretCell;
|
|
321
|
+
|
|
322
|
+
for (let index = 0; index < 45; index++) {
|
|
323
|
+
const before = scrollbox.scrollTop;
|
|
324
|
+
|
|
325
|
+
await press(setup, "down");
|
|
326
|
+
steps.push(scrollbox.scrollTop - before);
|
|
327
|
+
const caretRow = setup
|
|
328
|
+
.captureSpans()
|
|
329
|
+
.lines.findIndex((line) => line.spans.some((span) => hex(span.bg) === caretCell));
|
|
330
|
+
const markerRows = setup
|
|
331
|
+
.captureCharFrame()
|
|
332
|
+
.split("\n")
|
|
333
|
+
.flatMap((line, row) => (line.includes("▎") ? [row] : []));
|
|
334
|
+
|
|
335
|
+
expect(caretRow).toBeGreaterThanOrEqual(0);
|
|
336
|
+
expect(markerRows).toEqual([caretRow]);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
expect(Math.max(...steps)).toBeLessThanOrEqual(1);
|
|
340
|
+
expect(steps.some((step) => step > 0)).toBe(true);
|
|
341
|
+
await setup.mockMouse.scroll(20, 5, "up");
|
|
342
|
+
await settle(setup);
|
|
343
|
+
const wrappedMarkerRows = setup
|
|
344
|
+
.captureCharFrame()
|
|
345
|
+
.split("\n")
|
|
346
|
+
.flatMap((line, row) => (line.includes("▎") ? [row] : []));
|
|
347
|
+
|
|
348
|
+
expect(wrappedMarkerRows).toEqual([scrollbox.viewport.screenY + scrollbox.viewport.height - 1]);
|
|
349
|
+
setup.renderer.destroy();
|
|
350
|
+
});
|
|
351
|
+
|
|
123
352
|
test("opening a comment on the diff's last line reveals its composer", async () => {
|
|
124
353
|
const rows = diffRows(tallPatch(24));
|
|
125
354
|
const setup = await testRender(
|
|
@@ -147,3 +376,58 @@ test("opening a comment on the diff's last line reveals its composer", async ()
|
|
|
147
376
|
expect(setup.captureCharFrame()).toContain("● bottom comment");
|
|
148
377
|
setup.renderer.destroy();
|
|
149
378
|
}, 25000);
|
|
379
|
+
|
|
380
|
+
test("Up keeps the caret on the first screen row while wrapped lines scroll", async () => {
|
|
381
|
+
const lines = Array.from({ length: 24 }, (_, index) =>
|
|
382
|
+
index % 3 === 2 ? "-" : `-line ${index} ${"wrapped words here ".repeat(5)}`,
|
|
383
|
+
);
|
|
384
|
+
const rows = diffRows(
|
|
385
|
+
[
|
|
386
|
+
"diff --git a/long.md b/long.md",
|
|
387
|
+
"--- a/long.md",
|
|
388
|
+
"+++ /dev/null",
|
|
389
|
+
"@@ -1,24 +0,0 @@",
|
|
390
|
+
...lines,
|
|
391
|
+
"",
|
|
392
|
+
].join("\n"),
|
|
393
|
+
);
|
|
394
|
+
const setup = await testRender(
|
|
395
|
+
<DiffContentView
|
|
396
|
+
rows={rows}
|
|
397
|
+
session={fixtureDiffSession()}
|
|
398
|
+
marks={marksByRows([], rows)}
|
|
399
|
+
quickActions={[]}
|
|
400
|
+
observer={false}
|
|
401
|
+
onAnnotate={noop}
|
|
402
|
+
onReply={noop}
|
|
403
|
+
onUpdateAnnotation={noop}
|
|
404
|
+
onExit={noop}
|
|
405
|
+
/>,
|
|
406
|
+
{ width: 60, height: 12 },
|
|
407
|
+
);
|
|
408
|
+
await settle(setup);
|
|
409
|
+
const found = findById(setup.renderer.root, "diff-scroll");
|
|
410
|
+
|
|
411
|
+
if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
|
|
412
|
+
const scrollbox = found;
|
|
413
|
+
|
|
414
|
+
for (let index = 0; index < 40; index++) await press(setup, "down");
|
|
415
|
+
let previousTop = scrollbox.scrollTop;
|
|
416
|
+
let scrolled = false;
|
|
417
|
+
|
|
418
|
+
for (let index = 0; index < 80; index++) {
|
|
419
|
+
await press(setup, "up");
|
|
420
|
+
const frame = setup.captureCharFrame().split("\n");
|
|
421
|
+
const marker = frame.findIndex((line) => line.includes("▎"));
|
|
422
|
+
const top = scrollbox.scrollTop;
|
|
423
|
+
|
|
424
|
+
expect(previousTop - top).toBeLessThanOrEqual(1);
|
|
425
|
+
if (top < previousTop) {
|
|
426
|
+
expect(marker).toBe(0);
|
|
427
|
+
scrolled = true;
|
|
428
|
+
}
|
|
429
|
+
previousTop = top;
|
|
430
|
+
}
|
|
431
|
+
expect(scrolled).toBe(true);
|
|
432
|
+
setup.renderer.destroy();
|
|
433
|
+
});
|