@cueloop/client 0.1.0-alpha.75 → 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.
Files changed (59) hide show
  1. package/package.json +5 -5
  2. package/src/App.focus.test.ts +147 -0
  3. package/src/App.regression.test.tsx +94 -0
  4. package/src/App.test.tsx +11 -8
  5. package/src/App.tsx +476 -220
  6. package/src/App.visual.test.tsx +57 -0
  7. package/src/App.workbench.test.tsx +41 -22
  8. package/src/app-screens.tsx +168 -123
  9. package/src/components/AppShell.brand.test.tsx +1 -1
  10. package/src/components/AppShell.tsx +14 -1
  11. package/src/components/ChangesColumn.tsx +40 -12
  12. package/src/components/ChangesFileTree.test.tsx +61 -0
  13. package/src/components/ConfirmDialog.tsx +8 -1
  14. package/src/components/DiffContentView.tsx +7 -1
  15. package/src/components/DiscussionMarkerRail.tsx +2 -2
  16. package/src/components/EditorGrid.tsx +84 -26
  17. package/src/components/GridTabContent.tsx +10 -1
  18. package/src/components/PanelColumn.tsx +3 -0
  19. package/src/components/ProjectTreeView.test.tsx +39 -12
  20. package/src/components/ProjectTreeView.tsx +38 -10
  21. package/src/components/PromptDialog.test.tsx +61 -0
  22. package/src/components/PromptDialog.tsx +16 -3
  23. package/src/components/RootOverlay.stories.tsx +1 -0
  24. package/src/components/RootOverlay.tsx +19 -6
  25. package/src/components/SettingsDialog.stories.tsx +3 -3
  26. package/src/components/SettingsDialog.tsx +3 -4
  27. package/src/components/ThreadFooter.test.tsx +12 -7
  28. package/src/components/ThreadFooter.tsx +18 -4
  29. package/src/components/ThreadTree.tsx +79 -36
  30. package/src/components/ThreadView.slash.test.tsx +36 -0
  31. package/src/components/ThreadView.tsx +7 -1
  32. package/src/components/WelcomePlayground.tsx +2 -0
  33. package/src/components/__snapshots__/stories.test.tsx.snap +60 -51
  34. package/src/components/menu-control.stories.tsx +35 -0
  35. package/src/components/menu-control.tsx +43 -0
  36. package/src/components/primitives/Dialog.test.tsx +37 -0
  37. package/src/components/primitives/Dialog.tsx +5 -0
  38. package/src/components/primitives/icons.ts +1 -1
  39. package/src/components/quick-actions-editor.stories.tsx +2 -0
  40. package/src/components/quick-actions-editor.test.tsx +35 -1
  41. package/src/components/quick-actions-editor.tsx +23 -22
  42. package/src/components/session-tree.test.ts +3 -3
  43. package/src/components/session-tree.ts +1 -1
  44. package/src/config.ts +1 -0
  45. package/src/diff-intraline.ts +26 -5
  46. package/src/perf/otlp-trace.test.ts +116 -0
  47. package/src/perf/otlp-trace.ts +130 -0
  48. package/src/perf/perf-report.ts +17 -0
  49. package/src/perf/perf-timings.test.ts +18 -0
  50. package/src/perf/perf-timings.ts +38 -0
  51. package/src/run.ts +22 -2
  52. package/src/thread-chords.test.ts +33 -1
  53. package/src/thread-chords.ts +114 -29
  54. package/src/thread-controller.ts +4 -3
  55. package/src/use-annotation-surface.tsx +22 -0
  56. package/src/use-frame-measure.ts +4 -1
  57. package/src/use-settings-dialog.tsx +9 -7
  58. package/src/visual/contrast.test.ts +35 -0
  59. 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={onSelectFile}
127
- onToggle={(id) =>
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
- return next;
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 isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
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: "Split Left", direction: "left", arrow: "←" },
31
- { label: "Split Right", direction: "right", arrow: "→" },
32
- { label: "Split Up", direction: "up", arrow: "↑" },
33
- { label: "Split Down", direction: "down", arrow: "↓" },
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
- // the panel token is transparent in the branded theme, so the menu reads terminal-through
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}>{` ${item.arrow}`}</text>
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 [menuOpen, setMenuOpen] = useState(false);
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
- <IconButton
285
- glyph="split"
286
- active={menuOpen}
287
- onPress={() => setMenuOpen((open) => !open)}
288
- marginRight={2}
289
- theme={tokens}
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 style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
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
- import { expect, test } from "bun:test";
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 { clickText, waitForText } from "../test-support";
8
+ import { settle, waitForText } from "../test-support";
6
9
  import { DARK } from "../theme";
7
10
 
8
- test("renders the loaded files as a tree and opens a file on click", async () => {
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={() => Promise.resolve(["src/App.tsx", "README.md"])}
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: 16 },
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
- await waitForText(setup, "README.md");
20
- await clickText(setup, "README.md");
35
+ expect(opened).toEqual(["src/a.ts"]);
21
36
 
22
- expect(opened).toContain("README.md");
37
+ setup.renderer.destroy();
23
38
  });
24
39
 
25
- test("shows an empty hint when there are no files", async () => {
40
+ test("without focus the keyboard does nothing", async () => {
41
+ const opened: string[] = [];
26
42
  const setup = await testRender(
27
- <ProjectTreeView loadFiles={() => Promise.resolve([])} onSelectFile={() => {}} theme={DARK} />,
28
- { width: 40, height: 8 },
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
- await waitForText(setup, "empty");
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
- onSelect={onSelectFile}
69
- onToggle={(id) =>
70
- setExpandedIds((current) => {
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
- return next;
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
+ });