@cueloop/client 0.1.0-alpha.74 → 0.1.0-alpha.76
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.focus.test.ts +147 -0
- package/src/App.regression.test.tsx +94 -0
- package/src/App.test.tsx +11 -8
- package/src/App.tsx +476 -220
- package/src/App.visual.test.tsx +57 -0
- package/src/App.workbench.test.tsx +41 -22
- package/src/app-screens.tsx +168 -123
- package/src/components/AppShell.brand.test.tsx +1 -1
- package/src/components/AppShell.tsx +14 -1
- package/src/components/ChangesColumn.tsx +40 -12
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/ConfirmDialog.tsx +8 -1
- package/src/components/DiffContentView.tsx +7 -1
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tsx +84 -26
- package/src/components/GridTabContent.tsx +10 -1
- package/src/components/PanelColumn.tsx +3 -0
- package/src/components/ProjectTreeView.test.tsx +39 -12
- package/src/components/ProjectTreeView.tsx +38 -10
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +16 -3
- package/src/components/RootOverlay.stories.tsx +1 -0
- package/src/components/RootOverlay.tsx +19 -6
- package/src/components/SettingsDialog.stories.tsx +3 -3
- package/src/components/SettingsDialog.tsx +3 -4
- package/src/components/ThreadFooter.test.tsx +12 -7
- package/src/components/ThreadFooter.tsx +18 -4
- package/src/components/ThreadTree.tsx +79 -36
- package/src/components/ThreadView.slash.test.tsx +36 -0
- package/src/components/ThreadView.tsx +7 -1
- package/src/components/WelcomePlayground.tsx +2 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +60 -51
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +5 -0
- package/src/components/primitives/icons.ts +1 -1
- package/src/components/quick-actions-editor.stories.tsx +2 -0
- package/src/components/quick-actions-editor.test.tsx +35 -1
- package/src/components/quick-actions-editor.tsx +23 -22
- package/src/components/session-tree.test.ts +3 -3
- package/src/components/session-tree.ts +1 -1
- package/src/config.ts +1 -0
- package/src/diff-intraline.ts +26 -5
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/run.ts +22 -2
- package/src/thread-chords.test.ts +33 -1
- package/src/thread-chords.ts +114 -29
- package/src/thread-controller.ts +4 -3
- package/src/use-annotation-surface.tsx +22 -0
- package/src/use-frame-measure.ts +4 -1
- package/src/use-settings-dialog.tsx +9 -7
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
import { ScrollArea } from "./ScrollArea";
|
|
7
7
|
import React, { useMemo, useState } from "react";
|
|
8
|
+
import { useKeyboard } from "@opentui/react";
|
|
8
9
|
import type { DiffFileContents, Thread } from "@cueloop/schema";
|
|
9
10
|
import type { Theme } from "../theme";
|
|
10
11
|
import type { DiffRow } from "../view-diff";
|
|
11
12
|
import { Tree } from "./primitives/Tree";
|
|
12
|
-
import { allFolderIds } from "./primitives/tree-model";
|
|
13
|
+
import { allFolderIds, flattenTree } from "./primitives/tree-model";
|
|
13
14
|
import { IconButton } from "./primitives/IconButton";
|
|
14
15
|
import { NERD } from "./primitives/icons";
|
|
15
16
|
import { buildFileTree } from "./file-tree";
|
|
@@ -88,6 +89,7 @@ export interface ChangesColumnProps {
|
|
|
88
89
|
onSelectFile: (path: string) => void;
|
|
89
90
|
/** Comments per file path, shown as a dot-and-count badge on each entry. */
|
|
90
91
|
commentCounts?: ReadonlyMap<string, number>;
|
|
92
|
+
focused?: boolean;
|
|
91
93
|
width?: number;
|
|
92
94
|
theme?: Theme;
|
|
93
95
|
}
|
|
@@ -101,12 +103,14 @@ export function ChangesFileTree({
|
|
|
101
103
|
selectedPath,
|
|
102
104
|
onSelectFile,
|
|
103
105
|
commentCounts,
|
|
106
|
+
focused = false,
|
|
104
107
|
theme,
|
|
105
108
|
}: Omit<ChangesColumnProps, "open" | "width">): React.ReactNode {
|
|
106
109
|
const nodes = useMemo(() => buildFileTree(files, commentCounts), [files, commentCounts]);
|
|
107
110
|
// every folder opens by default so each changed file is reachable; the user
|
|
108
111
|
// only ever names the folders they fold shut, so a new file set stays open
|
|
109
112
|
const [collapsedIds, setCollapsedIds] = useState<ReadonlySet<string>>(new Set());
|
|
113
|
+
const [cursor, setCursor] = useState(0);
|
|
110
114
|
const expandedIds = useMemo(() => {
|
|
111
115
|
const expanded = allFolderIds(nodes);
|
|
112
116
|
|
|
@@ -114,26 +118,50 @@ export function ChangesFileTree({
|
|
|
114
118
|
|
|
115
119
|
return expanded;
|
|
116
120
|
}, [nodes, collapsedIds]);
|
|
121
|
+
const rows = useMemo(
|
|
122
|
+
() => flattenTree(nodes, { expandedIds, flattenEmptyDirectories: true }),
|
|
123
|
+
[nodes, expandedIds],
|
|
124
|
+
);
|
|
125
|
+
const cursorIndex = Math.min(cursor, Math.max(0, rows.length - 1));
|
|
126
|
+
|
|
127
|
+
const toggle = (id: string): void =>
|
|
128
|
+
setCollapsedIds((current) => {
|
|
129
|
+
const next = new Set(current);
|
|
130
|
+
|
|
131
|
+
if (next.has(id)) next.delete(id);
|
|
132
|
+
else next.add(id);
|
|
133
|
+
|
|
134
|
+
return next;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
useKeyboard((key) => {
|
|
138
|
+
if (!focused || rows.length === 0) return;
|
|
139
|
+
if (key.name === "j" || key.name === "down")
|
|
140
|
+
return setCursor(Math.min(cursorIndex + 1, rows.length - 1));
|
|
141
|
+
if (key.name === "k" || key.name === "up") return setCursor(Math.max(cursorIndex - 1, 0));
|
|
142
|
+
const row = rows[cursorIndex];
|
|
143
|
+
if (!row) return;
|
|
144
|
+
if (key.name === "tab" || key.name === "return" || key.name === "enter")
|
|
145
|
+
return row.isFolder ? toggle(row.id) : onSelectFile(row.id);
|
|
146
|
+
if (key.name === "l" && row.isFolder && collapsedIds.has(row.id)) return toggle(row.id);
|
|
147
|
+
if (key.name === "h" && row.isFolder && !collapsedIds.has(row.id)) return toggle(row.id);
|
|
148
|
+
});
|
|
117
149
|
|
|
118
150
|
return (
|
|
119
151
|
<ScrollArea>
|
|
120
152
|
<Tree
|
|
121
153
|
nodes={nodes}
|
|
122
154
|
expandedIds={expandedIds}
|
|
123
|
-
selectedId={selectedPath}
|
|
155
|
+
selectedId={focused ? rows[cursorIndex]?.id : selectedPath}
|
|
124
156
|
flattenEmptyDirectories
|
|
125
157
|
showStatus
|
|
126
|
-
onSelect={
|
|
127
|
-
|
|
128
|
-
setCollapsedIds((current) => {
|
|
129
|
-
const next = new Set(current);
|
|
130
|
-
|
|
131
|
-
if (next.has(id)) next.delete(id);
|
|
132
|
-
else next.add(id);
|
|
158
|
+
onSelect={(id) => {
|
|
159
|
+
const index = rows.findIndex((row) => row.id === id);
|
|
133
160
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
161
|
+
if (index >= 0) setCursor(index);
|
|
162
|
+
onSelectFile(id);
|
|
163
|
+
}}
|
|
164
|
+
onToggle={toggle}
|
|
137
165
|
theme={theme}
|
|
138
166
|
/>
|
|
139
167
|
</ScrollArea>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The changed-files tree navigates by keyboard when focused: j/k move the cursor, tab folds a folder
|
|
2
|
+
* or opens a file. */
|
|
3
|
+
|
|
4
|
+
import { test, expect } from "bun:test";
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
7
|
+
import type { DiffFileContents } from "@cueloop/schema";
|
|
8
|
+
import { ChangesFileTree } from "./ChangesColumn";
|
|
9
|
+
import { settle, waitForText } from "../test-support";
|
|
10
|
+
import { DARK } from "../theme";
|
|
11
|
+
|
|
12
|
+
const file = (path: string): DiffFileContents => ({
|
|
13
|
+
path,
|
|
14
|
+
oldContents: "",
|
|
15
|
+
newContents: "x",
|
|
16
|
+
status: "modified",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const files = [file("src/a.ts"), file("src/b.ts")];
|
|
20
|
+
|
|
21
|
+
test("j moves onto a file and tab opens it", async () => {
|
|
22
|
+
const opened: string[] = [];
|
|
23
|
+
const setup = await testRender(
|
|
24
|
+
<ChangesFileTree
|
|
25
|
+
files={files}
|
|
26
|
+
onSelectFile={(path) => opened.push(path)}
|
|
27
|
+
focused
|
|
28
|
+
theme={DARK}
|
|
29
|
+
/>,
|
|
30
|
+
{ width: 40, height: 12 },
|
|
31
|
+
);
|
|
32
|
+
await settle(setup);
|
|
33
|
+
await waitForText(setup, "a.ts");
|
|
34
|
+
|
|
35
|
+
// folders open by default; cursor starts on the "src" folder, j steps onto a.ts
|
|
36
|
+
setup.mockInput.pressKey("j");
|
|
37
|
+
await settle(setup);
|
|
38
|
+
setup.mockInput.pressKey("TAB");
|
|
39
|
+
await settle(setup);
|
|
40
|
+
|
|
41
|
+
expect(opened).toEqual(["src/a.ts"]);
|
|
42
|
+
|
|
43
|
+
setup.renderer.destroy();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("without focus the keyboard opens nothing", async () => {
|
|
47
|
+
const opened: string[] = [];
|
|
48
|
+
const setup = await testRender(
|
|
49
|
+
<ChangesFileTree files={files} onSelectFile={(path) => opened.push(path)} theme={DARK} />,
|
|
50
|
+
{ width: 40, height: 12 },
|
|
51
|
+
);
|
|
52
|
+
await settle(setup);
|
|
53
|
+
await waitForText(setup, "a.ts");
|
|
54
|
+
|
|
55
|
+
setup.mockInput.pressKey("j");
|
|
56
|
+
setup.mockInput.pressKey("TAB");
|
|
57
|
+
await settle(setup);
|
|
58
|
+
expect(opened).toEqual([]);
|
|
59
|
+
|
|
60
|
+
setup.renderer.destroy();
|
|
61
|
+
});
|
|
@@ -40,7 +40,14 @@ export function ConfirmDialog({
|
|
|
40
40
|
if (!isOpen) return null;
|
|
41
41
|
|
|
42
42
|
return (
|
|
43
|
-
<Dialog
|
|
43
|
+
<Dialog
|
|
44
|
+
isOpen
|
|
45
|
+
title={title}
|
|
46
|
+
width={Math.min(54, terminalWidth - 6)}
|
|
47
|
+
height={7}
|
|
48
|
+
onDismiss={onCancel}
|
|
49
|
+
theme={theme}
|
|
50
|
+
>
|
|
44
51
|
<box
|
|
45
52
|
style={{
|
|
46
53
|
flexDirection: "column",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
-
import { createTextAttributes, type ScrollBoxRenderable } from "@opentui/core";
|
|
12
|
+
import { createTextAttributes, type KeyEvent, type ScrollBoxRenderable } from "@opentui/core";
|
|
13
13
|
import type { Thread } from "@cueloop/schema";
|
|
14
14
|
import { diffRowText, fileChangeCounts, type DiffRow, type Mark } from "../view-diff";
|
|
15
15
|
import type { TextSpan } from "../thread-selection";
|
|
@@ -95,6 +95,8 @@ export interface DiffContentViewProps {
|
|
|
95
95
|
onAnnotate: (span: TextSpan, body: string) => void;
|
|
96
96
|
onReply: (rootAnnotationId: string, body: string) => void;
|
|
97
97
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
98
|
+
leaderCombos?: readonly string[];
|
|
99
|
+
onLeaderCommand?: (key: KeyEvent) => void;
|
|
98
100
|
onExit: () => void;
|
|
99
101
|
/** Row indices the owner rejected during curation; drawn struck through. */
|
|
100
102
|
rejectedRows?: Set<number>;
|
|
@@ -487,6 +489,8 @@ export function DiffContentView({
|
|
|
487
489
|
onAnnotate,
|
|
488
490
|
onReply,
|
|
489
491
|
onUpdateAnnotation,
|
|
492
|
+
leaderCombos,
|
|
493
|
+
onLeaderCommand,
|
|
490
494
|
onExit,
|
|
491
495
|
rejectedRows = EMPTY_REJECTED,
|
|
492
496
|
fold,
|
|
@@ -520,6 +524,8 @@ export function DiffContentView({
|
|
|
520
524
|
onAnnotate,
|
|
521
525
|
onReply,
|
|
522
526
|
onUpdateAnnotation,
|
|
527
|
+
leaderCombos,
|
|
528
|
+
onLeaderCommand,
|
|
523
529
|
onExit,
|
|
524
530
|
});
|
|
525
531
|
const { palette } = surface;
|
|
@@ -160,9 +160,9 @@ export function DiscussionMarkerRail({
|
|
|
160
160
|
// render the preview at the app root (above every pane rule); OpenTUI has no z-index, so an
|
|
161
161
|
// absolute box inside this pane would be sliced by the next pane's border
|
|
162
162
|
useEffect(() => {
|
|
163
|
-
setOverlay(preview());
|
|
163
|
+
setOverlay("discussion-preview", preview());
|
|
164
164
|
|
|
165
|
-
return () => clearOverlay();
|
|
165
|
+
return () => clearOverlay("discussion-preview");
|
|
166
166
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
167
|
}, [hovered, discussions, tokens]);
|
|
168
168
|
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
// editor group with its own tab strip and header controls. The caller supplies renderTab so the grid
|
|
3
3
|
// stays layout-only - a tab becomes a diff, a file's contents, or the aggregate Changes view upstream.
|
|
4
4
|
|
|
5
|
-
import React, { useRef, useState } from "react";
|
|
5
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { useKeyboard } from "@opentui/react";
|
|
6
7
|
import type { BoxRenderable } from "@opentui/core";
|
|
7
8
|
import { DARK, type Theme } from "../theme";
|
|
8
9
|
import { useFrameMeasure } from "../use-frame-measure";
|
|
10
|
+
import { useRootOverlay } from "./RootOverlay";
|
|
11
|
+
import { useMenuControl } from "./menu-control";
|
|
9
12
|
import { IconButton } from "./primitives/IconButton";
|
|
10
13
|
import { NERD, HEADER_UNDERLINE_CHARS } from "./primitives/icons";
|
|
11
14
|
import type { EditorGroup, EditorNode, EditorTab, SplitDirection } from "./editor-grid";
|
|
@@ -27,12 +30,21 @@ export interface EditorGridProps {
|
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const SPLIT_ITEMS: ReadonlyArray<{ label: string; direction: SplitDirection; arrow: string }> = [
|
|
30
|
-
{ label: "
|
|
31
|
-
{ label: "
|
|
32
|
-
{ label: "
|
|
33
|
-
{ label: "
|
|
33
|
+
{ label: "left", direction: "left", arrow: "←" },
|
|
34
|
+
{ label: "right", direction: "right", arrow: "→" },
|
|
35
|
+
{ label: "up", direction: "up", arrow: "↑" },
|
|
36
|
+
{ label: "down", direction: "down", arrow: "↓" },
|
|
34
37
|
];
|
|
35
38
|
|
|
39
|
+
const MENU_WIDTH = 12;
|
|
40
|
+
|
|
41
|
+
/** An arrow key maps straight to its split direction; other keys leave the menu untouched. */
|
|
42
|
+
function splitDirectionForKey(name: string): SplitDirection | null {
|
|
43
|
+
const item = SPLIT_ITEMS.find((entry) => entry.direction === name);
|
|
44
|
+
|
|
45
|
+
return item ? item.direction : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
function EditorTabButton({
|
|
37
49
|
tab,
|
|
38
50
|
active,
|
|
@@ -151,13 +163,13 @@ function SplitMenu({
|
|
|
151
163
|
}): React.ReactNode {
|
|
152
164
|
return (
|
|
153
165
|
<box
|
|
166
|
+
onMouseUp={(event) => event.stopPropagation()}
|
|
154
167
|
style={{
|
|
168
|
+
width: MENU_WIDTH,
|
|
155
169
|
flexDirection: "column",
|
|
156
|
-
flexShrink: 0,
|
|
157
170
|
borderStyle: "single",
|
|
158
171
|
borderColor: tokens.border,
|
|
159
|
-
|
|
160
|
-
backgroundColor: tokens.panel,
|
|
172
|
+
backgroundColor: tokens.elevated,
|
|
161
173
|
}}
|
|
162
174
|
>
|
|
163
175
|
{SPLIT_ITEMS.map((item) => (
|
|
@@ -168,7 +180,7 @@ function SplitMenu({
|
|
|
168
180
|
>
|
|
169
181
|
<text fg={tokens.text}>{item.label}</text>
|
|
170
182
|
<box style={{ flexGrow: 1 }} />
|
|
171
|
-
<text fg={tokens.textDim}>{
|
|
183
|
+
<text fg={tokens.textDim}>{item.arrow}</text>
|
|
172
184
|
</box>
|
|
173
185
|
))}
|
|
174
186
|
</box>
|
|
@@ -183,9 +195,63 @@ function EditorGroupPane({
|
|
|
183
195
|
props: EditorGridProps;
|
|
184
196
|
}): React.ReactNode {
|
|
185
197
|
const tokens = props.theme ?? DARK;
|
|
186
|
-
const
|
|
198
|
+
const menuControl = useMenuControl();
|
|
199
|
+
const { setOverlay, clearOverlay } = useRootOverlay();
|
|
200
|
+
const menuId = `split:${group.id}`;
|
|
201
|
+
const menuOpen = menuControl.openMenuId === menuId;
|
|
202
|
+
const splitRef = useRef<BoxRenderable | null>(null);
|
|
203
|
+
const anchor = useFrameMeasure(
|
|
204
|
+
() => ({
|
|
205
|
+
x: splitRef.current?.x ?? 0,
|
|
206
|
+
y: splitRef.current?.y ?? 0,
|
|
207
|
+
width: splitRef.current?.width ?? 0,
|
|
208
|
+
}),
|
|
209
|
+
(left, right) => left.x === right.x && left.y === right.y && left.width === right.width,
|
|
210
|
+
{ x: 0, y: 0, width: 0 },
|
|
211
|
+
menuOpen,
|
|
212
|
+
);
|
|
187
213
|
const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
|
|
188
214
|
const isFile = active?.kind === "file";
|
|
215
|
+
|
|
216
|
+
useKeyboard((key) => {
|
|
217
|
+
if (!menuOpen) return;
|
|
218
|
+
if (key.name === "escape") return menuControl.closeMenu();
|
|
219
|
+
const direction = splitDirectionForKey(key.name);
|
|
220
|
+
if (direction) {
|
|
221
|
+
props.onSplit(group.id, direction);
|
|
222
|
+
menuControl.closeMenu();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (!menuOpen) return;
|
|
228
|
+
setOverlay(
|
|
229
|
+
"split-menu",
|
|
230
|
+
<box
|
|
231
|
+
onMouseUp={menuControl.closeMenu}
|
|
232
|
+
style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%" }}
|
|
233
|
+
>
|
|
234
|
+
<box
|
|
235
|
+
style={{
|
|
236
|
+
position: "absolute",
|
|
237
|
+
top: anchor.y + 1,
|
|
238
|
+
left: Math.max(0, anchor.x + anchor.width - MENU_WIDTH),
|
|
239
|
+
}}
|
|
240
|
+
>
|
|
241
|
+
<SplitMenu
|
|
242
|
+
onPick={(direction) => {
|
|
243
|
+
props.onSplit(group.id, direction);
|
|
244
|
+
menuControl.closeMenu();
|
|
245
|
+
}}
|
|
246
|
+
tokens={tokens}
|
|
247
|
+
/>
|
|
248
|
+
</box>
|
|
249
|
+
</box>,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return () => clearOverlay("split-menu");
|
|
253
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
254
|
+
}, [menuOpen, anchor, tokens]);
|
|
189
255
|
// The strip windows its tabs rather than scrolling a nested scrollbox (which stalls the
|
|
190
256
|
// renderer inside this header): tabs keep the width of their names, only the run that fits
|
|
191
257
|
// renders, and the window follows the active tab so a tab opened past the edge is never hidden.
|
|
@@ -281,13 +347,14 @@ function EditorGroupPane({
|
|
|
281
347
|
<box style={{ flexDirection: "row", flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
|
|
282
348
|
{/* search sits out until it does something; split takes its place */}
|
|
283
349
|
{isFile ? (
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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>
|
|
291
358
|
) : null}
|
|
292
359
|
<IconButton
|
|
293
360
|
glyph={NERD.zoom}
|
|
@@ -298,15 +365,6 @@ function EditorGroupPane({
|
|
|
298
365
|
/>
|
|
299
366
|
</box>
|
|
300
367
|
</box>
|
|
301
|
-
{menuOpen ? (
|
|
302
|
-
<SplitMenu
|
|
303
|
-
onPick={(direction) => {
|
|
304
|
-
setMenuOpen(false);
|
|
305
|
-
props.onSplit(group.id, direction);
|
|
306
|
-
}}
|
|
307
|
-
tokens={tokens}
|
|
308
|
-
/>
|
|
309
|
-
) : null}
|
|
310
368
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
311
369
|
{active ? props.renderTab(active, props.focusedGroupId === group.id) : null}
|
|
312
370
|
</box>
|
|
@@ -34,6 +34,8 @@ export type DiffSurfaceProps = Pick<
|
|
|
34
34
|
| "onAnnotate"
|
|
35
35
|
| "onReply"
|
|
36
36
|
| "onUpdateAnnotation"
|
|
37
|
+
| "leaderCombos"
|
|
38
|
+
| "onLeaderCommand"
|
|
37
39
|
| "onExit"
|
|
38
40
|
>;
|
|
39
41
|
|
|
@@ -56,7 +58,14 @@ export function ChangesTabBody(props: {
|
|
|
56
58
|
|
|
57
59
|
if (props.rows.length === 0) {
|
|
58
60
|
return (
|
|
59
|
-
<box
|
|
61
|
+
<box
|
|
62
|
+
style={{
|
|
63
|
+
flexGrow: 1,
|
|
64
|
+
flexDirection: "column",
|
|
65
|
+
alignItems: "center",
|
|
66
|
+
justifyContent: "center",
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
60
69
|
<text fg={props.theme.textDim}>No changes</text>
|
|
61
70
|
</box>
|
|
62
71
|
);
|
|
@@ -44,6 +44,7 @@ export interface PanelColumnProps {
|
|
|
44
44
|
header: React.ReactNode;
|
|
45
45
|
/** Right-aligned header controls (toggles, search). */
|
|
46
46
|
headerRight?: React.ReactNode;
|
|
47
|
+
onFocus?: () => void;
|
|
47
48
|
children: React.ReactNode;
|
|
48
49
|
theme?: Theme;
|
|
49
50
|
}
|
|
@@ -53,11 +54,13 @@ export function PanelColumn({
|
|
|
53
54
|
border,
|
|
54
55
|
header,
|
|
55
56
|
headerRight,
|
|
57
|
+
onFocus,
|
|
56
58
|
children,
|
|
57
59
|
theme,
|
|
58
60
|
}: PanelColumnProps): React.ReactNode {
|
|
59
61
|
return (
|
|
60
62
|
<box
|
|
63
|
+
onMouseDown={onFocus}
|
|
61
64
|
style={{
|
|
62
65
|
width,
|
|
63
66
|
// grow panes split evenly and clip wide content (a long diff line) rather than stretching
|
|
@@ -1,32 +1,59 @@
|
|
|
1
|
-
|
|
1
|
+
/** The Project tree navigates by keyboard when focused: j/k move the cursor, tab folds a folder or
|
|
2
|
+
* opens a file. */
|
|
3
|
+
|
|
4
|
+
import { test, expect } from "bun:test";
|
|
2
5
|
import React from "react";
|
|
3
6
|
import { testRender } from "@opentui/react/test-utils";
|
|
4
7
|
import { ProjectTreeView } from "./ProjectTreeView";
|
|
5
|
-
import {
|
|
8
|
+
import { settle, waitForText } from "../test-support";
|
|
6
9
|
import { DARK } from "../theme";
|
|
7
10
|
|
|
8
|
-
test("
|
|
11
|
+
test("tab expands a folder, then j moves down and tab opens the file", async () => {
|
|
9
12
|
const opened: string[] = [];
|
|
10
13
|
const setup = await testRender(
|
|
11
14
|
<ProjectTreeView
|
|
12
|
-
loadFiles={() =>
|
|
15
|
+
loadFiles={async () => ["src/a.ts", "README.md"]}
|
|
13
16
|
onSelectFile={(path) => opened.push(path)}
|
|
17
|
+
focused
|
|
14
18
|
theme={DARK}
|
|
15
19
|
/>,
|
|
16
|
-
{ width: 40, height:
|
|
20
|
+
{ width: 40, height: 12 },
|
|
17
21
|
);
|
|
22
|
+
await settle(setup);
|
|
23
|
+
await waitForText(setup, "src");
|
|
24
|
+
|
|
25
|
+
// cursor starts on the "src" folder; tab expands it
|
|
26
|
+
setup.mockInput.pressKey("TAB");
|
|
27
|
+
await waitForText(setup, "a.ts");
|
|
28
|
+
|
|
29
|
+
// j moves onto the file, tab opens it
|
|
30
|
+
setup.mockInput.pressKey("j");
|
|
31
|
+
await settle(setup);
|
|
32
|
+
setup.mockInput.pressKey("TAB");
|
|
33
|
+
await settle(setup);
|
|
18
34
|
|
|
19
|
-
|
|
20
|
-
await clickText(setup, "README.md");
|
|
35
|
+
expect(opened).toEqual(["src/a.ts"]);
|
|
21
36
|
|
|
22
|
-
|
|
37
|
+
setup.renderer.destroy();
|
|
23
38
|
});
|
|
24
39
|
|
|
25
|
-
test("
|
|
40
|
+
test("without focus the keyboard does nothing", async () => {
|
|
41
|
+
const opened: string[] = [];
|
|
26
42
|
const setup = await testRender(
|
|
27
|
-
<ProjectTreeView
|
|
28
|
-
|
|
43
|
+
<ProjectTreeView
|
|
44
|
+
loadFiles={async () => ["src/a.ts"]}
|
|
45
|
+
onSelectFile={(path) => opened.push(path)}
|
|
46
|
+
theme={DARK}
|
|
47
|
+
/>,
|
|
48
|
+
{ width: 40, height: 12 },
|
|
29
49
|
);
|
|
50
|
+
await settle(setup);
|
|
51
|
+
await waitForText(setup, "src");
|
|
52
|
+
|
|
53
|
+
setup.mockInput.pressKey("TAB");
|
|
54
|
+
await settle(setup);
|
|
55
|
+
expect(setup.captureCharFrame()).not.toContain("a.ts");
|
|
56
|
+
expect(opened).toEqual([]);
|
|
30
57
|
|
|
31
|
-
|
|
58
|
+
setup.renderer.destroy();
|
|
32
59
|
});
|
|
@@ -4,31 +4,62 @@
|
|
|
4
4
|
|
|
5
5
|
import { ScrollArea } from "./ScrollArea";
|
|
6
6
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { useKeyboard } from "@opentui/react";
|
|
7
8
|
import type { Theme } from "../theme";
|
|
8
9
|
import { useComponentTheme } from "./theme-context";
|
|
9
10
|
import { Tree } from "./primitives/Tree";
|
|
11
|
+
import { flattenTree } from "./primitives/tree-model";
|
|
10
12
|
import { buildPathTree } from "./file-tree";
|
|
11
13
|
|
|
12
14
|
export interface ProjectTreeViewProps {
|
|
13
15
|
loadFiles: () => Promise<string[]>;
|
|
14
16
|
onSelectFile: (path: string) => void;
|
|
17
|
+
/** The pane owns the keyboard: j/k and arrows move the cursor, tab/enter open or fold. */
|
|
18
|
+
focused?: boolean;
|
|
15
19
|
theme?: Theme;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export function ProjectTreeView({
|
|
19
23
|
loadFiles,
|
|
20
24
|
onSelectFile,
|
|
25
|
+
focused = false,
|
|
21
26
|
theme,
|
|
22
27
|
}: ProjectTreeViewProps): React.ReactNode {
|
|
23
28
|
const tokens = useComponentTheme(theme);
|
|
24
29
|
const [paths, setPaths] = useState<readonly string[] | null>(null);
|
|
25
30
|
const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(new Set());
|
|
31
|
+
const [cursor, setCursor] = useState(0);
|
|
26
32
|
const nodes = useMemo(() => (paths ? buildPathTree(paths) : []), [paths]);
|
|
33
|
+
const rows = useMemo(() => flattenTree(nodes, { expandedIds }), [nodes, expandedIds]);
|
|
34
|
+
const cursorIndex = Math.min(cursor, Math.max(0, rows.length - 1));
|
|
27
35
|
const loadRef = useRef(loadFiles);
|
|
28
36
|
useEffect(() => {
|
|
29
37
|
loadRef.current = loadFiles;
|
|
30
38
|
});
|
|
31
39
|
|
|
40
|
+
const toggle = (id: string): void =>
|
|
41
|
+
setExpandedIds((current) => {
|
|
42
|
+
const next = new Set(current);
|
|
43
|
+
|
|
44
|
+
if (next.has(id)) next.delete(id);
|
|
45
|
+
else next.add(id);
|
|
46
|
+
|
|
47
|
+
return next;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
useKeyboard((key) => {
|
|
51
|
+
if (!focused || rows.length === 0) return;
|
|
52
|
+
if (key.name === "j" || key.name === "down")
|
|
53
|
+
return setCursor(Math.min(cursorIndex + 1, rows.length - 1));
|
|
54
|
+
if (key.name === "k" || key.name === "up") return setCursor(Math.max(cursorIndex - 1, 0));
|
|
55
|
+
const row = rows[cursorIndex];
|
|
56
|
+
if (!row) return;
|
|
57
|
+
if (key.name === "tab" || key.name === "return" || key.name === "enter")
|
|
58
|
+
return row.isFolder ? toggle(row.id) : onSelectFile(row.id);
|
|
59
|
+
if (key.name === "l" && row.isFolder && !expandedIds.has(row.id)) return toggle(row.id);
|
|
60
|
+
if (key.name === "h" && row.isFolder && expandedIds.has(row.id)) return toggle(row.id);
|
|
61
|
+
});
|
|
62
|
+
|
|
32
63
|
useEffect(() => {
|
|
33
64
|
let alive = true;
|
|
34
65
|
void loadRef.current().then(
|
|
@@ -65,17 +96,14 @@ export function ProjectTreeView({
|
|
|
65
96
|
<Tree
|
|
66
97
|
nodes={nodes}
|
|
67
98
|
expandedIds={expandedIds}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const next = new Set(current);
|
|
72
|
-
|
|
73
|
-
if (next.has(id)) next.delete(id);
|
|
74
|
-
else next.add(id);
|
|
99
|
+
selectedId={focused ? rows[cursorIndex]?.id : undefined}
|
|
100
|
+
onSelect={(id) => {
|
|
101
|
+
const index = rows.findIndex((row) => row.id === id);
|
|
75
102
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
103
|
+
if (index >= 0) setCursor(index);
|
|
104
|
+
onSelectFile(id);
|
|
105
|
+
}}
|
|
106
|
+
onToggle={toggle}
|
|
79
107
|
theme={theme}
|
|
80
108
|
/>
|
|
81
109
|
</ScrollArea>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The prompt dialog's footer actions are mouse-clickable: clicking "save" commits and "cancel"
|
|
2
|
+
* dismisses, the same as pressing enter or esc. */
|
|
3
|
+
|
|
4
|
+
import { test, expect } from "bun:test";
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
7
|
+
import { PromptDialog } from "./PromptDialog";
|
|
8
|
+
import { locateText, settle, typeText } from "../test-support";
|
|
9
|
+
import { DARK } from "../theme";
|
|
10
|
+
|
|
11
|
+
test("the input is focused on open, so typing lands in the dialog", async () => {
|
|
12
|
+
const inputs: string[] = [];
|
|
13
|
+
const setup = await testRender(
|
|
14
|
+
<PromptDialog
|
|
15
|
+
isOpen
|
|
16
|
+
title=" rename thread "
|
|
17
|
+
label="new title for this thread:"
|
|
18
|
+
value="hi"
|
|
19
|
+
onInput={(text) => inputs.push(text)}
|
|
20
|
+
theme={DARK}
|
|
21
|
+
/>,
|
|
22
|
+
{ width: 60, height: 12 },
|
|
23
|
+
);
|
|
24
|
+
await settle(setup);
|
|
25
|
+
|
|
26
|
+
await typeText(setup, "!");
|
|
27
|
+
expect(inputs.at(-1)).toBe("hi!");
|
|
28
|
+
|
|
29
|
+
setup.renderer.destroy();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("clicking save fires onSave and clicking cancel fires onCancel", async () => {
|
|
33
|
+
let saved = 0;
|
|
34
|
+
let cancelled = 0;
|
|
35
|
+
const setup = await testRender(
|
|
36
|
+
<PromptDialog
|
|
37
|
+
isOpen
|
|
38
|
+
title=" rename thread "
|
|
39
|
+
label="new title for this thread:"
|
|
40
|
+
value="a title"
|
|
41
|
+
onInput={() => {}}
|
|
42
|
+
onSave={() => (saved += 1)}
|
|
43
|
+
onCancel={() => (cancelled += 1)}
|
|
44
|
+
theme={DARK}
|
|
45
|
+
/>,
|
|
46
|
+
{ width: 60, height: 12 },
|
|
47
|
+
);
|
|
48
|
+
await settle(setup);
|
|
49
|
+
|
|
50
|
+
const save = locateText(setup, "enter save");
|
|
51
|
+
await setup.mockMouse.click(save.column, save.row);
|
|
52
|
+
expect(saved).toBe(1);
|
|
53
|
+
expect(cancelled).toBe(0);
|
|
54
|
+
|
|
55
|
+
const cancel = locateText(setup, "esc cancel");
|
|
56
|
+
await setup.mockMouse.click(cancel.column, cancel.row);
|
|
57
|
+
expect(cancelled).toBe(1);
|
|
58
|
+
expect(saved).toBe(1);
|
|
59
|
+
|
|
60
|
+
setup.renderer.destroy();
|
|
61
|
+
});
|