@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.
Files changed (40) hide show
  1. package/package.json +10 -10
  2. package/src/App.deleted-scroll.test.tsx +186 -0
  3. package/src/App.diff.test.tsx +3 -4
  4. package/src/App.test.tsx +1 -0
  5. package/src/App.toast.test.tsx +27 -1
  6. package/src/App.tsx +52 -12
  7. package/src/App.workbench.test.tsx +290 -2
  8. package/src/app-screens.tsx +34 -8
  9. package/src/components/AppShell.zoom.test.tsx +16 -3
  10. package/src/components/ChangesColumn.tsx +10 -4
  11. package/src/components/ChangesFileTree.test.tsx +122 -4
  12. package/src/components/DiffContentView.scroll.test.tsx +285 -1
  13. package/src/components/DiffContentView.tsx +214 -56
  14. package/src/components/EditorGrid.split.test.tsx +118 -0
  15. package/src/components/EditorGrid.stories.tsx +27 -0
  16. package/src/components/EditorGrid.tsx +170 -50
  17. package/src/components/EditorGrid.zoom.test.tsx +65 -1
  18. package/src/components/GridTabContent.actions.test.tsx +151 -0
  19. package/src/components/GridTabContent.stories.tsx +2 -2
  20. package/src/components/GridTabContent.tsx +5 -1
  21. package/src/components/ProjectTreeView.test.tsx +166 -8
  22. package/src/components/ProjectTreeView.tsx +53 -27
  23. package/src/components/ScrollArea.tsx +21 -2
  24. package/src/components/Toast.tsx +3 -1
  25. package/src/components/WelcomePlayground.test.tsx +28 -1
  26. package/src/components/__snapshots__/stories.test.tsx.snap +41 -21
  27. package/src/components/editor-grid.test.ts +87 -0
  28. package/src/components/editor-grid.ts +142 -6
  29. package/src/components/primitives/Tree.tsx +51 -15
  30. package/src/run.ts +6 -0
  31. package/src/thread-controller.curate.test.ts +27 -0
  32. package/src/thread-controller.live-diff.test.ts +30 -0
  33. package/src/thread-controller.ts +29 -7
  34. package/src/use-annotation-surface.tsx +86 -19
  35. package/src/use-changes-workbench.ts +30 -18
  36. package/src/use-frame-measure.ts +40 -7
  37. package/src/use-repo-changes.ts +5 -1
  38. package/src/use-settings-dialog.tsx +1 -13
  39. package/src/use-shared-keyboard.ts +55 -0
  40. 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, tab folds a folder
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 tab opens it", async () => {
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("TAB");
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("TAB");
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
- // Assert - scrollTop only ever grows going down (never rebounds/jitters)
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
+ });