@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
|
@@ -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 {
|
|
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 {
|
|
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) =>
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
|
604
|
-
|
|
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 = `${
|
|
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
|
|
781
|
+
return (
|
|
656
782
|
<>
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
|
793
|
+
return (
|
|
667
794
|
<>
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|