@cueloop/client 0.1.0-alpha.91 → 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/AnnotatableFileView.test.tsx +48 -1
- 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/diff-syntax.test.ts +15 -2
- package/src/diff-syntax.ts +13 -5
- 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
|
@@ -3,21 +3,31 @@
|
|
|
3
3
|
// stays layout-only - a tab becomes a diff, a file's contents, or the aggregate Changes view upstream.
|
|
4
4
|
|
|
5
5
|
import React, { useEffect, useRef, useState } from "react";
|
|
6
|
-
import { useKeyboard } from "@opentui/react";
|
|
7
6
|
import type { BoxRenderable } from "@opentui/core";
|
|
7
|
+
import { isDoubleClick, type ClickStamp } from "../thread-selection";
|
|
8
8
|
import { DARK, type Theme } from "../theme";
|
|
9
9
|
import { useFrameMeasure } from "../use-frame-measure";
|
|
10
|
+
import { useSharedKeyboard } from "../use-shared-keyboard";
|
|
10
11
|
import { useRootOverlay } from "./RootOverlay";
|
|
11
12
|
import { useMenuControl } from "./menu-control";
|
|
12
13
|
import { IconButton } from "./primitives/IconButton";
|
|
13
14
|
import { NERD, HEADER_UNDERLINE_CHARS } from "./primitives/icons";
|
|
14
|
-
import
|
|
15
|
+
import {
|
|
16
|
+
countEditorGroups,
|
|
17
|
+
MAX_EDITOR_GROUPS,
|
|
18
|
+
type EditorBranch,
|
|
19
|
+
type EditorGroup,
|
|
20
|
+
type EditorNode,
|
|
21
|
+
type EditorTab,
|
|
22
|
+
type SplitDirection,
|
|
23
|
+
} from "./editor-grid";
|
|
15
24
|
|
|
16
25
|
export interface EditorGridProps {
|
|
17
26
|
tree: EditorNode;
|
|
18
27
|
focusedGroupId: string | null;
|
|
19
28
|
onFocusGroup: (groupId: string) => void;
|
|
20
29
|
onActivateTab: (groupId: string, tabId: string) => void;
|
|
30
|
+
onKeepTab?: (groupId: string, tabId: string) => void;
|
|
21
31
|
onCloseTab: (groupId: string, tabId: string) => void;
|
|
22
32
|
onSplit: (groupId: string, direction: SplitDirection) => void;
|
|
23
33
|
onZoom: () => void;
|
|
@@ -37,6 +47,14 @@ const SPLIT_ITEMS: ReadonlyArray<{ label: string; direction: SplitDirection; arr
|
|
|
37
47
|
];
|
|
38
48
|
|
|
39
49
|
const MENU_WIDTH = 12;
|
|
50
|
+
const MIN_GROUP_WIDTH = 24;
|
|
51
|
+
const MIN_GROUP_HEIGHT = 8;
|
|
52
|
+
|
|
53
|
+
function canSplitGroup(width: number, height: number, direction: SplitDirection): boolean {
|
|
54
|
+
return direction === "left" || direction === "right"
|
|
55
|
+
? width >= MIN_GROUP_WIDTH * 2 + 1
|
|
56
|
+
: height >= MIN_GROUP_HEIGHT * 2 + 1;
|
|
57
|
+
}
|
|
40
58
|
|
|
41
59
|
/** An arrow key maps straight to its split direction; other keys leave the menu untouched. */
|
|
42
60
|
function splitDirectionForKey(name: string): SplitDirection | null {
|
|
@@ -50,6 +68,7 @@ function EditorTabButton({
|
|
|
50
68
|
active,
|
|
51
69
|
commentCount,
|
|
52
70
|
onSelect,
|
|
71
|
+
onKeep,
|
|
53
72
|
onClose,
|
|
54
73
|
tokens,
|
|
55
74
|
}: {
|
|
@@ -58,15 +77,24 @@ function EditorTabButton({
|
|
|
58
77
|
/** Comments on this tab's file, shown as a dot-and-count badge; 0 shows nothing. */
|
|
59
78
|
commentCount: number;
|
|
60
79
|
onSelect: () => void;
|
|
80
|
+
onKeep?: () => void;
|
|
61
81
|
onClose: () => void;
|
|
62
82
|
tokens: Theme;
|
|
63
83
|
}): React.ReactNode {
|
|
64
84
|
const [hovered, setHovered] = useState(false);
|
|
85
|
+
const lastClick = useRef<ClickStamp | null>(null);
|
|
65
86
|
|
|
66
87
|
return (
|
|
67
88
|
<box
|
|
68
89
|
id={tab.id}
|
|
69
|
-
onMouseUp={
|
|
90
|
+
onMouseUp={(event) => {
|
|
91
|
+
const stamp = { time: Date.now(), x: event.x, y: event.y };
|
|
92
|
+
const doubleClick = isDoubleClick(lastClick.current, stamp);
|
|
93
|
+
|
|
94
|
+
lastClick.current = stamp;
|
|
95
|
+
onSelect();
|
|
96
|
+
if (doubleClick && tab.preview) onKeep?.();
|
|
97
|
+
}}
|
|
70
98
|
onMouseOver={() => setHovered(true)}
|
|
71
99
|
onMouseOut={() => setHovered(false)}
|
|
72
100
|
style={{
|
|
@@ -156,9 +184,11 @@ export function visibleTabWindow(
|
|
|
156
184
|
|
|
157
185
|
function SplitMenu({
|
|
158
186
|
onPick,
|
|
187
|
+
canPick,
|
|
159
188
|
tokens,
|
|
160
189
|
}: {
|
|
161
190
|
onPick: (direction: SplitDirection) => void;
|
|
191
|
+
canPick: (direction: SplitDirection) => boolean;
|
|
162
192
|
tokens: Theme;
|
|
163
193
|
}): React.ReactNode {
|
|
164
194
|
return (
|
|
@@ -175,10 +205,10 @@ function SplitMenu({
|
|
|
175
205
|
{SPLIT_ITEMS.map((item) => (
|
|
176
206
|
<box
|
|
177
207
|
key={item.direction}
|
|
178
|
-
onMouseUp={() => onPick(item.direction)}
|
|
208
|
+
onMouseUp={canPick(item.direction) ? () => onPick(item.direction) : undefined}
|
|
179
209
|
style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1 }}
|
|
180
210
|
>
|
|
181
|
-
<text fg={tokens.text}>{item.label}</text>
|
|
211
|
+
<text fg={canPick(item.direction) ? tokens.text : tokens.textDim}>{item.label}</text>
|
|
182
212
|
<box style={{ flexGrow: 1 }} />
|
|
183
213
|
<text fg={tokens.textDim}>{item.arrow}</text>
|
|
184
214
|
</box>
|
|
@@ -190,15 +220,28 @@ function SplitMenu({
|
|
|
190
220
|
function EditorGroupPane({
|
|
191
221
|
group,
|
|
192
222
|
props,
|
|
223
|
+
showZoom,
|
|
224
|
+
splitDisabled,
|
|
193
225
|
}: {
|
|
194
226
|
group: EditorGroup;
|
|
195
227
|
props: EditorGridProps;
|
|
228
|
+
showZoom: boolean;
|
|
229
|
+
splitDisabled: boolean;
|
|
196
230
|
}): React.ReactNode {
|
|
197
231
|
const tokens = props.theme ?? DARK;
|
|
198
232
|
const menuControl = useMenuControl();
|
|
199
233
|
const { setOverlay, clearOverlay } = useRootOverlay();
|
|
200
234
|
const menuId = `split:${group.id}`;
|
|
201
235
|
const menuOpen = menuControl.openMenuId === menuId;
|
|
236
|
+
const groupRef = useRef<BoxRenderable | null>(null);
|
|
237
|
+
const groupSize = useFrameMeasure(
|
|
238
|
+
() => ({ width: groupRef.current?.width ?? 0, height: groupRef.current?.height ?? 0 }),
|
|
239
|
+
(left, right) => left.width === right.width && left.height === right.height,
|
|
240
|
+
{ width: 0, height: 0 },
|
|
241
|
+
);
|
|
242
|
+
const canSplit = (direction: SplitDirection): boolean =>
|
|
243
|
+
!splitDisabled && canSplitGroup(groupSize.width, groupSize.height, direction);
|
|
244
|
+
const cannotSplit = !SPLIT_ITEMS.some((item) => canSplit(item.direction));
|
|
202
245
|
const splitRef = useRef<BoxRenderable | null>(null);
|
|
203
246
|
const anchor = useFrameMeasure(
|
|
204
247
|
() => ({
|
|
@@ -211,13 +254,13 @@ function EditorGroupPane({
|
|
|
211
254
|
menuOpen,
|
|
212
255
|
);
|
|
213
256
|
const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
|
|
214
|
-
const isFile = active?.kind === "file";
|
|
215
257
|
|
|
216
|
-
|
|
258
|
+
useSharedKeyboard((key) => {
|
|
217
259
|
if (!menuOpen) return;
|
|
218
260
|
if (key.name === "escape") return menuControl.closeMenu();
|
|
219
261
|
const direction = splitDirectionForKey(key.name);
|
|
220
|
-
|
|
262
|
+
|
|
263
|
+
if (direction && canSplit(direction)) {
|
|
221
264
|
props.onSplit(group.id, direction);
|
|
222
265
|
menuControl.closeMenu();
|
|
223
266
|
}
|
|
@@ -240,9 +283,11 @@ function EditorGroupPane({
|
|
|
240
283
|
>
|
|
241
284
|
<SplitMenu
|
|
242
285
|
onPick={(direction) => {
|
|
286
|
+
if (!canSplit(direction)) return;
|
|
243
287
|
props.onSplit(group.id, direction);
|
|
244
288
|
menuControl.closeMenu();
|
|
245
289
|
}}
|
|
290
|
+
canPick={canSplit}
|
|
246
291
|
tokens={tokens}
|
|
247
292
|
/>
|
|
248
293
|
</box>
|
|
@@ -251,7 +296,7 @@ function EditorGroupPane({
|
|
|
251
296
|
|
|
252
297
|
return () => clearOverlay("split-menu");
|
|
253
298
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
254
|
-
}, [menuOpen, anchor, tokens]);
|
|
299
|
+
}, [menuOpen, anchor, tokens, groupSize.width, groupSize.height, splitDisabled]);
|
|
255
300
|
// The strip windows its tabs rather than scrolling a nested scrollbox (which stalls the
|
|
256
301
|
// renderer inside this header): tabs keep the width of their names, only the run that fits
|
|
257
302
|
// renders, and the window follows the active tab so a tab opened past the edge is never hidden.
|
|
@@ -281,6 +326,8 @@ function EditorGroupPane({
|
|
|
281
326
|
|
|
282
327
|
return (
|
|
283
328
|
<box
|
|
329
|
+
id={`editor-group:${group.id}`}
|
|
330
|
+
ref={groupRef}
|
|
284
331
|
onMouseDown={() => props.onFocusGroup(group.id)}
|
|
285
332
|
style={{ flexDirection: "column", flexGrow: 1, flexBasis: 0, minWidth: 0, minHeight: 0 }}
|
|
286
333
|
>
|
|
@@ -326,6 +373,7 @@ function EditorGroupPane({
|
|
|
326
373
|
active={tab.id === active?.id}
|
|
327
374
|
commentCount={tab.path ? (props.commentCounts?.get(tab.path) ?? 0) : 0}
|
|
328
375
|
onSelect={() => props.onActivateTab(group.id, tab.id)}
|
|
376
|
+
onKeep={() => props.onKeepTab?.(group.id, tab.id)}
|
|
329
377
|
onClose={() => props.onCloseTab(group.id, tab.id)}
|
|
330
378
|
tokens={tokens}
|
|
331
379
|
/>
|
|
@@ -346,40 +394,97 @@ function EditorGroupPane({
|
|
|
346
394
|
</box>
|
|
347
395
|
<box style={{ flexDirection: "row", flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
|
|
348
396
|
{/* search sits out until it does something; split takes its place */}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
397
|
+
<box
|
|
398
|
+
id={`split-button:${group.id}`}
|
|
399
|
+
ref={splitRef}
|
|
400
|
+
style={{ flexShrink: 0, marginRight: showZoom ? 2 : 0 }}
|
|
401
|
+
>
|
|
402
|
+
<IconButton
|
|
403
|
+
glyph="split"
|
|
404
|
+
active={menuOpen}
|
|
405
|
+
disabled={cannotSplit}
|
|
406
|
+
onPress={() => menuControl.toggleMenu(menuId)}
|
|
407
|
+
tip={
|
|
408
|
+
splitDisabled
|
|
409
|
+
? "Maximum 8 editor groups"
|
|
410
|
+
: cannotSplit
|
|
411
|
+
? "Tile too small to split"
|
|
412
|
+
: undefined
|
|
413
|
+
}
|
|
414
|
+
theme={tokens}
|
|
415
|
+
/>
|
|
416
|
+
</box>
|
|
417
|
+
{showZoom ? (
|
|
418
|
+
<IconButton
|
|
419
|
+
glyph={NERD.zoom}
|
|
420
|
+
active={props.zoomed}
|
|
421
|
+
onPress={props.onZoom}
|
|
422
|
+
tip={props.zoomed ? "Zoom Out" : "Zoom In"}
|
|
423
|
+
theme={tokens}
|
|
424
|
+
/>
|
|
358
425
|
) : null}
|
|
359
|
-
<IconButton
|
|
360
|
-
glyph={NERD.zoom}
|
|
361
|
-
active={props.zoomed}
|
|
362
|
-
onPress={props.onZoom}
|
|
363
|
-
tip={props.zoomed ? "Zoom Out" : "Zoom In"}
|
|
364
|
-
theme={tokens}
|
|
365
|
-
/>
|
|
366
426
|
</box>
|
|
367
427
|
</box>
|
|
368
|
-
<box
|
|
428
|
+
<box
|
|
429
|
+
style={{ flexGrow: 1, flexBasis: 0, minHeight: 0, minWidth: 0, flexDirection: "column" }}
|
|
430
|
+
>
|
|
369
431
|
{active ? props.renderTab(active, props.focusedGroupId === group.id) : null}
|
|
370
432
|
</box>
|
|
371
433
|
</box>
|
|
372
434
|
);
|
|
373
435
|
}
|
|
374
436
|
|
|
375
|
-
function GridNode({
|
|
437
|
+
function GridNode({
|
|
438
|
+
node,
|
|
439
|
+
props,
|
|
440
|
+
showZoom,
|
|
441
|
+
splitDisabled,
|
|
442
|
+
}: {
|
|
443
|
+
node: EditorNode;
|
|
444
|
+
props: EditorGridProps;
|
|
445
|
+
showZoom: boolean;
|
|
446
|
+
splitDisabled: boolean;
|
|
447
|
+
}): React.ReactNode {
|
|
376
448
|
if (node.type === "group") {
|
|
377
|
-
return
|
|
449
|
+
return (
|
|
450
|
+
<EditorGroupPane
|
|
451
|
+
group={node}
|
|
452
|
+
props={props}
|
|
453
|
+
showZoom={showZoom}
|
|
454
|
+
splitDisabled={splitDisabled}
|
|
455
|
+
/>
|
|
456
|
+
);
|
|
378
457
|
}
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<GridBranchPane node={node} props={props} showZoom={showZoom} splitDisabled={splitDisabled} />
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function GridBranchPane({
|
|
465
|
+
node,
|
|
466
|
+
props,
|
|
467
|
+
showZoom,
|
|
468
|
+
splitDisabled,
|
|
469
|
+
}: {
|
|
470
|
+
node: EditorBranch;
|
|
471
|
+
props: EditorGridProps;
|
|
472
|
+
showZoom: boolean;
|
|
473
|
+
splitDisabled: boolean;
|
|
474
|
+
}): React.ReactNode {
|
|
379
475
|
const tokens = props.theme ?? DARK;
|
|
380
476
|
const horizontal = node.orientation === "horizontal";
|
|
477
|
+
const branchRef = useRef<BoxRenderable | null>(null);
|
|
478
|
+
const branchWidth = useFrameMeasure(
|
|
479
|
+
() => branchRef.current?.width ?? 0,
|
|
480
|
+
(left, right) => left === right,
|
|
481
|
+
0,
|
|
482
|
+
);
|
|
483
|
+
const leadingWidth = Math.floor((branchWidth - node.children.length + 1) / node.children.length);
|
|
484
|
+
|
|
381
485
|
return (
|
|
382
486
|
<box
|
|
487
|
+
ref={branchRef}
|
|
383
488
|
style={{
|
|
384
489
|
flexDirection: horizontal ? "row" : "column",
|
|
385
490
|
flexGrow: 1,
|
|
@@ -388,39 +493,54 @@ function GridNode({ node, props }: { node: EditorNode; props: EditorGridProps })
|
|
|
388
493
|
minHeight: 0,
|
|
389
494
|
}}
|
|
390
495
|
>
|
|
391
|
-
{node.children.map((child, index) =>
|
|
392
|
-
<
|
|
393
|
-
|
|
496
|
+
{node.children.map((child, index) => {
|
|
497
|
+
const fixedWidth = horizontal && index < node.children.length - 1 && leadingWidth > 0;
|
|
498
|
+
|
|
499
|
+
return (
|
|
500
|
+
<React.Fragment key={child.id}>
|
|
501
|
+
{index > 0 ? (
|
|
502
|
+
<box
|
|
503
|
+
style={{
|
|
504
|
+
borderStyle: "single",
|
|
505
|
+
border: [horizontal ? "left" : "top"],
|
|
506
|
+
borderColor: tokens.border,
|
|
507
|
+
}}
|
|
508
|
+
/>
|
|
509
|
+
) : null}
|
|
394
510
|
<box
|
|
395
511
|
style={{
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
512
|
+
flexDirection: "column",
|
|
513
|
+
// A fixed whole-cell share for leading horizontal groups leaves the last group
|
|
514
|
+
// the exact remainder. Equal fractional flex shares can overflow the right edge.
|
|
515
|
+
width: fixedWidth ? leadingWidth : undefined,
|
|
516
|
+
flexGrow: fixedWidth ? 0 : 1,
|
|
517
|
+
flexBasis: fixedWidth ? undefined : 0,
|
|
518
|
+
flexShrink: fixedWidth ? 0 : 1,
|
|
519
|
+
minWidth: 0,
|
|
520
|
+
minHeight: 0,
|
|
399
521
|
}}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
<GridNode node={child} props={props} />
|
|
412
|
-
</box>
|
|
413
|
-
</React.Fragment>
|
|
414
|
-
))}
|
|
522
|
+
>
|
|
523
|
+
<GridNode
|
|
524
|
+
node={child}
|
|
525
|
+
props={props}
|
|
526
|
+
showZoom={showZoom && index === (horizontal ? node.children.length - 1 : 0)}
|
|
527
|
+
splitDisabled={splitDisabled}
|
|
528
|
+
/>
|
|
529
|
+
</box>
|
|
530
|
+
</React.Fragment>
|
|
531
|
+
);
|
|
532
|
+
})}
|
|
415
533
|
</box>
|
|
416
534
|
);
|
|
417
535
|
}
|
|
418
536
|
|
|
419
537
|
/** The Changes pane body: the editor grid of groups and splits, each rendering its active tab. */
|
|
420
538
|
export function EditorGrid(props: EditorGridProps): React.ReactNode {
|
|
539
|
+
const splitDisabled = countEditorGroups(props.tree) >= MAX_EDITOR_GROUPS;
|
|
540
|
+
|
|
421
541
|
return (
|
|
422
542
|
<box style={{ flexDirection: "column", flexGrow: 1, minWidth: 0, minHeight: 0 }}>
|
|
423
|
-
<GridNode node={props.tree} props={props} />
|
|
543
|
+
<GridNode node={props.tree} props={props} showZoom splitDisabled={splitDisabled} />
|
|
424
544
|
</box>
|
|
425
545
|
);
|
|
426
546
|
}
|
|
@@ -9,7 +9,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
9
9
|
import { testRender } from "@opentui/react/test-utils";
|
|
10
10
|
import React from "react";
|
|
11
11
|
import { EditorGrid } from "./EditorGrid";
|
|
12
|
-
import { changesTab, fileTab, makeGroup } from "./editor-grid";
|
|
12
|
+
import { changesTab, fileTab, makeGroup, splitGroup } from "./editor-grid";
|
|
13
|
+
import { NERD } from "./primitives/icons";
|
|
13
14
|
import { allowEventLoopUpdates } from "../test-support";
|
|
14
15
|
|
|
15
16
|
const grid = makeGroup([changesTab(), fileTab("App.tsx", "src/App.tsx", "diff")]);
|
|
@@ -66,3 +67,66 @@ describe("zoom icon position", () => {
|
|
|
66
67
|
expect(active).toBe(inactive);
|
|
67
68
|
});
|
|
68
69
|
});
|
|
70
|
+
|
|
71
|
+
test("split stays in every header while zoom appears only in the upper-right group", async () => {
|
|
72
|
+
const first = makeGroup([changesTab()]);
|
|
73
|
+
const right = splitGroup(first, first.id, "right");
|
|
74
|
+
const upperRight = splitGroup(right.tree, right.focusGroupId, "up");
|
|
75
|
+
const setup = await testRender(
|
|
76
|
+
<EditorGrid
|
|
77
|
+
tree={upperRight.tree}
|
|
78
|
+
focusedGroupId={upperRight.focusGroupId}
|
|
79
|
+
onFocusGroup={() => {}}
|
|
80
|
+
onActivateTab={() => {}}
|
|
81
|
+
onCloseTab={() => {}}
|
|
82
|
+
onSplit={() => {}}
|
|
83
|
+
onZoom={() => {}}
|
|
84
|
+
zoomed={false}
|
|
85
|
+
renderTab={() => <text>body</text>}
|
|
86
|
+
/>,
|
|
87
|
+
{ width: 100, height: 16 },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
allowEventLoopUpdates();
|
|
91
|
+
await setup.waitForVisualIdle();
|
|
92
|
+
const rows = setup.captureCharFrame().split("\n");
|
|
93
|
+
const headers = rows.filter((row) => row.includes("split"));
|
|
94
|
+
|
|
95
|
+
expect([...rows.join("\n").matchAll(/split/g)]).toHaveLength(3);
|
|
96
|
+
expect(headers[0]).toContain(NERD.zoom);
|
|
97
|
+
expect(headers[1]).not.toContain(NERD.zoom);
|
|
98
|
+
expect(rows.filter((row) => row.includes(NERD.zoom))).toHaveLength(1);
|
|
99
|
+
setup.renderer.destroy();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("zoom keeps a full cell at the right edge of a narrow split", async () => {
|
|
103
|
+
const first = makeGroup([changesTab()]);
|
|
104
|
+
const split = splitGroup(first, first.id, "right");
|
|
105
|
+
|
|
106
|
+
for (const width of [50, 51, 100]) {
|
|
107
|
+
for (const zoomed of [false, true]) {
|
|
108
|
+
const setup = await testRender(
|
|
109
|
+
<EditorGrid
|
|
110
|
+
tree={split.tree}
|
|
111
|
+
focusedGroupId={split.focusGroupId}
|
|
112
|
+
onFocusGroup={() => {}}
|
|
113
|
+
onActivateTab={() => {}}
|
|
114
|
+
onCloseTab={() => {}}
|
|
115
|
+
onSplit={() => {}}
|
|
116
|
+
onZoom={() => {}}
|
|
117
|
+
zoomed={zoomed}
|
|
118
|
+
renderTab={() => <text>body</text>}
|
|
119
|
+
/>,
|
|
120
|
+
{ width, height: 8 },
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
allowEventLoopUpdates();
|
|
124
|
+
await setup.waitForVisualIdle();
|
|
125
|
+
const header = setup.captureCharFrame().split("\n")[0]!;
|
|
126
|
+
|
|
127
|
+
expect(header.match(/⛶/g)).toHaveLength(1);
|
|
128
|
+
expect(width - header.indexOf(NERD.zoom)).toBe(2);
|
|
129
|
+
setup.renderer.destroy();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/** File tabs keep path actions while only the aggregate Changes tab can fold a file. */
|
|
2
|
+
|
|
3
|
+
import { expect, test } from "bun:test";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
6
|
+
import { changesTab, fileTab } from "./editor-grid";
|
|
7
|
+
import { GridTabContent, type DiffSurfaceProps } from "./GridTabContent";
|
|
8
|
+
import { FIXTURE_PATCH, fixtureDiffSession } from "./story-fixtures";
|
|
9
|
+
import { NERD } from "./primitives/icons";
|
|
10
|
+
import { TooltipProvider } from "./Tooltip";
|
|
11
|
+
import { locateText, waitForText } from "../test-support";
|
|
12
|
+
import { DARK } from "../theme";
|
|
13
|
+
import { diffRows } from "../view-diff";
|
|
14
|
+
|
|
15
|
+
const noop = (): void => {};
|
|
16
|
+
const rows = diffRows(
|
|
17
|
+
FIXTURE_PATCH.replaceAll("src/store.ts", "src/store.txt").replace("-1,4 +1,4", "-1,3 +1,3"),
|
|
18
|
+
);
|
|
19
|
+
const surface: DiffSurfaceProps = {
|
|
20
|
+
session: fixtureDiffSession(),
|
|
21
|
+
quickActions: [],
|
|
22
|
+
observer: false,
|
|
23
|
+
onAnnotate: noop,
|
|
24
|
+
onReply: noop,
|
|
25
|
+
onUpdateAnnotation: noop,
|
|
26
|
+
onExit: noop,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
test("a single-file diff keeps copy and expand without a collapse action", async () => {
|
|
30
|
+
const copied: string[] = [];
|
|
31
|
+
const setup = await testRender(
|
|
32
|
+
<TooltipProvider theme={DARK}>
|
|
33
|
+
<GridTabContent
|
|
34
|
+
tab={fileTab("store.txt", "src/store.txt", "diff")}
|
|
35
|
+
rows={rows}
|
|
36
|
+
surface={surface}
|
|
37
|
+
rejectedRows={new Set()}
|
|
38
|
+
fold={{
|
|
39
|
+
isCollapsed: () => false,
|
|
40
|
+
isExpanded: () => false,
|
|
41
|
+
canExpand: () => true,
|
|
42
|
+
onToggleCollapse: noop,
|
|
43
|
+
onToggleExpand: noop,
|
|
44
|
+
onCopyPath: (file) => {
|
|
45
|
+
copied.push(file);
|
|
46
|
+
|
|
47
|
+
return true;
|
|
48
|
+
},
|
|
49
|
+
}}
|
|
50
|
+
dimmed={false}
|
|
51
|
+
readFile={async () => null}
|
|
52
|
+
onAddFileComment={noop}
|
|
53
|
+
theme={DARK}
|
|
54
|
+
/>
|
|
55
|
+
</TooltipProvider>,
|
|
56
|
+
{ width: 80, height: 12 },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
await waitForText(setup, "src/store.txt");
|
|
60
|
+
const frame = setup.captureCharFrame();
|
|
61
|
+
|
|
62
|
+
expect(frame).toContain(NERD.copy);
|
|
63
|
+
expect(frame).not.toContain("v src/store.txt");
|
|
64
|
+
expect(frame).toContain("expand");
|
|
65
|
+
const copy = locateText(setup, NERD.copy);
|
|
66
|
+
|
|
67
|
+
await setup.mockMouse.moveTo(copy.column, copy.row);
|
|
68
|
+
await waitForText(setup, "Copy path");
|
|
69
|
+
await setup.mockMouse.click(copy.column, copy.row);
|
|
70
|
+
expect(copied).toEqual(["src/store.txt"]);
|
|
71
|
+
await waitForText(setup, "copied");
|
|
72
|
+
await setup.waitForVisualIdle();
|
|
73
|
+
setup.renderer.destroy();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("the full Changes tab retains fold and expand controls", async () => {
|
|
77
|
+
const setup = await testRender(
|
|
78
|
+
<GridTabContent
|
|
79
|
+
tab={changesTab()}
|
|
80
|
+
rows={rows}
|
|
81
|
+
surface={surface}
|
|
82
|
+
rejectedRows={new Set()}
|
|
83
|
+
fold={{
|
|
84
|
+
isCollapsed: () => false,
|
|
85
|
+
isExpanded: () => false,
|
|
86
|
+
canExpand: () => true,
|
|
87
|
+
onToggleCollapse: noop,
|
|
88
|
+
onToggleExpand: noop,
|
|
89
|
+
onCopyPath: () => true,
|
|
90
|
+
}}
|
|
91
|
+
dimmed={false}
|
|
92
|
+
readFile={async () => null}
|
|
93
|
+
onAddFileComment={noop}
|
|
94
|
+
theme={DARK}
|
|
95
|
+
/>,
|
|
96
|
+
{ width: 80, height: 12 },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await waitForText(setup, "src/store.txt");
|
|
100
|
+
const frame = setup.captureCharFrame();
|
|
101
|
+
|
|
102
|
+
expect(frame).toContain("src/store.txt");
|
|
103
|
+
expect(frame).not.toContain("v src/store.txt");
|
|
104
|
+
expect(frame).toContain(NERD.copy);
|
|
105
|
+
expect(frame).toContain("expand");
|
|
106
|
+
await setup.waitForVisualIdle();
|
|
107
|
+
setup.renderer.destroy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("the file title tooltip follows its collapse action", async () => {
|
|
111
|
+
function TestChanges(): React.ReactNode {
|
|
112
|
+
const [collapsed, setCollapsed] = React.useState(false);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<TooltipProvider theme={DARK}>
|
|
116
|
+
<GridTabContent
|
|
117
|
+
tab={changesTab()}
|
|
118
|
+
rows={rows}
|
|
119
|
+
surface={surface}
|
|
120
|
+
rejectedRows={new Set()}
|
|
121
|
+
fold={{
|
|
122
|
+
isCollapsed: () => collapsed,
|
|
123
|
+
isExpanded: () => false,
|
|
124
|
+
canExpand: () => true,
|
|
125
|
+
onToggleCollapse: () => setCollapsed((current) => !current),
|
|
126
|
+
onToggleExpand: noop,
|
|
127
|
+
onCopyPath: () => true,
|
|
128
|
+
}}
|
|
129
|
+
dimmed={false}
|
|
130
|
+
readFile={async () => null}
|
|
131
|
+
onAddFileComment={noop}
|
|
132
|
+
theme={DARK}
|
|
133
|
+
/>
|
|
134
|
+
</TooltipProvider>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const setup = await testRender(<TestChanges />, { width: 80, height: 12 });
|
|
139
|
+
|
|
140
|
+
await waitForText(setup, "src/store.txt");
|
|
141
|
+
const title = locateText(setup, "src/store.txt");
|
|
142
|
+
|
|
143
|
+
await setup.mockMouse.moveTo(title.column, title.row);
|
|
144
|
+
await waitForText(setup, "collapse");
|
|
145
|
+
await setup.mockMouse.click(title.column, title.row);
|
|
146
|
+
await waitForText(setup, "uncollapse");
|
|
147
|
+
await setup.mockMouse.click(title.column, title.row);
|
|
148
|
+
await waitForText(setup, "collapse");
|
|
149
|
+
expect(setup.captureCharFrame()).not.toContain("uncollapse");
|
|
150
|
+
setup.renderer.destroy();
|
|
151
|
+
});
|
|
@@ -36,7 +36,7 @@ export const ChangesTab: Story = {
|
|
|
36
36
|
theme={DARK}
|
|
37
37
|
/>
|
|
38
38
|
),
|
|
39
|
-
expectedColors: [DARK.
|
|
39
|
+
expectedColors: [DARK.textMuted],
|
|
40
40
|
size: { width: 80, height: 18 },
|
|
41
41
|
};
|
|
42
42
|
|
|
@@ -54,6 +54,6 @@ export const FileDiffTab: Story = {
|
|
|
54
54
|
theme={DARK}
|
|
55
55
|
/>
|
|
56
56
|
),
|
|
57
|
-
expectedColors: [DARK.
|
|
57
|
+
expectedColors: [DARK.textMuted],
|
|
58
58
|
size: { width: 80, height: 18 },
|
|
59
59
|
};
|
|
@@ -45,6 +45,7 @@ export function ChangesTabBody(props: {
|
|
|
45
45
|
surface: DiffSurfaceProps;
|
|
46
46
|
rejectedRows: Set<number>;
|
|
47
47
|
fold?: DiffFoldControls;
|
|
48
|
+
showFileCollapse?: boolean;
|
|
48
49
|
fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
|
|
49
50
|
split?: boolean;
|
|
50
51
|
dimmed: boolean;
|
|
@@ -81,6 +82,7 @@ export function ChangesTabBody(props: {
|
|
|
81
82
|
{...props.surface}
|
|
82
83
|
rejectedRows={props.rejectedRows}
|
|
83
84
|
fold={props.fold}
|
|
85
|
+
showFileCollapse={props.showFileCollapse}
|
|
84
86
|
fileStats={props.fileStats}
|
|
85
87
|
split={props.split}
|
|
86
88
|
theme={props.dimmed ? dimmedTheme(props.theme) : undefined}
|
|
@@ -148,7 +150,7 @@ export function GridTabContent(props: {
|
|
|
148
150
|
);
|
|
149
151
|
}
|
|
150
152
|
// a single-file tab shows that file's rows alone, so its row indices are its own: comments and
|
|
151
|
-
// the caret report back in whole-diff indices
|
|
153
|
+
// the caret report back in whole-diff indices; a single-file tab omits only the collapse action
|
|
152
154
|
const fileRowIndices = props.rows.flatMap((row, index) => (row.file === tab.path ? [index] : []));
|
|
153
155
|
const rows = fileRowIndices.map((index) => props.rows[index]!);
|
|
154
156
|
const wholeIndex = (rowIndex: number): number => fileRowIndices[rowIndex] ?? rowIndex;
|
|
@@ -173,6 +175,8 @@ export function GridTabContent(props: {
|
|
|
173
175
|
}}
|
|
174
176
|
rejectedRows={rejectedRows}
|
|
175
177
|
fileStats={props.fileStats}
|
|
178
|
+
fold={props.fold}
|
|
179
|
+
showFileCollapse={false}
|
|
176
180
|
split={props.split}
|
|
177
181
|
dimmed={props.dimmed}
|
|
178
182
|
emptyBottomPadding={props.emptyBottomPadding}
|