@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
@@ -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 type { EditorGroup, EditorNode, EditorTab, SplitDirection } from "./editor-grid";
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={onSelect}
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
- useKeyboard((key) => {
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
- if (direction) {
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
- {isFile ? (
350
- <box ref={splitRef} style={{ flexShrink: 0, marginRight: 2 }}>
351
- <IconButton
352
- glyph="split"
353
- active={menuOpen}
354
- onPress={() => menuControl.toggleMenu(menuId)}
355
- theme={tokens}
356
- />
357
- </box>
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 style={{ flexGrow: 1, flexDirection: "column" }}>
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({ node, props }: { node: EditorNode; props: EditorGridProps }): React.ReactNode {
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 <EditorGroupPane group={node} props={props} />;
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
- <React.Fragment key={child.id}>
393
- {index > 0 ? (
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
- borderStyle: "single",
397
- border: [horizontal ? "left" : "top"],
398
- borderColor: tokens.border,
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
- ) : null}
402
- <box
403
- style={{
404
- flexDirection: "column",
405
- flexGrow: 1,
406
- flexBasis: 0,
407
- minWidth: 0,
408
- minHeight: 0,
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.text],
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.text],
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, and the fold controls (a band to fold) do not apply
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}