@cueloop/client 0.1.0-alpha.92 → 0.1.0-alpha.93
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 +5 -5
- 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 +21 -12
- package/src/App.workbench.test.tsx +208 -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/thread-controller.curate.test.ts +27 -0
- package/src/thread-controller.ts +4 -6
- 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-settings-dialog.tsx +1 -13
- package/src/use-shared-keyboard.ts +55 -0
- package/src/use-terminal-virtualizer.ts +8 -2
package/src/app-screens.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import React, { useCallback, useMemo, type Dispatch, type SetStateAction } from
|
|
|
3
3
|
import type { Thread, MessageOutcome } from "@cueloop/schema";
|
|
4
4
|
import { returnPaneFor } from "@cueloop/schema";
|
|
5
5
|
import type { Theme } from "./theme";
|
|
6
|
-
import type { QuickAction } from "./config";
|
|
6
|
+
import type { DiffViewMode, QuickAction } from "./config";
|
|
7
7
|
import type { LaunchLayout } from "./launch-layout";
|
|
8
8
|
import { useRememberLayout } from "./use-remember-layout";
|
|
9
9
|
import type { Mode, TreeAsk } from "./intent-dispatch";
|
|
@@ -30,6 +30,7 @@ import { ProjectTreeView } from "./components/ProjectTreeView";
|
|
|
30
30
|
import { ChangesFileTree } from "./components/ChangesColumn";
|
|
31
31
|
import { BareWorkbenchFileView, draftThread } from "./components/BareWorkbenchFileView";
|
|
32
32
|
import { GridTabContent, type DiffSurfaceProps } from "./components/GridTabContent";
|
|
33
|
+
import type { DiffFoldControls } from "./components/DiffContentView";
|
|
33
34
|
import { useChangesWorkbench } from "./use-changes-workbench";
|
|
34
35
|
import { useRepoChanges } from "./use-repo-changes";
|
|
35
36
|
import { ConfirmDialog } from "./components/ConfirmDialog";
|
|
@@ -112,9 +113,9 @@ function WelcomeProjectPanel({
|
|
|
112
113
|
mode: ProjectPanelMode;
|
|
113
114
|
controller: ReviewController;
|
|
114
115
|
/** Changes tree click -> open the file's working-tree diff. */
|
|
115
|
-
onOpenChangedFile: (path: string) => void;
|
|
116
|
+
onOpenChangedFile: (path: string, persistent?: boolean) => void;
|
|
116
117
|
/** Project tree click -> open the file's read-only contents. */
|
|
117
|
-
onOpenProjectFile: (path: string) => void;
|
|
118
|
+
onOpenProjectFile: (path: string, persistent?: boolean) => void;
|
|
118
119
|
focused?: boolean;
|
|
119
120
|
theme: Theme;
|
|
120
121
|
}): React.ReactNode {
|
|
@@ -177,6 +178,7 @@ export function NoThreadShell(props: {
|
|
|
177
178
|
onRename: (id: string, title: string) => void;
|
|
178
179
|
/** Drives the "/" quick actions and skills in the welcome playground's composer. */
|
|
179
180
|
quickActions: QuickAction[];
|
|
181
|
+
diffView: DiffViewMode;
|
|
180
182
|
/** Reports the welcome composer's open state, so the shell suspends its inbox keys while typing. */
|
|
181
183
|
onWelcomeComposingChange: (composing: boolean) => void;
|
|
182
184
|
/** The pane composition this vanilla launch restores; changes here are remembered for the next one. */
|
|
@@ -201,6 +203,7 @@ export function NoThreadShell(props: {
|
|
|
201
203
|
onPin,
|
|
202
204
|
onRename,
|
|
203
205
|
quickActions,
|
|
206
|
+
diffView,
|
|
204
207
|
onWelcomeComposingChange,
|
|
205
208
|
layout,
|
|
206
209
|
} = props;
|
|
@@ -222,13 +225,14 @@ export function NoThreadShell(props: {
|
|
|
222
225
|
workbench.zoomed,
|
|
223
226
|
);
|
|
224
227
|
// the Changes tree opens a file's working-tree diff; the Project tree opens read-only contents
|
|
225
|
-
const openChangedFile = (path: string): void => {
|
|
228
|
+
const openChangedFile = (path: string, persistent?: boolean): void => {
|
|
226
229
|
// no thread means no live-diff refresh loop, so re-capture on open or a long-lived shell goes
|
|
227
230
|
// stale; the catch keeps a fire-and-forget refresh from throwing when the shell tears down
|
|
228
231
|
void controller.repoChanges().catch(() => undefined);
|
|
229
|
-
workbench.openFile(path, "diff");
|
|
232
|
+
workbench.openFile(path, "diff", persistent);
|
|
230
233
|
};
|
|
231
|
-
const openProjectFile = (path: string): void =>
|
|
234
|
+
const openProjectFile = (path: string, persistent?: boolean): void =>
|
|
235
|
+
workbench.openFile(path, "contents", persistent);
|
|
232
236
|
// a bare launch has no thread yet, so the diff renders against a draft session; the first note
|
|
233
237
|
// promotes it to the per-repo workbench thread (commentOnWorkbenchDiff)
|
|
234
238
|
const draft = useMemo(() => draftThread(), []);
|
|
@@ -256,6 +260,15 @@ export function NoThreadShell(props: {
|
|
|
256
260
|
onUpdateAnnotation: () => {},
|
|
257
261
|
onExit: () => {},
|
|
258
262
|
};
|
|
263
|
+
const diffFold: DiffFoldControls = {
|
|
264
|
+
isCollapsed: (file) => controller.isFileCollapsed(file),
|
|
265
|
+
isExpanded: (file) => controller.isFileExpanded(file),
|
|
266
|
+
canExpand: (file) => controller.canExpandFile(file),
|
|
267
|
+
onToggleCollapse: (file) =>
|
|
268
|
+
controller.setFileCollapsed(file, !controller.isFileCollapsed(file)),
|
|
269
|
+
onToggleExpand: (file) => controller.setFileExpanded(file, !controller.isFileExpanded(file)),
|
|
270
|
+
onCopyPath: (file) => controller.copyFilePath(file),
|
|
271
|
+
};
|
|
259
272
|
|
|
260
273
|
return (
|
|
261
274
|
<ThemeProvider theme={theme}>
|
|
@@ -313,6 +326,7 @@ export function NoThreadShell(props: {
|
|
|
313
326
|
focusedGroupId={workbench.activeGroup}
|
|
314
327
|
onFocusGroup={workbench.focusGroup}
|
|
315
328
|
onActivateTab={workbench.activate}
|
|
329
|
+
onKeepTab={workbench.keepTab}
|
|
316
330
|
onCloseTab={workbench.close}
|
|
317
331
|
onSplit={workbench.split}
|
|
318
332
|
onZoom={workbench.toggleZoom}
|
|
@@ -340,6 +354,9 @@ export function NoThreadShell(props: {
|
|
|
340
354
|
rows={controller.rows()}
|
|
341
355
|
surface={bareSurface}
|
|
342
356
|
rejectedRows={EMPTY_ROWS}
|
|
357
|
+
fold={diffFold}
|
|
358
|
+
fileStats={controller.fileStats()}
|
|
359
|
+
split={diffView === "split" && workbench.zoomed}
|
|
343
360
|
dimmed={false}
|
|
344
361
|
readFile={(path) => controller.repoReadFile(path)}
|
|
345
362
|
onAddFileComment={(path, anchor, body) =>
|
|
@@ -396,7 +413,12 @@ export function NoThreadShell(props: {
|
|
|
396
413
|
/>
|
|
397
414
|
) : null}
|
|
398
415
|
{props.toast ? (
|
|
399
|
-
<Toast
|
|
416
|
+
<Toast
|
|
417
|
+
title={props.toast.title}
|
|
418
|
+
body={props.toast.body}
|
|
419
|
+
onDismiss={() => controller.dismissToast()}
|
|
420
|
+
theme={theme}
|
|
421
|
+
/>
|
|
400
422
|
) : null}
|
|
401
423
|
</AppShell>
|
|
402
424
|
</MenuControlProvider>
|
|
@@ -460,6 +482,7 @@ export function TrailingOverlays(props: {
|
|
|
460
482
|
theme: Theme;
|
|
461
483
|
mode: Mode;
|
|
462
484
|
toast: ToastState | null;
|
|
485
|
+
onDismissToast: () => void;
|
|
463
486
|
setMode: Dispatch<SetStateAction<Mode>>;
|
|
464
487
|
dispatch: (intent: Intent) => void;
|
|
465
488
|
}): React.ReactNode {
|
|
@@ -473,6 +496,7 @@ export function TrailingOverlays(props: {
|
|
|
473
496
|
theme,
|
|
474
497
|
mode,
|
|
475
498
|
toast,
|
|
499
|
+
onDismissToast,
|
|
476
500
|
setMode,
|
|
477
501
|
dispatch,
|
|
478
502
|
} = props;
|
|
@@ -571,7 +595,9 @@ export function TrailingOverlays(props: {
|
|
|
571
595
|
theme={theme}
|
|
572
596
|
/>
|
|
573
597
|
) : null}
|
|
574
|
-
{toast ?
|
|
598
|
+
{toast ? (
|
|
599
|
+
<Toast title={toast.title} body={toast.body} onDismiss={onDismissToast} theme={theme} />
|
|
600
|
+
) : null}
|
|
575
601
|
</>
|
|
576
602
|
);
|
|
577
603
|
}
|
|
@@ -11,7 +11,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
11
11
|
import React from "react";
|
|
12
12
|
import { AppShell } from "./AppShell";
|
|
13
13
|
import { EditorGrid } from "./EditorGrid";
|
|
14
|
-
import { changesTab, makeGroup } from "./editor-grid";
|
|
14
|
+
import { changesTab, makeGroup, splitGroup } from "./editor-grid";
|
|
15
15
|
import { NERD } from "./primitives/icons";
|
|
16
16
|
import { allowEventLoopUpdates } from "../test-support";
|
|
17
17
|
|
|
@@ -21,7 +21,11 @@ async function zoomIconColumn(
|
|
|
21
21
|
width: number,
|
|
22
22
|
zoomed: boolean,
|
|
23
23
|
sidebarOpen: boolean,
|
|
24
|
+
splitEditor = false,
|
|
24
25
|
): Promise<number> {
|
|
26
|
+
const editor = splitEditor
|
|
27
|
+
? splitGroup(grid, grid.id, "right")
|
|
28
|
+
: { tree: grid, focusGroupId: grid.id };
|
|
25
29
|
const setup = await testRender(
|
|
26
30
|
<AppShell
|
|
27
31
|
sidebarOpen={sidebarOpen}
|
|
@@ -40,8 +44,8 @@ async function zoomIconColumn(
|
|
|
40
44
|
projectPanel={<text>project</text>}
|
|
41
45
|
changesPanel={
|
|
42
46
|
<EditorGrid
|
|
43
|
-
tree={
|
|
44
|
-
focusedGroupId={
|
|
47
|
+
tree={editor.tree}
|
|
48
|
+
focusedGroupId={editor.focusGroupId}
|
|
45
49
|
onFocusGroup={() => {}}
|
|
46
50
|
onActivateTab={() => {}}
|
|
47
51
|
onCloseTab={() => {}}
|
|
@@ -132,4 +136,13 @@ describe("zoom icon in the shell", () => {
|
|
|
132
136
|
// before it, the same inset the right-sidebar icon keeps from its own edge
|
|
133
137
|
for (const column of setupColumns) expect(128 - column).toBe(2);
|
|
134
138
|
});
|
|
139
|
+
|
|
140
|
+
test("a split editor keeps the zoom icon clear of the Project border", async () => {
|
|
141
|
+
const columns = await Promise.all([
|
|
142
|
+
zoomIconColumn(160, false, false, true),
|
|
143
|
+
zoomIconColumn(160, true, false, true),
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
for (const column of columns) expect(128 - column).toBe(2);
|
|
147
|
+
});
|
|
135
148
|
});
|
|
@@ -86,7 +86,7 @@ export interface ChangesColumnProps {
|
|
|
86
86
|
files?: readonly DiffFileContents[];
|
|
87
87
|
/** The path whose diff the sheet is scrolled to, highlighted in the tree. */
|
|
88
88
|
selectedPath?: string;
|
|
89
|
-
onSelectFile: (path: string) => void;
|
|
89
|
+
onSelectFile: (path: string, persistent?: boolean) => void;
|
|
90
90
|
/** Comments per file path, shown as a dot-and-count badge on each entry. */
|
|
91
91
|
commentCounts?: ReadonlyMap<string, number>;
|
|
92
92
|
focused?: boolean;
|
|
@@ -141,18 +141,23 @@ export function ChangesFileTree({
|
|
|
141
141
|
if (key.name === "k" || key.name === "up") return setCursor(Math.max(cursorIndex - 1, 0));
|
|
142
142
|
const row = rows[cursorIndex];
|
|
143
143
|
if (!row) return;
|
|
144
|
-
if (key.name === "
|
|
145
|
-
return row.isFolder ? toggle(row.id) : onSelectFile(row.id);
|
|
144
|
+
if (key.name === "return" || key.name === "enter")
|
|
145
|
+
return row.isFolder ? toggle(row.id) : onSelectFile(row.id, true);
|
|
146
146
|
if (key.name === "l" && row.isFolder && collapsedIds.has(row.id)) return toggle(row.id);
|
|
147
147
|
if (key.name === "h" && row.isFolder && !collapsedIds.has(row.id)) return toggle(row.id);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
return (
|
|
151
|
-
<ScrollArea
|
|
151
|
+
<ScrollArea
|
|
152
|
+
id="tree-scroll"
|
|
153
|
+
revealId={focused ? rows[cursorIndex]?.id : undefined}
|
|
154
|
+
gestureWheel
|
|
155
|
+
>
|
|
152
156
|
<Tree
|
|
153
157
|
nodes={nodes}
|
|
154
158
|
expandedIds={expandedIds}
|
|
155
159
|
selectedId={focused ? rows[cursorIndex]?.id : selectedPath}
|
|
160
|
+
singleLine
|
|
156
161
|
flattenEmptyDirectories
|
|
157
162
|
showStatus
|
|
158
163
|
onSelect={(id) => {
|
|
@@ -161,6 +166,7 @@ export function ChangesFileTree({
|
|
|
161
166
|
if (index >= 0) setCursor(index);
|
|
162
167
|
onSelectFile(id);
|
|
163
168
|
}}
|
|
169
|
+
onDoubleSelect={(id) => onSelectFile(id, true)}
|
|
164
170
|
onToggle={toggle}
|
|
165
171
|
theme={theme}
|
|
166
172
|
/>
|
|
@@ -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
|
+
});
|