@cueloop/client 0.1.0-alpha.66 → 0.1.0-alpha.68
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 +6 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +71 -39
- package/src/App.plan-review.test.tsx +20 -21
- package/src/App.readonly.test.tsx +7 -3
- package/src/App.test.tsx +73 -14
- package/src/App.tsx +375 -129
- package/src/App.walk.test.tsx +14 -15
- package/src/App.workbench.test.tsx +390 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +61 -0
- package/src/app-screens.tsx +176 -45
- package/src/app-view-model.ts +7 -65
- package/src/components/AnnotationCard.tsx +1 -1
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +47 -42
- package/src/components/AppShell.tsx +212 -100
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/ChangesColumn.tsx +49 -26
- package/src/components/DiffSheet.scroll.test.tsx +38 -55
- package/src/components/DiffSheet.split.test.tsx +140 -0
- package/src/components/DiffSheet.stories.tsx +55 -53
- package/src/components/DiffSheet.syntax.test.tsx +24 -7
- package/src/components/DiffSheet.tsx +607 -276
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +105 -0
- package/src/components/EditorGrid.tsx +338 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +78 -0
- package/src/components/InboxList.tsx +159 -30
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +82 -0
- package/src/components/PromptDialog.tsx +8 -1
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/SettingsDialog.tsx +2 -1
- package/src/components/SurfaceScrollbar.stories.tsx +34 -0
- package/src/components/SurfaceScrollbar.test.tsx +111 -0
- package/src/components/SurfaceScrollbar.tsx +93 -0
- package/src/components/ThreadFooter.stories.tsx +2 -2
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +12 -7
- package/src/components/ThreadView.tsx +62 -1440
- package/src/components/ThreadsSidebar.tsx +14 -1
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/WelcomeSurface.stories.tsx +12 -0
- package/src/components/WelcomeSurface.tsx +52 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
- package/src/components/diff-sheet-layout.test.ts +125 -55
- package/src/components/diff-sheet-layout.ts +3 -103
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +15 -0
- package/src/components/file-tree.ts +59 -4
- package/src/components/pages/Workspace.stories.tsx +2 -2
- package/src/components/primitives/IconButton.tsx +21 -2
- package/src/components/primitives/Tree.tsx +13 -4
- package/src/components/primitives/icons.ts +27 -4
- package/src/components/session-tree.test.ts +20 -0
- package/src/components/session-tree.ts +16 -2
- package/src/components/stories-app.tsx +2 -2
- package/src/config.test.ts +20 -49
- package/src/config.ts +26 -28
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-syntax.test.ts +16 -0
- package/src/diff-syntax.ts +6 -3
- package/src/discussions.ts +79 -0
- package/src/ghostty-terminal.test.ts +2 -1
- package/src/ghostty-terminal.ts +27 -4
- package/src/intent-dispatch.test.ts +31 -11
- package/src/intent-dispatch.ts +93 -48
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +3 -25
- package/src/keymap.ts +17 -18
- package/src/mark-runs.ts +120 -0
- package/src/pty.ts +10 -2
- package/src/run.ts +17 -7
- package/src/serve.test.ts +4 -8
- package/src/session-controller.curate.test.ts +75 -0
- package/src/session-controller.share.test.ts +1 -0
- package/src/session-controller.tree.test.ts +26 -0
- package/src/session-controller.ts +153 -29
- package/src/slash-palette.ts +83 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +15 -3
- package/src/test-support.ts +66 -15
- package/src/thread-chords.test.ts +25 -9
- package/src/thread-chords.ts +33 -8
- package/src/use-annotation-surface.tsx +837 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +14 -24
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/view-diff.ts +114 -0
- package/src/components/ReviewPanel.stories.tsx +0 -100
- package/src/components/ReviewPanel.tsx +0 -103
- package/src/components/ReviewRail.stories.tsx +0 -232
- package/src/components/ReviewRail.tsx +0 -327
- package/src/review-panel.test.ts +0 -113
- package/src/review-panel.ts +0 -83
|
@@ -1,83 +1,131 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The one app shell: a single header row over four full-height panes - Threads, Thread, Changes,
|
|
2
|
+
// Project - divided by straight rules; each header cell's thin bottom rule shares the divider gray so
|
|
3
|
+
// the whole header underline reads as one line joining the side rules.
|
|
4
|
+
// The right region (Changes + Project) toggles as a unit: the Project pane is the right sidebar and is
|
|
5
|
+
// always present when the region is on, Changes rides on top of it, and a thin rail holds the sidebar
|
|
6
|
+
// toggle when the region is closed. Each pane owns its own header controls; the thread header never does.
|
|
4
7
|
|
|
5
8
|
import React from "react";
|
|
9
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
6
10
|
import { DARK, type Theme } from "../theme";
|
|
7
|
-
import
|
|
11
|
+
import { PanelColumn } from "./PanelColumn";
|
|
12
|
+
import { IconButton } from "./primitives/IconButton";
|
|
13
|
+
import { NERD, HEADER_UNDERLINE_CHARS } from "./primitives/icons";
|
|
14
|
+
import { TooltipProvider } from "./Tooltip";
|
|
15
|
+
import { RootOverlayProvider } from "./RootOverlay";
|
|
16
|
+
|
|
17
|
+
export type ProjectPanelMode = "changes" | "tree";
|
|
8
18
|
|
|
9
19
|
export interface AppShellProps {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
sidebarOpen: boolean;
|
|
21
|
+
onToggleSidebar: () => void;
|
|
22
|
+
onOpenMenu: () => void;
|
|
23
|
+
threadsPanel: React.ReactNode;
|
|
24
|
+
/** The Thread header title; mirrors the selected sidebar thread, blank on a bare launch. */
|
|
25
|
+
threadTitle: string;
|
|
26
|
+
/** Owner actions at the right edge of the Thread header (Edit/Share) - the only thread-header controls. */
|
|
27
|
+
threadActions?: React.ReactNode;
|
|
28
|
+
threadPanel: React.ReactNode;
|
|
29
|
+
changesOpen: boolean;
|
|
30
|
+
projectOpen: boolean;
|
|
31
|
+
/** Toggle the Changes editor (also switches the Project tree to changed-files mode). */
|
|
32
|
+
onToggleChanges: () => void;
|
|
33
|
+
/** Toggle the Project tree to the full project view. */
|
|
34
|
+
onToggleProject: () => void;
|
|
35
|
+
/** Open or close the whole right region (the right sidebar). */
|
|
36
|
+
onToggleRight: () => void;
|
|
37
|
+
projectMode: ProjectPanelMode;
|
|
38
|
+
/** The Changes editor grid; rendered only when changesOpen. */
|
|
39
|
+
changesPanel?: React.ReactNode;
|
|
40
|
+
projectPanel: React.ReactNode;
|
|
41
|
+
/** Hide the Thread pane so Changes fills the middle (zoom); the sidebars stay. */
|
|
42
|
+
zoomHideThread?: boolean;
|
|
19
43
|
footer?: React.ReactNode;
|
|
44
|
+
children?: React.ReactNode;
|
|
20
45
|
theme?: Theme;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
interface PaneColumnProps {
|
|
24
|
-
width?: number;
|
|
25
|
-
grow?: boolean;
|
|
26
|
-
side?: BorderSides;
|
|
27
|
-
header: React.ReactNode;
|
|
28
|
-
body: React.ReactNode;
|
|
29
|
-
headerHeight: number;
|
|
30
|
-
theme: Theme;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function PaneColumn({
|
|
34
|
-
width,
|
|
35
|
-
grow,
|
|
36
|
-
side,
|
|
37
|
-
header,
|
|
38
|
-
body,
|
|
39
|
-
headerHeight,
|
|
40
|
-
theme,
|
|
41
|
-
}: PaneColumnProps): React.ReactNode {
|
|
42
|
-
return (
|
|
43
|
-
<box
|
|
44
|
-
style={{
|
|
45
|
-
flexDirection: "column",
|
|
46
|
-
...(grow ? { flexGrow: 1 } : { width }),
|
|
47
|
-
borderStyle: "single",
|
|
48
|
-
border: side ? [side] : [],
|
|
49
|
-
borderColor: theme.border,
|
|
50
|
-
}}
|
|
51
|
-
>
|
|
52
|
-
<box
|
|
53
|
-
style={{
|
|
54
|
-
height: headerHeight,
|
|
55
|
-
borderStyle: "single",
|
|
56
|
-
border: ["bottom"],
|
|
57
|
-
borderColor: theme.border,
|
|
58
|
-
}}
|
|
59
|
-
>
|
|
60
|
-
{header}
|
|
61
|
-
</box>
|
|
62
|
-
<box style={{ flexGrow: 1 }}>{body}</box>
|
|
63
|
-
</box>
|
|
64
|
-
);
|
|
46
|
+
threadsWidth?: number;
|
|
47
|
+
projectWidth?: number;
|
|
65
48
|
}
|
|
66
49
|
|
|
67
50
|
export function AppShell({
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
51
|
+
sidebarOpen,
|
|
52
|
+
onToggleSidebar,
|
|
53
|
+
onOpenMenu,
|
|
54
|
+
threadsPanel,
|
|
55
|
+
threadTitle,
|
|
56
|
+
threadActions,
|
|
57
|
+
threadPanel,
|
|
58
|
+
changesOpen,
|
|
59
|
+
projectOpen,
|
|
60
|
+
onToggleChanges,
|
|
61
|
+
onToggleProject,
|
|
62
|
+
onToggleRight,
|
|
63
|
+
projectMode,
|
|
64
|
+
changesPanel,
|
|
65
|
+
projectPanel,
|
|
66
|
+
zoomHideThread,
|
|
77
67
|
footer,
|
|
68
|
+
children,
|
|
78
69
|
theme,
|
|
70
|
+
threadsWidth = 30,
|
|
71
|
+
projectWidth = 32,
|
|
79
72
|
}: AppShellProps): React.ReactNode {
|
|
80
73
|
const tokens = theme ?? DARK;
|
|
74
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
75
|
+
// Thread and Changes would otherwise split the middle as two flexBasis-0 items, and Yoga adds the
|
|
76
|
+
// Changes left border on top of its share: at even widths the halves come out fractional, the
|
|
77
|
+
// Changes side rounds up, and the row overflows a cell under the Project border. Sizing the Thread
|
|
78
|
+
// pane to a whole number leaves Changes the lone flex item, which lays out exactly.
|
|
79
|
+
const collapsedRailWidth = 4;
|
|
80
|
+
const middleWidth =
|
|
81
|
+
terminalWidth -
|
|
82
|
+
(sidebarOpen ? threadsWidth : 0) -
|
|
83
|
+
(projectOpen ? projectWidth : collapsedRailWidth);
|
|
84
|
+
const threadPaneWidth =
|
|
85
|
+
changesOpen && !zoomHideThread ? Math.max(20, Math.floor(middleWidth / 2)) : undefined;
|
|
86
|
+
|
|
87
|
+
// gear + sidebar toggle + product mark: global chrome, in the Threads header when open, else at the
|
|
88
|
+
// left of the Thread header when the Threads pane is collapsed
|
|
89
|
+
const brandChrome = (
|
|
90
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
91
|
+
<box onMouseUp={onOpenMenu} style={{ paddingRight: 2 }}>
|
|
92
|
+
<text fg={tokens.textMuted}>{NERD.settings}</text>
|
|
93
|
+
</box>
|
|
94
|
+
<IconButton
|
|
95
|
+
glyph={sidebarOpen ? NERD.sidebarLeft : NERD.sidebarLeftOff}
|
|
96
|
+
onPress={onToggleSidebar}
|
|
97
|
+
tip="Toggle Threads"
|
|
98
|
+
marginRight={2}
|
|
99
|
+
theme={tokens}
|
|
100
|
+
/>
|
|
101
|
+
<text fg={tokens.accent}>cueloop</text>
|
|
102
|
+
</box>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const projectToggles = (
|
|
106
|
+
<box style={{ flexDirection: "row", flexShrink: 0, alignItems: "center" }}>
|
|
107
|
+
<IconButton
|
|
108
|
+
glyph="changes"
|
|
109
|
+
active={projectMode === "changes"}
|
|
110
|
+
onPress={onToggleChanges}
|
|
111
|
+
marginRight={2}
|
|
112
|
+
theme={tokens}
|
|
113
|
+
/>
|
|
114
|
+
<IconButton
|
|
115
|
+
glyph="project"
|
|
116
|
+
active={projectMode === "tree"}
|
|
117
|
+
onPress={onToggleProject}
|
|
118
|
+
marginRight={2}
|
|
119
|
+
theme={tokens}
|
|
120
|
+
/>
|
|
121
|
+
<IconButton
|
|
122
|
+
glyph={NERD.sidebarRight}
|
|
123
|
+
onPress={onToggleRight}
|
|
124
|
+
tip="Toggle Right Sidebar"
|
|
125
|
+
theme={tokens}
|
|
126
|
+
/>
|
|
127
|
+
</box>
|
|
128
|
+
);
|
|
81
129
|
|
|
82
130
|
return (
|
|
83
131
|
<box
|
|
@@ -88,40 +136,104 @@ export function AppShell({
|
|
|
88
136
|
backgroundColor: tokens.background,
|
|
89
137
|
}}
|
|
90
138
|
>
|
|
91
|
-
<
|
|
92
|
-
{
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
<RootOverlayProvider>
|
|
140
|
+
<TooltipProvider theme={tokens}>
|
|
141
|
+
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
142
|
+
{sidebarOpen ? (
|
|
143
|
+
<PanelColumn width={threadsWidth} border="right" header={brandChrome} theme={tokens}>
|
|
144
|
+
{threadsPanel}
|
|
145
|
+
</PanelColumn>
|
|
146
|
+
) : null}
|
|
147
|
+
{!zoomHideThread ? (
|
|
148
|
+
<PanelColumn
|
|
149
|
+
width={threadPaneWidth}
|
|
150
|
+
header={
|
|
151
|
+
<box style={{ flexDirection: "row" }}>
|
|
152
|
+
{!sidebarOpen ? <box style={{ paddingRight: 2 }}>{brandChrome}</box> : null}
|
|
153
|
+
{threadTitle ? <text fg={tokens.textDim}>{threadTitle}</text> : null}
|
|
154
|
+
</box>
|
|
155
|
+
}
|
|
156
|
+
headerRight={threadActions}
|
|
157
|
+
theme={tokens}
|
|
158
|
+
>
|
|
159
|
+
{threadPanel}
|
|
160
|
+
</PanelColumn>
|
|
161
|
+
) : null}
|
|
162
|
+
{changesOpen ? (
|
|
163
|
+
<box
|
|
164
|
+
style={{
|
|
165
|
+
flexDirection: "column",
|
|
166
|
+
flexGrow: 1,
|
|
167
|
+
flexBasis: 0,
|
|
168
|
+
minWidth: 0,
|
|
169
|
+
borderStyle: "single",
|
|
170
|
+
// zoom drops the Thread pane, whose sidebar rule already divides here, so drop this
|
|
171
|
+
// one then to avoid a double border
|
|
172
|
+
border: zoomHideThread ? [] : ["left"],
|
|
173
|
+
borderColor: tokens.border,
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
{changesPanel}
|
|
177
|
+
</box>
|
|
178
|
+
) : null}
|
|
179
|
+
{projectOpen ? (
|
|
180
|
+
<PanelColumn
|
|
181
|
+
width={projectWidth}
|
|
182
|
+
border="left"
|
|
183
|
+
header={null}
|
|
184
|
+
headerRight={projectToggles}
|
|
185
|
+
theme={tokens}
|
|
186
|
+
>
|
|
187
|
+
{projectPanel}
|
|
188
|
+
</PanelColumn>
|
|
189
|
+
) : (
|
|
190
|
+
// collapsed: a divider tick above the continuous header underline holds the reopen toggle;
|
|
191
|
+
// the rule lives in the header only, never running the pane's full height. The extra column
|
|
192
|
+
// of right padding keeps the reopen icon off the terminal's last column, which squeezes it
|
|
193
|
+
<box style={{ flexDirection: "column", width: 4 }}>
|
|
194
|
+
<box
|
|
195
|
+
customBorderChars={HEADER_UNDERLINE_CHARS}
|
|
196
|
+
style={{
|
|
197
|
+
flexDirection: "row",
|
|
198
|
+
alignItems: "center",
|
|
199
|
+
height: 2,
|
|
200
|
+
paddingRight: 1,
|
|
201
|
+
backgroundColor: tokens.panel,
|
|
202
|
+
borderStyle: "single",
|
|
203
|
+
border: ["bottom"],
|
|
204
|
+
borderColor: tokens.border,
|
|
205
|
+
}}
|
|
206
|
+
>
|
|
207
|
+
<text fg={tokens.border}>{"│"}</text>
|
|
208
|
+
<IconButton
|
|
209
|
+
glyph={NERD.sidebarRightOff}
|
|
210
|
+
onPress={onToggleRight}
|
|
211
|
+
tip="Toggle Right Sidebar"
|
|
212
|
+
marginLeft={1}
|
|
213
|
+
theme={tokens}
|
|
214
|
+
/>
|
|
215
|
+
</box>
|
|
216
|
+
</box>
|
|
217
|
+
)}
|
|
218
|
+
</box>
|
|
219
|
+
{footer !== undefined ? (
|
|
220
|
+
<box
|
|
221
|
+
style={{
|
|
222
|
+
flexDirection: "row",
|
|
223
|
+
height: 1,
|
|
224
|
+
paddingLeft: 1,
|
|
225
|
+
paddingRight: 1,
|
|
226
|
+
borderStyle: "single",
|
|
227
|
+
border: ["top"],
|
|
228
|
+
borderColor: tokens.border,
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
{footer}
|
|
232
|
+
</box>
|
|
233
|
+
) : null}
|
|
234
|
+
{children}
|
|
235
|
+
</TooltipProvider>
|
|
236
|
+
</RootOverlayProvider>
|
|
125
237
|
</box>
|
|
126
238
|
);
|
|
127
239
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: the Changes header controls keep their column when zoom hides the Thread
|
|
3
|
+
* pane. Thread and Changes used to split the middle as two flexBasis-0 items, and Yoga
|
|
4
|
+
* added the Changes left border on top of its share, so at even terminal widths the
|
|
5
|
+
* Changes pane overflowed a cell under the Project border and the zoom icon appeared to
|
|
6
|
+
* jump left on zoom. The shell now sizes the Thread pane to a whole number.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { AppShell } from "./AppShell";
|
|
13
|
+
import { EditorGrid } from "./EditorGrid";
|
|
14
|
+
import { changesTab, makeGroup } from "./editor-grid";
|
|
15
|
+
import { NERD } from "./primitives/icons";
|
|
16
|
+
import { allowEventLoopUpdates } from "../test-support";
|
|
17
|
+
|
|
18
|
+
const grid = makeGroup([changesTab()]);
|
|
19
|
+
|
|
20
|
+
async function zoomIconColumn(
|
|
21
|
+
width: number,
|
|
22
|
+
zoomed: boolean,
|
|
23
|
+
sidebarOpen: boolean,
|
|
24
|
+
): Promise<number> {
|
|
25
|
+
const setup = await testRender(
|
|
26
|
+
<AppShell
|
|
27
|
+
sidebarOpen={sidebarOpen}
|
|
28
|
+
onToggleSidebar={() => {}}
|
|
29
|
+
onOpenMenu={() => {}}
|
|
30
|
+
threadsPanel={<text>threads</text>}
|
|
31
|
+
threadTitle="thread"
|
|
32
|
+
threadPanel={<text>body</text>}
|
|
33
|
+
changesOpen
|
|
34
|
+
projectOpen
|
|
35
|
+
onToggleChanges={() => {}}
|
|
36
|
+
onToggleProject={() => {}}
|
|
37
|
+
onToggleRight={() => {}}
|
|
38
|
+
projectMode="changes"
|
|
39
|
+
zoomHideThread={zoomed}
|
|
40
|
+
projectPanel={<text>project</text>}
|
|
41
|
+
changesPanel={
|
|
42
|
+
<EditorGrid
|
|
43
|
+
tree={grid}
|
|
44
|
+
focusedGroupId={grid.id}
|
|
45
|
+
onFocusGroup={() => {}}
|
|
46
|
+
onActivateTab={() => {}}
|
|
47
|
+
onCloseTab={() => {}}
|
|
48
|
+
onSplit={() => {}}
|
|
49
|
+
onZoom={() => {}}
|
|
50
|
+
zoomed={zoomed}
|
|
51
|
+
renderTab={() => <text>diff</text>}
|
|
52
|
+
/>
|
|
53
|
+
}
|
|
54
|
+
/>,
|
|
55
|
+
{ width, height: 8 },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
allowEventLoopUpdates();
|
|
59
|
+
await setup.waitForVisualIdle();
|
|
60
|
+
const column = setup.captureCharFrame().split("\n")[0]!.indexOf(NERD.zoom);
|
|
61
|
+
|
|
62
|
+
setup.renderer.destroy();
|
|
63
|
+
|
|
64
|
+
return column;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("zoom icon in the shell", () => {
|
|
68
|
+
// even widths are where the fractional split used to overflow; odd widths never did
|
|
69
|
+
for (const width of [160, 161]) {
|
|
70
|
+
for (const sidebarOpen of [false, true]) {
|
|
71
|
+
test(`stays in its column when zoom toggles (width ${width}, sidebar ${sidebarOpen ? "open" : "closed"})`, async () => {
|
|
72
|
+
// Act
|
|
73
|
+
const unzoomed = await zoomIconColumn(width, false, sidebarOpen);
|
|
74
|
+
const zoomed = await zoomIconColumn(width, true, sidebarOpen);
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
77
|
+
expect(unzoomed).toBeGreaterThan(0);
|
|
78
|
+
expect(zoomed).toBe(unzoomed);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test("the icon clears the Project border by its padding in both states", async () => {
|
|
84
|
+
// Act
|
|
85
|
+
const setupColumns = await Promise.all([
|
|
86
|
+
zoomIconColumn(160, false, false),
|
|
87
|
+
zoomIconColumn(160, true, false),
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
// Assert - width 160, project pane 32: the border sits at 128 and the glyph one padding cell
|
|
91
|
+
// before it, the same inset the right-sidebar icon keeps from its own edge
|
|
92
|
+
for (const column of setupColumns) expect(128 - column).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -85,19 +85,24 @@ export interface ChangesColumnProps {
|
|
|
85
85
|
/** The path whose diff the sheet is scrolled to, highlighted in the tree. */
|
|
86
86
|
selectedPath?: string;
|
|
87
87
|
onSelectFile: (path: string) => void;
|
|
88
|
+
/** Comments per file path, shown as a dot-and-count badge on each entry. */
|
|
89
|
+
commentCounts?: ReadonlyMap<string, number>;
|
|
88
90
|
width?: number;
|
|
89
91
|
theme?: Theme;
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
/**
|
|
95
|
+
* The changed-files tree on its own, with no surrounding column frame: the body
|
|
96
|
+
* a workbench Project pane renders. A file click scrolls the diff to that file.
|
|
97
|
+
*/
|
|
98
|
+
export function ChangesFileTree({
|
|
94
99
|
files = [],
|
|
95
100
|
selectedPath,
|
|
96
101
|
onSelectFile,
|
|
97
|
-
|
|
102
|
+
commentCounts,
|
|
98
103
|
theme,
|
|
99
|
-
}: ChangesColumnProps): React.ReactNode {
|
|
100
|
-
const nodes = useMemo(() => buildFileTree(files), [files]);
|
|
104
|
+
}: Omit<ChangesColumnProps, "open" | "width">): React.ReactNode {
|
|
105
|
+
const nodes = useMemo(() => buildFileTree(files, commentCounts), [files, commentCounts]);
|
|
101
106
|
// every folder opens by default so each changed file is reachable; the user
|
|
102
107
|
// only ever names the folders they fold shut, so a new file set stays open
|
|
103
108
|
const [collapsedIds, setCollapsedIds] = useState<ReadonlySet<string>>(new Set());
|
|
@@ -109,6 +114,39 @@ export function ChangesColumn({
|
|
|
109
114
|
return expanded;
|
|
110
115
|
}, [nodes, collapsedIds]);
|
|
111
116
|
|
|
117
|
+
return (
|
|
118
|
+
<scrollbox style={{ flexGrow: 1 }} focused={false}>
|
|
119
|
+
<Tree
|
|
120
|
+
nodes={nodes}
|
|
121
|
+
expandedIds={expandedIds}
|
|
122
|
+
selectedId={selectedPath}
|
|
123
|
+
flattenEmptyDirectories
|
|
124
|
+
showStatus
|
|
125
|
+
onSelect={onSelectFile}
|
|
126
|
+
onToggle={(id) =>
|
|
127
|
+
setCollapsedIds((current) => {
|
|
128
|
+
const next = new Set(current);
|
|
129
|
+
|
|
130
|
+
if (next.has(id)) next.delete(id);
|
|
131
|
+
else next.add(id);
|
|
132
|
+
|
|
133
|
+
return next;
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
theme={theme}
|
|
137
|
+
/>
|
|
138
|
+
</scrollbox>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function ChangesColumn({
|
|
143
|
+
open,
|
|
144
|
+
files = [],
|
|
145
|
+
selectedPath,
|
|
146
|
+
onSelectFile,
|
|
147
|
+
width = 32,
|
|
148
|
+
theme,
|
|
149
|
+
}: ChangesColumnProps): React.ReactNode {
|
|
112
150
|
if (!open) return null;
|
|
113
151
|
|
|
114
152
|
return (
|
|
@@ -121,27 +159,12 @@ export function ChangesColumn({
|
|
|
121
159
|
borderColor: theme?.border,
|
|
122
160
|
}}
|
|
123
161
|
>
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
showStatus
|
|
131
|
-
onSelect={onSelectFile}
|
|
132
|
-
onToggle={(id) =>
|
|
133
|
-
setCollapsedIds((current) => {
|
|
134
|
-
const next = new Set(current);
|
|
135
|
-
|
|
136
|
-
if (next.has(id)) next.delete(id);
|
|
137
|
-
else next.add(id);
|
|
138
|
-
|
|
139
|
-
return next;
|
|
140
|
-
})
|
|
141
|
-
}
|
|
142
|
-
theme={theme}
|
|
143
|
-
/>
|
|
144
|
-
</scrollbox>
|
|
162
|
+
<ChangesFileTree
|
|
163
|
+
files={files}
|
|
164
|
+
selectedPath={selectedPath}
|
|
165
|
+
onSelectFile={onSelectFile}
|
|
166
|
+
theme={theme}
|
|
167
|
+
/>
|
|
145
168
|
</box>
|
|
146
169
|
);
|
|
147
170
|
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
/** Keyboard scroll is smooth:
|
|
1
|
+
/** Keyboard scroll is smooth: the caret-follow reveal never scrolls the caret off screen, nor rebounds. */
|
|
2
2
|
|
|
3
3
|
import { expect, test } from "bun:test";
|
|
4
|
-
import React
|
|
4
|
+
import React from "react";
|
|
5
5
|
import { testRender } from "@opentui/react/test-utils";
|
|
6
|
-
import { useKeyboard } from "@opentui/react";
|
|
7
6
|
import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
|
|
8
7
|
import type { Annotation } from "@cueloop/schema";
|
|
9
8
|
import { DiffSheet } from "./DiffSheet";
|
|
10
|
-
import { diffRows,
|
|
9
|
+
import { diffRows, marksByRows } from "../view-diff";
|
|
11
10
|
import { DARK } from "../theme";
|
|
11
|
+
import { annotationPaletteFor } from "../annotation-palette";
|
|
12
12
|
import { press } from "../test-support";
|
|
13
|
+
import { fixtureDiffSession } from "./story-fixtures";
|
|
14
|
+
|
|
15
|
+
const noop = (): void => {};
|
|
13
16
|
|
|
14
17
|
/** Find a renderable by id anywhere in the tree (getRenderable is not recursive). */
|
|
15
18
|
function findById(node: Renderable, id: string): Renderable | undefined {
|
|
@@ -46,26 +49,9 @@ function tallPatch(count: number): string {
|
|
|
46
49
|
].join("\n");
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
rows
|
|
52
|
-
annotations,
|
|
53
|
-
}: {
|
|
54
|
-
rows: DiffRow[];
|
|
55
|
-
annotations: Annotation[];
|
|
56
|
-
}): React.ReactNode {
|
|
57
|
-
const [cursor, setCursor] = useState(0);
|
|
58
|
-
|
|
59
|
-
useKeyboard((key) => {
|
|
60
|
-
if (key.name === "j") setCursor((current) => Math.min(rows.length - 1, current + 1));
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
return <DiffSheet rows={rows} cursor={cursor} annotations={annotations} />;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
test("scrolling down past wrapped annotation cards keeps the cursor pinned and the scroll monotonic", async () => {
|
|
67
|
-
// Arrange - a tall diff whose annotation bodies would wrap at this width; the
|
|
68
|
-
// wrapped card is exactly the layout the cursor-follow model used to mis-measure
|
|
52
|
+
test("walking the caret down past wrapped discussion cards keeps it on screen and the scroll monotonic", async () => {
|
|
53
|
+
// Arrange - a tall diff whose comment bodies wrap at this width, so the cards
|
|
54
|
+
// between rows are taller than one line
|
|
69
55
|
const rows = diffRows(tallPatch(40));
|
|
70
56
|
const longBody =
|
|
71
57
|
"this is a deliberately long annotation body that would wrap across the narrow test viewport";
|
|
@@ -73,39 +59,52 @@ test("scrolling down past wrapped annotation cards keeps the cursor pinned and t
|
|
|
73
59
|
id: `a${rowIndex}`,
|
|
74
60
|
kind: "comment",
|
|
75
61
|
body: longBody,
|
|
76
|
-
anchor: { quote: rows[rowIndex]!.text, prefix: "", suffix: "" },
|
|
62
|
+
anchor: { quote: rows[rowIndex]!.text.replace(/\n$/, ""), prefix: "", suffix: "" },
|
|
77
63
|
createdAt: "2026-01-01T00:00:00Z",
|
|
78
64
|
}));
|
|
79
|
-
const setup = await testRender(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
65
|
+
const setup = await testRender(
|
|
66
|
+
<DiffSheet
|
|
67
|
+
rows={rows}
|
|
68
|
+
session={fixtureDiffSession({ annotations })}
|
|
69
|
+
marks={marksByRows(annotations, rows)}
|
|
70
|
+
quickActions={[]}
|
|
71
|
+
observer={false}
|
|
72
|
+
onAnnotate={noop}
|
|
73
|
+
onReply={noop}
|
|
74
|
+
onUpdateAnnotation={noop}
|
|
75
|
+
onExit={noop}
|
|
76
|
+
/>,
|
|
77
|
+
{ width: 60, height: 12 },
|
|
78
|
+
);
|
|
83
79
|
|
|
84
80
|
await setup.waitForVisualIdle();
|
|
85
81
|
const found = findById(setup.renderer.root, "diff-scroll");
|
|
86
82
|
|
|
87
83
|
if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
|
|
88
84
|
const scrollbox = found;
|
|
85
|
+
const caretCell = annotationPaletteFor(DARK).caretCell;
|
|
89
86
|
|
|
90
|
-
// the
|
|
91
|
-
const
|
|
87
|
+
// the caret row is the one painting the caret cell
|
|
88
|
+
const caretScreenRow = (): number => {
|
|
92
89
|
const lines = setup.captureSpans().lines;
|
|
93
90
|
|
|
94
91
|
for (let index = 0; index < lines.length; index++) {
|
|
95
|
-
if (lines[index]!.spans.some((span) => hex(span.bg) ===
|
|
92
|
+
if (lines[index]!.spans.some((span) => hex(span.bg) === caretCell)) return index;
|
|
96
93
|
}
|
|
97
94
|
|
|
98
95
|
return -1;
|
|
99
96
|
};
|
|
100
97
|
|
|
101
|
-
// Act - walk the
|
|
98
|
+
// Act - walk the caret down through every code row, sampling the scroll each step
|
|
102
99
|
const scrollTops: number[] = [];
|
|
103
100
|
const screenRows: number[] = [];
|
|
101
|
+
const codeRowCount = rows.filter((row) => row.kind === "add").length;
|
|
104
102
|
|
|
105
|
-
for (let step = 0; step <
|
|
103
|
+
for (let step = 0; step < codeRowCount; step++) {
|
|
104
|
+
await setup.waitForVisualIdle();
|
|
106
105
|
scrollTops.push(scrollbox.scrollTop);
|
|
107
|
-
screenRows.push(
|
|
108
|
-
await press(setup, "
|
|
106
|
+
screenRows.push(caretScreenRow());
|
|
107
|
+
await press(setup, "down");
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
// Assert - scrollTop only ever grows going down (never rebounds/jitters)
|
|
@@ -113,26 +112,10 @@ test("scrolling down past wrapped annotation cards keeps the cursor pinned and t
|
|
|
113
112
|
expect(scrollTops[step]!).toBeGreaterThanOrEqual(scrollTops[step - 1]!);
|
|
114
113
|
}
|
|
115
114
|
|
|
116
|
-
// Assert - the
|
|
117
|
-
// the viewport as it passed each wrapped card)
|
|
115
|
+
// Assert - the caret never scrolls off screen while it walks past each wrapped card
|
|
118
116
|
expect(screenRows.every((row) => row >= 0)).toBe(true);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
// final clamp at the content end) the cursor holds ONE stable row near the
|
|
122
|
-
// bottom; pre-fix it drifted down a row per wrapped card
|
|
123
|
-
const maxScroll = scrollbox.scrollHeight - scrollbox.height;
|
|
124
|
-
const steadyRows = screenRows.filter(
|
|
125
|
-
(_, step) => scrollTops[step]! > 0 && scrollTops[step]! < maxScroll,
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
expect(steadyRows.length).toBeGreaterThan(0);
|
|
129
|
-
expect(new Set(steadyRows).size).toBe(1);
|
|
130
|
-
const pinnedRow = steadyRows[0]!;
|
|
131
|
-
const viewportBottom = scrollbox.height;
|
|
132
|
-
|
|
133
|
-
// the cursor sits a couple of rows inside the bottom edge, per the follow rule
|
|
134
|
-
expect(viewportBottom - pinnedRow).toBeGreaterThanOrEqual(2);
|
|
135
|
-
expect(viewportBottom - pinnedRow).toBeLessThanOrEqual(3);
|
|
117
|
+
// and the walk did scroll: the tall diff does not fit the viewport
|
|
118
|
+
expect(scrollTops[scrollTops.length - 1]!).toBeGreaterThan(0);
|
|
136
119
|
|
|
137
120
|
setup.renderer.destroy();
|
|
138
121
|
}, 25000);
|