@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
@@ -8,15 +8,20 @@
8
8
  * so a drag can hit-test any row, unified or side by side.
9
9
  */
10
10
 
11
- import React, { useEffect, useMemo, useRef, useState } from "react";
12
- import { createTextAttributes, type KeyEvent, type ScrollBoxRenderable } from "@opentui/core";
11
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
12
+ import {
13
+ MacOSScrollAccel,
14
+ createTextAttributes,
15
+ type KeyEvent,
16
+ type ScrollBoxRenderable,
17
+ } from "@opentui/core";
13
18
  import type { Annotation, Thread } from "@cueloop/schema";
14
19
  import { diffRowText, fileChangeCounts, type DiffRow, type Mark } from "../view-diff";
15
20
  import type { TextSpan } from "../thread-selection";
16
21
  import type { QuickAction } from "../config";
17
22
  import type { Theme } from "../theme";
18
23
  import { useComponentTheme } from "./theme-context";
19
- import { IconButton } from "./primitives/IconButton";
24
+ import { useTooltip } from "./Tooltip";
20
25
  import { NERD } from "./primitives/icons";
21
26
  import { createIntralineResolver, type IntralineRun } from "../diff-intraline";
22
27
  import { highlightDiffRows, type SyntaxSpan } from "../diff-syntax";
@@ -39,9 +44,6 @@ const EMPTY_REJECTED: Set<number> = new Set();
39
44
  /** A rejected (curated-out) change row renders struck through and dimmed. */
40
45
  const REJECTED_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
41
46
 
42
- /** A file band renders its name in bold between an equal rule above and below. */
43
- const FILE_HEADER_ATTRIBUTES = createTextAttributes({ bold: true });
44
-
45
47
  /** The split-view gutter before a code line: caret bar, four-digit line number, a space, the sign, a space. */
46
48
  const GUTTER_COLUMNS = 8;
47
49
 
@@ -67,7 +69,7 @@ export interface DiffFoldControls {
67
69
  canExpand: (file: string) => boolean;
68
70
  onToggleCollapse: (file: string) => void;
69
71
  onToggleExpand: (file: string) => void;
70
- onCopyPath: (file: string) => void;
72
+ onCopyPath: (file: string) => boolean | Promise<boolean>;
71
73
  }
72
74
 
73
75
  export interface DiffContentViewProps {
@@ -104,6 +106,8 @@ export interface DiffContentViewProps {
104
106
  rejectedRows?: Set<number>;
105
107
  /** File-band chevron/copy/unfold actions; when absent the band shows no controls. */
106
108
  fold?: DiffFoldControls;
109
+ /** A single-file tab has no file-collapse chevron, but keeps copy and expand. */
110
+ showFileCollapse?: boolean;
107
111
  /** Per-file +/- counts from the base rows, so a collapsed file keeps its badge; else computed here. */
108
112
  fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
109
113
  /** Render old|new side by side instead of one inline column; the App gates this on zoom. */
@@ -163,21 +167,56 @@ function ExpandToggle({
163
167
  );
164
168
  }
165
169
 
166
- /**
167
- * A file band: chevron, bold name, and the added/removed counts between an equal rule above and
168
- * below. `fold` absent (story renders) drops the controls.
169
- */
170
+ /** Copy feedback stays at the control the user clicked. */
171
+ function CopyPathButton({
172
+ file,
173
+ onCopyPath,
174
+ marginRight,
175
+ tokens,
176
+ }: {
177
+ file: string;
178
+ onCopyPath: (file: string) => boolean | Promise<boolean>;
179
+ marginRight: number;
180
+ tokens: Theme;
181
+ }): React.ReactNode {
182
+ const { showTooltip, hideTooltip } = useTooltip();
183
+
184
+ return (
185
+ <box
186
+ onMouseUp={(event) => {
187
+ event.stopPropagation();
188
+ const { x, y } = event;
189
+
190
+ void Promise.resolve(onCopyPath(file)).then((copied) => {
191
+ showTooltip(copied ? "copied" : "copy failed", x, y);
192
+ });
193
+ }}
194
+ onMouseOver={(event) => {
195
+ showTooltip("Copy path", event.x, event.y);
196
+ }}
197
+ onMouseOut={hideTooltip}
198
+ style={{ flexShrink: 0, alignSelf: "center", marginRight }}
199
+ >
200
+ <text fg={tokens.textMuted}>{NERD.copy}</text>
201
+ </box>
202
+ );
203
+ }
204
+
205
+ /** A file band: clickable title and file actions between rules above and below. */
170
206
  function FileBand({
171
207
  row,
172
208
  stats,
173
209
  fold,
210
+ showFileCollapse,
174
211
  tokens,
175
212
  }: {
176
213
  row: DiffRow;
177
214
  stats: { additions: number; deletions: number } | undefined;
178
215
  fold: DiffFoldControls | undefined;
216
+ showFileCollapse: boolean;
179
217
  tokens: Theme;
180
218
  }): React.ReactNode {
219
+ const { showTooltip, hideTooltip } = useTooltip();
181
220
  const file = row.file;
182
221
  const collapsed = fold?.isCollapsed(file) ?? false;
183
222
  const expanded = fold?.isExpanded(file) ?? false;
@@ -187,32 +226,43 @@ function FileBand({
187
226
  <box style={{ borderStyle: "single", border: ["top", "bottom"], borderColor: tokens.border }}>
188
227
  <box style={{ flexDirection: "row", justifyContent: "space-between" }}>
189
228
  <box style={{ flexDirection: "row", flexShrink: 1, minWidth: 0 }}>
190
- {fold ? (
191
- <IconButton
192
- glyph={collapsed ? NERD.chevronRight : NERD.chevronDown}
193
- onPress={() => fold.onToggleCollapse(file)}
194
- tip={collapsed ? "Expand file" : "Collapse file"}
195
- marginRight={1}
196
- />
197
- ) : null}
198
- <text
199
- fg={tokens.text}
200
- attributes={FILE_HEADER_ATTRIBUTES}
201
- style={{ flexShrink: 1, minWidth: 0, wrapMode: "none" }}
229
+ <box
230
+ id={`file-title-${file}`}
231
+ onMouseUp={
232
+ fold && showFileCollapse
233
+ ? (event) => {
234
+ event.stopPropagation();
235
+ fold.onToggleCollapse(file);
236
+ showTooltip(collapsed ? "collapse" : "uncollapse", event.x, event.y);
237
+ }
238
+ : undefined
239
+ }
240
+ onMouseOver={
241
+ fold && showFileCollapse
242
+ ? (event) => showTooltip(collapsed ? "uncollapse" : "collapse", event.x, event.y)
243
+ : undefined
244
+ }
245
+ onMouseOut={fold && showFileCollapse ? hideTooltip : undefined}
246
+ style={{ flexShrink: 1, minWidth: 0 }}
202
247
  >
203
- {diffRowText(row)}
204
- </text>
248
+ <text
249
+ fg={collapsed ? tokens.textDim : tokens.textMuted}
250
+ style={{ flexShrink: 1, minWidth: 0, wrapMode: "none" }}
251
+ >
252
+ {diffRowText(row)}
253
+ </text>
254
+ </box>
205
255
  </box>
206
256
  <box style={{ flexDirection: "row", flexShrink: 0 }}>
207
257
  {stats ? (
208
258
  <FileCountsBadge stats={stats} tokens={tokens} marginRight={fold ? 1 : 0} />
209
259
  ) : null}
210
260
  {fold ? (
211
- <IconButton
212
- glyph={NERD.copy}
213
- onPress={() => fold.onCopyPath(file)}
214
- tip="Copy path"
261
+ <CopyPathButton
262
+ file={file}
263
+ onCopyPath={fold.onCopyPath}
215
264
  marginRight={canExpand ? 1 : 0}
265
+ tokens={tokens}
216
266
  />
217
267
  ) : null}
218
268
  {fold && canExpand ? (
@@ -497,6 +547,7 @@ export function DiffContentView({
497
547
  onExit,
498
548
  rejectedRows = EMPTY_REJECTED,
499
549
  fold,
550
+ showFileCollapse = true,
500
551
  fileStats: fileStatsProp,
501
552
  split = false,
502
553
  fileView = false,
@@ -504,6 +555,13 @@ export function DiffContentView({
504
555
  }: DiffContentViewProps): React.ReactNode {
505
556
  const tokens = useComponentTheme(theme);
506
557
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
558
+ const wheelAcceleration = useMemo(
559
+ () => new MacOSScrollAccel({ A: 0.4, tau: 4, maxMultiplier: 3 }),
560
+ [],
561
+ );
562
+ const verticalStepRef = useRef<
563
+ (from: number, to: number, direction: -1 | 1, targetY?: number) => boolean
564
+ >(() => false);
507
565
  const source: LineSource = {
508
566
  count: rows.length,
509
567
  textAt: (rowIndex) => diffRowText(rows[rowIndex]!),
@@ -545,6 +603,8 @@ export function DiffContentView({
545
603
  onComposingChange,
546
604
  onObserverBlocked,
547
605
  onCursorChange,
606
+ onVerticalStep: (from, to, direction, targetY) =>
607
+ verticalStepRef.current(from, to, direction, targetY),
548
608
  focusedAnnotationId,
549
609
  onFocusAnnotation,
550
610
  onAnnotate,
@@ -595,16 +655,80 @@ export function DiffContentView({
595
655
  overscan: OVERSCAN_LINES,
596
656
  });
597
657
 
598
- // an opening card shifts the layout, so the row it belongs to is revealed again; a
599
- // discussion focused from the rail is scrolled into view the same way
658
+ useEffect(() => {
659
+ const scrollbox = scrollRef.current;
660
+
661
+ if (scrollbox) scrollbox.verticalScrollBar.scrollStep = 1;
662
+ }, []);
663
+
664
+ verticalStepRef.current = (from, to, direction, targetY) => {
665
+ const scrollbox = scrollRef.current;
666
+
667
+ if (!scrollbox) return false;
668
+ let distance: number;
669
+
670
+ if (targetY !== undefined) {
671
+ const top = scrollbox.viewport.y;
672
+ const bottom = top + scrollbox.viewport.height - 1;
673
+
674
+ distance = direction === 1 ? targetY - bottom : top - targetY;
675
+ } else {
676
+ if (from === to) return false;
677
+ const item = layout.itemOfRow[to];
678
+ const target = item === undefined ? undefined : virtual.startOfIndex(item);
679
+
680
+ if (target === undefined) return false;
681
+ const top = scrollbox.scrollTop;
682
+ const bottom = top + scrollbox.viewport.height - 1;
683
+
684
+ distance = direction === 1 ? target - bottom : top - target;
685
+ }
686
+
687
+ if (distance <= 0) return false;
688
+ // Let the caret commit before scrolling the next visual row into view.
689
+ if (distance === 1) return false;
690
+ scrollbox.scrollBy(direction);
691
+
692
+ return distance > 1;
693
+ };
694
+
695
+ // An opening card or focused discussion reveals its owning item. Ordinary caret
696
+ // movement reveals the exact visual line, since an item's first wrapped line may
697
+ // sit above the viewport while the caret is already on screen.
600
698
  const revealItem = layout.itemOfRow[surface.revealBlockIndex];
601
699
 
602
700
  useEffect(() => {
603
- if (revealItem !== undefined) {
604
- virtual.scrollToIndex(revealItem, surface.compose ? "end" : "auto");
701
+ if (revealItem === undefined || (!surface.compose && !surface.focusedDiscussion)) return;
702
+ virtual.scrollToIndex(revealItem, surface.compose ? "end" : "auto");
703
+ // eslint-disable-next-line react-hooks/exhaustive-deps
704
+ }, [surface.revealBlockIndex, surface.compose, surface.focusedDiscussion]);
705
+
706
+ useLayoutEffect(() => {
707
+ if (revealItem === undefined || surface.compose || surface.focusedDiscussion) return;
708
+ const scrollbox = scrollRef.current;
709
+
710
+ if (!scrollbox) return;
711
+ const headY = surface.headVisualY();
712
+
713
+ if (headY !== undefined) {
714
+ const top = scrollbox.viewport.y;
715
+ const bottom = top + scrollbox.viewport.height - 1;
716
+
717
+ if (headY < top) scrollbox.scrollBy(-1);
718
+ else if (headY > bottom) scrollbox.scrollBy(1);
719
+
720
+ return;
605
721
  }
722
+ const start = virtual.startOfIndex(revealItem);
723
+
724
+ if (start === undefined) return;
725
+ const top = scrollbox.scrollTop;
726
+ const bottom = top + scrollbox.viewport.height - 1;
727
+
728
+ if (start < top) virtual.scrollToOffset(start);
729
+ else if (start > bottom) virtual.scrollToOffset(start - scrollbox.viewport.height + 1);
606
730
  // eslint-disable-next-line react-hooks/exhaustive-deps
607
- }, [surface.revealBlockIndex, surface.compose]);
731
+ }, [surface.revealBlockIndex, surface.head.char, surface.compose, surface.focusedDiscussion]);
608
732
 
609
733
  /**
610
734
  * The visual lines of one code row painted with gutter, colors, and marks; cards collected after.
@@ -638,9 +762,7 @@ export function DiffContentView({
638
762
  const isCaretRow = paintMarks && surface.head.blockIndex === rowIndex;
639
763
  // split view carries one number per side; the stacked view shows the old and new numbers together
640
764
  const splitLineNumber = row.kind === "del" ? row.oldLine : row.newLine;
641
- const splitGutter = `${isCaretRow ? "▎" : " "}${String(splitLineNumber ?? "").padStart(4, " ")} ${rowSign(row)} `;
642
- const barChar = isCaretRow ? "▎" : " ";
643
- const barColor = isCaretRow ? tokens.accent : tokens.textDim;
765
+ const splitGutter = `${String(splitLineNumber ?? "").padStart(4, " ")} ${rowSign(row)} `;
644
766
  const oldNumber =
645
767
  row.kind === "add"
646
768
  ? " ".repeat(numberWidth)
@@ -649,34 +771,44 @@ export function DiffContentView({
649
771
  row.kind === "del"
650
772
  ? " ".repeat(numberWidth)
651
773
  : String(row.newLine ?? "").padStart(numberWidth);
652
- const gutterFor = (lineIndex: number): React.ReactNode => {
774
+ const gutterFor = (lineIndex: number, isCaretLine: boolean): React.ReactNode => {
775
+ const bar = (
776
+ <span fg={isCaretLine ? tokens.accent : tokens.textDim}>{isCaretLine ? "▎" : " "}</span>
777
+ );
778
+
653
779
  if (fileView) {
654
780
  // a plain file has no old/new sides, so one line number reads like an ordinary file viewer
655
- return lineIndex === 0 ? (
781
+ return (
656
782
  <>
657
- <span fg={barColor}>{barChar}</span>
658
- <span fg={tokens.textDim}>{String(row.newLine ?? "").padStart(numberWidth)}</span>
659
- <span> </span>
783
+ {bar}
784
+ {lineIndex === 0 ? (
785
+ <span fg={tokens.textDim}>{String(row.newLine ?? "").padStart(numberWidth)} </span>
786
+ ) : (
787
+ " ".repeat(numberWidth + 1)
788
+ )}
660
789
  </>
661
- ) : (
662
- " ".repeat(numberWidth + 2)
663
790
  );
664
791
  }
665
792
  if (split) {
666
- return lineIndex === 0 ? (
793
+ return (
667
794
  <>
668
- <span fg={isCaretRow ? tokens.accent : tokens.textDim}>{splitGutter.slice(0, 6)}</span>
669
- <span fg={rowBaseColor(row, tokens)}>{splitGutter.slice(6)}</span>
795
+ {bar}
796
+ {lineIndex === 0 ? (
797
+ <>
798
+ <span fg={tokens.textDim}>{splitGutter.slice(0, 5)}</span>
799
+ <span fg={rowBaseColor(row, tokens)}>{splitGutter.slice(5)}</span>
800
+ </>
801
+ ) : (
802
+ " ".repeat(GUTTER_COLUMNS - 1)
803
+ )}
670
804
  </>
671
- ) : (
672
- " ".repeat(GUTTER_COLUMNS)
673
805
  );
674
806
  }
675
807
  // stacked: caret bar, old new (each tinted red/green on its side), then the change sign;
676
808
  // the numbers sit on the row's soft band, only the changed code carries the brighter backdrop
677
809
  return lineIndex === 0 ? (
678
810
  <>
679
- <span fg={barColor}>{barChar}</span>
811
+ {bar}
680
812
  <span fg={row.kind === "del" ? tokens.deletedForeground : tokens.textDim}>
681
813
  {oldNumber}
682
814
  </span>
@@ -689,7 +821,7 @@ export function DiffContentView({
689
821
  </>
690
822
  ) : (
691
823
  <>
692
- <span fg={barColor}>{barChar}</span>
824
+ {bar}
693
825
  {" ".repeat(unifiedGutterColumns - 1)}
694
826
  </>
695
827
  );
@@ -700,6 +832,9 @@ export function DiffContentView({
700
832
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
701
833
  const line = lines[lineIndex]!;
702
834
  const isLastLine = lineIndex === lines.length - 1;
835
+ const lineRanges = lineMarkRanges(ranges, line);
836
+ const isCaretLine =
837
+ isCaretRow && (text.length === 0 || lineRanges.some((range) => range.caretOnly));
703
838
 
704
839
  lineNodes.push(
705
840
  <box
@@ -708,10 +843,10 @@ export function DiffContentView({
708
843
  >
709
844
  <text
710
845
  selectable={false}
711
- fg={isCaretRow && lineIndex === 0 ? tokens.accent : tokens.textDim}
712
- style={{ flexShrink: 0, wrapMode: "none" }}
846
+ fg={isCaretLine ? tokens.accent : tokens.textDim}
847
+ style={{ flexShrink: 0, height: 1, alignSelf: "flex-start", wrapMode: "none" }}
713
848
  >
714
- {gutterFor(lineIndex)}
849
+ {gutterFor(lineIndex, isCaretLine)}
715
850
  </text>
716
851
  <text
717
852
  selectable={false}
@@ -722,7 +857,7 @@ export function DiffContentView({
722
857
  {codeLineSpans(
723
858
  text.slice(line.start, line.end),
724
859
  line.start,
725
- lineMarkRanges(ranges, line),
860
+ lineRanges,
726
861
  fgByColumn,
727
862
  emphasisBgByColumn,
728
863
  rejected,
@@ -746,7 +881,13 @@ export function DiffContentView({
746
881
  const headerNode = (row: DiffRow, rowIndex: number): React.ReactNode =>
747
882
  row.kind === "file" ? (
748
883
  <box key={rowIndex} id={`diff-row-${rowIndex}`}>
749
- <FileBand row={row} stats={fileStats.get(row.file)} fold={fold} tokens={tokens} />
884
+ <FileBand
885
+ row={row}
886
+ stats={fileStats.get(row.file)}
887
+ fold={fold}
888
+ showFileCollapse={showFileCollapse}
889
+ tokens={tokens}
890
+ />
750
891
  </box>
751
892
  ) : (
752
893
  <text
@@ -880,8 +1021,25 @@ export function DiffContentView({
880
1021
  id="diff-scroll"
881
1022
  ref={scrollRef}
882
1023
  style={{ flexGrow: 1 }}
1024
+ focusable={false}
883
1025
  focused={false}
1026
+ scrollAcceleration={wheelAcceleration}
884
1027
  verticalScrollbarOptions={{ visible: false }}
1028
+ onMouseScroll={(event) => {
1029
+ const scrollbox = scrollRef.current;
1030
+
1031
+ if (!scrollbox || !event.scroll || event.modifiers.shift) return;
1032
+ if (event.scroll.direction !== "up" && event.scroll.direction !== "down") return;
1033
+ const before = scrollbox.scrollTop;
1034
+ const followCaret = surface.prepareViewportScroll();
1035
+
1036
+ queueMicrotask(() => {
1037
+ const delta = scrollbox.scrollTop - before;
1038
+ const top = scrollbox.viewport.screenY;
1039
+
1040
+ followCaret(delta, top, top + scrollbox.viewport.height - 1);
1041
+ });
1042
+ }}
885
1043
  >
886
1044
  {materialized}
887
1045
  </scrollbox>
@@ -0,0 +1,118 @@
1
+ import { expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import type { Renderable } from "@opentui/core";
5
+ import { allowEventLoopUpdates } from "../test-support";
6
+ import { EditorGrid } from "./EditorGrid";
7
+ import { makeGroup, splitGroup, welcomeTab, type EditorNode } from "./editor-grid";
8
+ import { WelcomePlayground } from "./WelcomePlayground";
9
+
10
+ function findById(node: Renderable, id: string): Renderable | undefined {
11
+ if (node.id === id) return node;
12
+
13
+ for (const child of node.getChildren()) {
14
+ const found = findById(child, id);
15
+
16
+ if (found) return found;
17
+ }
18
+
19
+ return undefined;
20
+ }
21
+
22
+ function groupIds(node: EditorNode): string[] {
23
+ if (node.type === "group") return [node.id];
24
+
25
+ return node.children.flatMap(groupIds);
26
+ }
27
+
28
+ test("mixed splits keep each Welcome page inside its own tile", async () => {
29
+ const first = makeGroup([welcomeTab()]);
30
+ let tree: EditorNode = first;
31
+ let focusGroupId = first.id;
32
+
33
+ for (const direction of ["right", "up", "left", "down"] as const) {
34
+ const next = splitGroup(tree, focusGroupId, direction);
35
+
36
+ tree = next.tree;
37
+ focusGroupId = next.focusGroupId;
38
+ }
39
+
40
+ const setup = await testRender(
41
+ <EditorGrid
42
+ tree={tree}
43
+ focusedGroupId={focusGroupId}
44
+ onFocusGroup={() => {}}
45
+ onActivateTab={() => {}}
46
+ onCloseTab={() => {}}
47
+ onSplit={() => {}}
48
+ onZoom={() => {}}
49
+ zoomed={false}
50
+ renderTab={(_, focused) => <WelcomePlayground quickActions={[]} suspended={!focused} />}
51
+ />,
52
+ { width: 120, height: 48 },
53
+ );
54
+
55
+ allowEventLoopUpdates();
56
+ await setup.waitForVisualIdle();
57
+ expect(setup.renderer.listenerCount("frame")).toBeLessThan(10);
58
+ expect(setup.renderer.listenerCount("resize")).toBeLessThan(10);
59
+ expect(setup.renderer.keyInput.listenerCount("keypress")).toBeLessThan(10);
60
+
61
+ for (const id of groupIds(tree)) {
62
+ const group = findById(setup.renderer.root, `editor-group:${id}`);
63
+ const scroll = group && findById(group, "diff-scroll");
64
+
65
+ expect(group).toBeDefined();
66
+ expect(scroll).toBeDefined();
67
+ expect(scroll!.y).toBeGreaterThanOrEqual(group!.y + 2);
68
+ expect(scroll!.y + scroll!.height).toBeLessThanOrEqual(group!.y + group!.height);
69
+ }
70
+
71
+ setup.renderer.destroy();
72
+ });
73
+
74
+ test("Split is disabled at eight tiles and when a tile is too small", async () => {
75
+ const first = makeGroup([welcomeTab()]);
76
+ let tree: EditorNode = first;
77
+ let focusGroupId = first.id;
78
+
79
+ for (const direction of ["right", "up", "left", "down", "right", "up", "left"] as const) {
80
+ const next = splitGroup(tree, focusGroupId, direction);
81
+
82
+ tree = next.tree;
83
+ focusGroupId = next.focusGroupId;
84
+ }
85
+
86
+ for (const [candidate, width, height] of [
87
+ [tree, 240, 100],
88
+ [first, 40, 12],
89
+ ] as const) {
90
+ let splitCalls = 0;
91
+ const setup = await testRender(
92
+ <EditorGrid
93
+ tree={candidate}
94
+ focusedGroupId={first.id}
95
+ onFocusGroup={() => {}}
96
+ onActivateTab={() => {}}
97
+ onCloseTab={() => {}}
98
+ onSplit={() => {
99
+ splitCalls += 1;
100
+ }}
101
+ onZoom={() => {}}
102
+ zoomed={false}
103
+ renderTab={() => <text>body</text>}
104
+ />,
105
+ { width, height },
106
+ );
107
+
108
+ allowEventLoopUpdates();
109
+ await setup.waitForVisualIdle();
110
+ const button = findById(setup.renderer.root, `split-button:${first.id}`);
111
+
112
+ expect(button).toBeDefined();
113
+ await setup.mockMouse.click(button!.x, button!.y);
114
+ expect(setup.captureCharFrame()).not.toContain("right →");
115
+ expect(splitCalls).toBe(0);
116
+ setup.renderer.destroy();
117
+ }
118
+ });
@@ -57,3 +57,30 @@ export const SplitGroups: Story = {
57
57
  expectedColors: [DARK.textMuted, DARK.border],
58
58
  size: { width: 100, height: 16 },
59
59
  };
60
+
61
+ /** The rightmost group takes the remainder after whole-cell leading groups and divider cells. */
62
+ export const WholeCellSizing: Story = {
63
+ render: () => (
64
+ <box style={{ flexDirection: "column", width: 100, height: 16 }}>
65
+ <text fg={DARK.text}>Whole-cell split sizing</text>
66
+ <text fg={DARK.textMuted}>100 cells = 49 left + 1 divider + 50 right</text>
67
+ <text fg={DARK.textDim}>Fix the leading width; let the last group take the remainder.</text>
68
+ <box style={{ flexGrow: 1, minHeight: 0 }}>
69
+ <EditorGrid
70
+ tree={splitTree}
71
+ focusedGroupId={null}
72
+ onFocusGroup={noop}
73
+ onActivateTab={noop}
74
+ onCloseTab={noop}
75
+ onSplit={noop}
76
+ onZoom={noop}
77
+ zoomed={false}
78
+ renderTab={renderTab}
79
+ theme={DARK}
80
+ />
81
+ </box>
82
+ </box>
83
+ ),
84
+ expectedColors: [DARK.textMuted, DARK.border],
85
+ size: { width: 100, height: 16 },
86
+ };