@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.
Files changed (40) hide show
  1. package/package.json +5 -5
  2. package/src/App.deleted-scroll.test.tsx +186 -0
  3. package/src/App.diff.test.tsx +3 -4
  4. package/src/App.test.tsx +1 -0
  5. package/src/App.toast.test.tsx +27 -1
  6. package/src/App.tsx +21 -12
  7. package/src/App.workbench.test.tsx +208 -2
  8. package/src/app-screens.tsx +34 -8
  9. package/src/components/AnnotatableFileView.test.tsx +48 -1
  10. package/src/components/AppShell.zoom.test.tsx +16 -3
  11. package/src/components/ChangesColumn.tsx +10 -4
  12. package/src/components/ChangesFileTree.test.tsx +122 -4
  13. package/src/components/DiffContentView.scroll.test.tsx +285 -1
  14. package/src/components/DiffContentView.tsx +214 -56
  15. package/src/components/EditorGrid.split.test.tsx +118 -0
  16. package/src/components/EditorGrid.stories.tsx +27 -0
  17. package/src/components/EditorGrid.tsx +170 -50
  18. package/src/components/EditorGrid.zoom.test.tsx +65 -1
  19. package/src/components/GridTabContent.actions.test.tsx +151 -0
  20. package/src/components/GridTabContent.stories.tsx +2 -2
  21. package/src/components/GridTabContent.tsx +5 -1
  22. package/src/components/ProjectTreeView.test.tsx +166 -8
  23. package/src/components/ProjectTreeView.tsx +53 -27
  24. package/src/components/ScrollArea.tsx +21 -2
  25. package/src/components/Toast.tsx +3 -1
  26. package/src/components/WelcomePlayground.test.tsx +28 -1
  27. package/src/components/__snapshots__/stories.test.tsx.snap +41 -21
  28. package/src/components/editor-grid.test.ts +87 -0
  29. package/src/components/editor-grid.ts +142 -6
  30. package/src/components/primitives/Tree.tsx +51 -15
  31. package/src/diff-syntax.test.ts +15 -2
  32. package/src/diff-syntax.ts +13 -5
  33. package/src/thread-controller.curate.test.ts +27 -0
  34. package/src/thread-controller.ts +4 -6
  35. package/src/use-annotation-surface.tsx +86 -19
  36. package/src/use-changes-workbench.ts +30 -18
  37. package/src/use-frame-measure.ts +40 -7
  38. package/src/use-settings-dialog.tsx +1 -13
  39. package/src/use-shared-keyboard.ts +55 -0
  40. package/src/use-terminal-virtualizer.ts +8 -2
@@ -3,7 +3,7 @@ import React, { useCallback, useMemo, type Dispatch, type SetStateAction } from
3
3
  import type { Thread, MessageOutcome } from "@cueloop/schema";
4
4
  import { returnPaneFor } from "@cueloop/schema";
5
5
  import type { Theme } from "./theme";
6
- import type { QuickAction } from "./config";
6
+ import type { DiffViewMode, QuickAction } from "./config";
7
7
  import type { LaunchLayout } from "./launch-layout";
8
8
  import { useRememberLayout } from "./use-remember-layout";
9
9
  import type { Mode, TreeAsk } from "./intent-dispatch";
@@ -30,6 +30,7 @@ import { ProjectTreeView } from "./components/ProjectTreeView";
30
30
  import { ChangesFileTree } from "./components/ChangesColumn";
31
31
  import { BareWorkbenchFileView, draftThread } from "./components/BareWorkbenchFileView";
32
32
  import { GridTabContent, type DiffSurfaceProps } from "./components/GridTabContent";
33
+ import type { DiffFoldControls } from "./components/DiffContentView";
33
34
  import { useChangesWorkbench } from "./use-changes-workbench";
34
35
  import { useRepoChanges } from "./use-repo-changes";
35
36
  import { ConfirmDialog } from "./components/ConfirmDialog";
@@ -112,9 +113,9 @@ function WelcomeProjectPanel({
112
113
  mode: ProjectPanelMode;
113
114
  controller: ReviewController;
114
115
  /** Changes tree click -> open the file's working-tree diff. */
115
- onOpenChangedFile: (path: string) => void;
116
+ onOpenChangedFile: (path: string, persistent?: boolean) => void;
116
117
  /** Project tree click -> open the file's read-only contents. */
117
- onOpenProjectFile: (path: string) => void;
118
+ onOpenProjectFile: (path: string, persistent?: boolean) => void;
118
119
  focused?: boolean;
119
120
  theme: Theme;
120
121
  }): React.ReactNode {
@@ -177,6 +178,7 @@ export function NoThreadShell(props: {
177
178
  onRename: (id: string, title: string) => void;
178
179
  /** Drives the "/" quick actions and skills in the welcome playground's composer. */
179
180
  quickActions: QuickAction[];
181
+ diffView: DiffViewMode;
180
182
  /** Reports the welcome composer's open state, so the shell suspends its inbox keys while typing. */
181
183
  onWelcomeComposingChange: (composing: boolean) => void;
182
184
  /** The pane composition this vanilla launch restores; changes here are remembered for the next one. */
@@ -201,6 +203,7 @@ export function NoThreadShell(props: {
201
203
  onPin,
202
204
  onRename,
203
205
  quickActions,
206
+ diffView,
204
207
  onWelcomeComposingChange,
205
208
  layout,
206
209
  } = props;
@@ -222,13 +225,14 @@ export function NoThreadShell(props: {
222
225
  workbench.zoomed,
223
226
  );
224
227
  // the Changes tree opens a file's working-tree diff; the Project tree opens read-only contents
225
- const openChangedFile = (path: string): void => {
228
+ const openChangedFile = (path: string, persistent?: boolean): void => {
226
229
  // no thread means no live-diff refresh loop, so re-capture on open or a long-lived shell goes
227
230
  // stale; the catch keeps a fire-and-forget refresh from throwing when the shell tears down
228
231
  void controller.repoChanges().catch(() => undefined);
229
- workbench.openFile(path, "diff");
232
+ workbench.openFile(path, "diff", persistent);
230
233
  };
231
- const openProjectFile = (path: string): void => workbench.openFile(path, "contents");
234
+ const openProjectFile = (path: string, persistent?: boolean): void =>
235
+ workbench.openFile(path, "contents", persistent);
232
236
  // a bare launch has no thread yet, so the diff renders against a draft session; the first note
233
237
  // promotes it to the per-repo workbench thread (commentOnWorkbenchDiff)
234
238
  const draft = useMemo(() => draftThread(), []);
@@ -256,6 +260,15 @@ export function NoThreadShell(props: {
256
260
  onUpdateAnnotation: () => {},
257
261
  onExit: () => {},
258
262
  };
263
+ const diffFold: DiffFoldControls = {
264
+ isCollapsed: (file) => controller.isFileCollapsed(file),
265
+ isExpanded: (file) => controller.isFileExpanded(file),
266
+ canExpand: (file) => controller.canExpandFile(file),
267
+ onToggleCollapse: (file) =>
268
+ controller.setFileCollapsed(file, !controller.isFileCollapsed(file)),
269
+ onToggleExpand: (file) => controller.setFileExpanded(file, !controller.isFileExpanded(file)),
270
+ onCopyPath: (file) => controller.copyFilePath(file),
271
+ };
259
272
 
260
273
  return (
261
274
  <ThemeProvider theme={theme}>
@@ -313,6 +326,7 @@ export function NoThreadShell(props: {
313
326
  focusedGroupId={workbench.activeGroup}
314
327
  onFocusGroup={workbench.focusGroup}
315
328
  onActivateTab={workbench.activate}
329
+ onKeepTab={workbench.keepTab}
316
330
  onCloseTab={workbench.close}
317
331
  onSplit={workbench.split}
318
332
  onZoom={workbench.toggleZoom}
@@ -340,6 +354,9 @@ export function NoThreadShell(props: {
340
354
  rows={controller.rows()}
341
355
  surface={bareSurface}
342
356
  rejectedRows={EMPTY_ROWS}
357
+ fold={diffFold}
358
+ fileStats={controller.fileStats()}
359
+ split={diffView === "split" && workbench.zoomed}
343
360
  dimmed={false}
344
361
  readFile={(path) => controller.repoReadFile(path)}
345
362
  onAddFileComment={(path, anchor, body) =>
@@ -396,7 +413,12 @@ export function NoThreadShell(props: {
396
413
  />
397
414
  ) : null}
398
415
  {props.toast ? (
399
- <Toast title={props.toast.title} body={props.toast.body} theme={theme} />
416
+ <Toast
417
+ title={props.toast.title}
418
+ body={props.toast.body}
419
+ onDismiss={() => controller.dismissToast()}
420
+ theme={theme}
421
+ />
400
422
  ) : null}
401
423
  </AppShell>
402
424
  </MenuControlProvider>
@@ -460,6 +482,7 @@ export function TrailingOverlays(props: {
460
482
  theme: Theme;
461
483
  mode: Mode;
462
484
  toast: ToastState | null;
485
+ onDismissToast: () => void;
463
486
  setMode: Dispatch<SetStateAction<Mode>>;
464
487
  dispatch: (intent: Intent) => void;
465
488
  }): React.ReactNode {
@@ -473,6 +496,7 @@ export function TrailingOverlays(props: {
473
496
  theme,
474
497
  mode,
475
498
  toast,
499
+ onDismissToast,
476
500
  setMode,
477
501
  dispatch,
478
502
  } = props;
@@ -571,7 +595,9 @@ export function TrailingOverlays(props: {
571
595
  theme={theme}
572
596
  />
573
597
  ) : null}
574
- {toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
598
+ {toast ? (
599
+ <Toast title={toast.title} body={toast.body} onDismiss={onDismissToast} theme={theme} />
600
+ ) : null}
575
601
  </>
576
602
  );
577
603
  }
@@ -4,12 +4,13 @@
4
4
  */
5
5
 
6
6
  import { describe, expect, mock, test } from "bun:test";
7
+ import type { RGBA } from "@opentui/core";
7
8
  import { testRender } from "@opentui/react/test-utils";
8
9
  import React from "react";
9
10
  import { SCHEMA_VERSION, type Anchor, type Thread } from "@cueloop/schema";
10
11
  import { AnnotatableFileView } from "./AnnotatableFileView";
11
12
  import { DARK } from "../theme";
12
- import { dragText, typeText, pressKey, waitForText } from "../test-support";
13
+ import { allowEventLoopUpdates, dragText, typeText, pressKey, waitForText } from "../test-support";
13
14
 
14
15
  const SAMPLE = "export function add(a: number, b: number) {\n return a + b;\n}\n";
15
16
 
@@ -29,7 +30,53 @@ function planSession(): Thread {
29
30
 
30
31
  const noop = (): void => {};
31
32
 
33
+ function colorHex(color: RGBA): string {
34
+ const [red, green, blue] = color.toInts();
35
+
36
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
37
+ }
38
+
32
39
  describe("AnnotatableFileView", () => {
40
+ for (const { path, source, keyword } of [
41
+ { path: "src/add.ts", source: "export const add = 1;", keyword: "export" },
42
+ { path: "src/add.js", source: "const add = 1;", keyword: "const" },
43
+ ]) {
44
+ test(`highlights ${path} by its detected language`, async () => {
45
+ allowEventLoopUpdates();
46
+ const setup = await testRender(
47
+ <AnnotatableFileView
48
+ path={path}
49
+ loadContents={() => Promise.resolve(source)}
50
+ session={planSession()}
51
+ quickActions={[]}
52
+ observer={false}
53
+ onAddComment={noop}
54
+ onReply={noop}
55
+ onUpdateAnnotation={noop}
56
+ onExit={noop}
57
+ theme={DARK}
58
+ />,
59
+ { width: 64, height: 12 },
60
+ );
61
+ let highlighted = false;
62
+
63
+ for (let attempt = 0; attempt < 60 && !highlighted; attempt++) {
64
+ await setup.renderOnce();
65
+ await new Promise((resolve) => setTimeout(resolve, 20));
66
+ highlighted = setup.captureSpans().lines.some((line) =>
67
+ line.spans
68
+ .filter((span) => colorHex(span.fg) === DARK.accent)
69
+ .map((span) => span.text)
70
+ .join("")
71
+ .includes(keyword),
72
+ );
73
+ }
74
+
75
+ expect(highlighted).toBe(true);
76
+ setup.renderer.destroy();
77
+ }, 25000);
78
+ }
79
+
33
80
  test("commenting a file line hands up an anchor that quotes the line", async () => {
34
81
  const onAddComment = mock((_anchor: Anchor, _body: string) => {});
35
82
  const setup = await testRender(
@@ -11,7 +11,7 @@ import { testRender } from "@opentui/react/test-utils";
11
11
  import React from "react";
12
12
  import { AppShell } from "./AppShell";
13
13
  import { EditorGrid } from "./EditorGrid";
14
- import { changesTab, makeGroup } from "./editor-grid";
14
+ import { changesTab, makeGroup, splitGroup } from "./editor-grid";
15
15
  import { NERD } from "./primitives/icons";
16
16
  import { allowEventLoopUpdates } from "../test-support";
17
17
 
@@ -21,7 +21,11 @@ async function zoomIconColumn(
21
21
  width: number,
22
22
  zoomed: boolean,
23
23
  sidebarOpen: boolean,
24
+ splitEditor = false,
24
25
  ): Promise<number> {
26
+ const editor = splitEditor
27
+ ? splitGroup(grid, grid.id, "right")
28
+ : { tree: grid, focusGroupId: grid.id };
25
29
  const setup = await testRender(
26
30
  <AppShell
27
31
  sidebarOpen={sidebarOpen}
@@ -40,8 +44,8 @@ async function zoomIconColumn(
40
44
  projectPanel={<text>project</text>}
41
45
  changesPanel={
42
46
  <EditorGrid
43
- tree={grid}
44
- focusedGroupId={grid.id}
47
+ tree={editor.tree}
48
+ focusedGroupId={editor.focusGroupId}
45
49
  onFocusGroup={() => {}}
46
50
  onActivateTab={() => {}}
47
51
  onCloseTab={() => {}}
@@ -132,4 +136,13 @@ describe("zoom icon in the shell", () => {
132
136
  // before it, the same inset the right-sidebar icon keeps from its own edge
133
137
  for (const column of setupColumns) expect(128 - column).toBe(2);
134
138
  });
139
+
140
+ test("a split editor keeps the zoom icon clear of the Project border", async () => {
141
+ const columns = await Promise.all([
142
+ zoomIconColumn(160, false, false, true),
143
+ zoomIconColumn(160, true, false, true),
144
+ ]);
145
+
146
+ for (const column of columns) expect(128 - column).toBe(2);
147
+ });
135
148
  });
@@ -86,7 +86,7 @@ export interface ChangesColumnProps {
86
86
  files?: readonly DiffFileContents[];
87
87
  /** The path whose diff the sheet is scrolled to, highlighted in the tree. */
88
88
  selectedPath?: string;
89
- onSelectFile: (path: string) => void;
89
+ onSelectFile: (path: string, persistent?: boolean) => void;
90
90
  /** Comments per file path, shown as a dot-and-count badge on each entry. */
91
91
  commentCounts?: ReadonlyMap<string, number>;
92
92
  focused?: boolean;
@@ -141,18 +141,23 @@ export function ChangesFileTree({
141
141
  if (key.name === "k" || key.name === "up") return setCursor(Math.max(cursorIndex - 1, 0));
142
142
  const row = rows[cursorIndex];
143
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);
144
+ if (key.name === "return" || key.name === "enter")
145
+ return row.isFolder ? toggle(row.id) : onSelectFile(row.id, true);
146
146
  if (key.name === "l" && row.isFolder && collapsedIds.has(row.id)) return toggle(row.id);
147
147
  if (key.name === "h" && row.isFolder && !collapsedIds.has(row.id)) return toggle(row.id);
148
148
  });
149
149
 
150
150
  return (
151
- <ScrollArea>
151
+ <ScrollArea
152
+ id="tree-scroll"
153
+ revealId={focused ? rows[cursorIndex]?.id : undefined}
154
+ gestureWheel
155
+ >
152
156
  <Tree
153
157
  nodes={nodes}
154
158
  expandedIds={expandedIds}
155
159
  selectedId={focused ? rows[cursorIndex]?.id : selectedPath}
160
+ singleLine
156
161
  flattenEmptyDirectories
157
162
  showStatus
158
163
  onSelect={(id) => {
@@ -161,6 +166,7 @@ export function ChangesFileTree({
161
166
  if (index >= 0) setCursor(index);
162
167
  onSelectFile(id);
163
168
  }}
169
+ onDoubleSelect={(id) => onSelectFile(id, true)}
164
170
  onToggle={toggle}
165
171
  theme={theme}
166
172
  />
@@ -1,14 +1,26 @@
1
- /** The changed-files tree navigates by keyboard when focused: j/k move the cursor, tab folds a folder
1
+ /** The changed-files tree navigates by keyboard when focused: j/k move the cursor, Enter folds a folder
2
2
  * or opens a file. */
3
3
 
4
4
  import { test, expect } from "bun:test";
5
5
  import React from "react";
6
6
  import { testRender } from "@opentui/react/test-utils";
7
+ import { ScrollBoxRenderable, TextRenderable, type Renderable } from "@opentui/core";
7
8
  import type { DiffFileContents } from "@cueloop/schema";
8
9
  import { ChangesFileTree } from "./ChangesColumn";
9
10
  import { settle, waitForText } from "../test-support";
10
11
  import { DARK } from "../theme";
11
12
 
13
+ function findById(node: Renderable, id: string): Renderable | undefined {
14
+ if (node.id === id) return node;
15
+ for (const child of node.getChildren()) {
16
+ const found = findById(child, id);
17
+
18
+ if (found) return found;
19
+ }
20
+
21
+ return undefined;
22
+ }
23
+
12
24
  const file = (path: string): DiffFileContents => ({
13
25
  path,
14
26
  oldContents: "",
@@ -18,7 +30,7 @@ const file = (path: string): DiffFileContents => ({
18
30
 
19
31
  const files = [file("src/a.ts"), file("src/b.ts")];
20
32
 
21
- test("j moves onto a file and tab opens it", async () => {
33
+ test("j moves onto a file and Enter opens it", async () => {
22
34
  const opened: string[] = [];
23
35
  const setup = await testRender(
24
36
  <ChangesFileTree
@@ -35,7 +47,7 @@ test("j moves onto a file and tab opens it", async () => {
35
47
  // folders open by default; cursor starts on the "src" folder, j steps onto a.ts
36
48
  setup.mockInput.pressKey("j");
37
49
  await settle(setup);
38
- setup.mockInput.pressKey("TAB");
50
+ setup.mockInput.pressKey("RETURN");
39
51
  await settle(setup);
40
52
 
41
53
  expect(opened).toEqual(["src/a.ts"]);
@@ -43,6 +55,62 @@ test("j moves onto a file and tab opens it", async () => {
43
55
  setup.renderer.destroy();
44
56
  });
45
57
 
58
+ test("Tab leaves the focused tree without opening a file", async () => {
59
+ const opened: string[] = [];
60
+ const setup = await testRender(
61
+ <ChangesFileTree
62
+ files={files}
63
+ onSelectFile={(path) => opened.push(path)}
64
+ focused
65
+ theme={DARK}
66
+ />,
67
+ { width: 40, height: 12 },
68
+ );
69
+
70
+ await waitForText(setup, "a.ts");
71
+ setup.mockInput.pressKey("j");
72
+ setup.mockInput.pressKey("TAB");
73
+ await settle(setup);
74
+ expect(opened).toEqual([]);
75
+ setup.renderer.destroy();
76
+ });
77
+
78
+ test("keyboard navigation reveals the selected changed file", async () => {
79
+ const manyFiles = Array.from({ length: 35 }, (_, index) =>
80
+ file(`file-${String(index).padStart(2, "0")}.ts`),
81
+ );
82
+ const setup = await testRender(
83
+ <ChangesFileTree files={manyFiles} onSelectFile={() => {}} focused theme={DARK} />,
84
+ { width: 28, height: 8 },
85
+ );
86
+
87
+ await waitForText(setup, "file-00.ts");
88
+ const scrollbox = findById(setup.renderer.root, "tree-scroll");
89
+
90
+ if (!(scrollbox instanceof ScrollBoxRenderable)) throw new Error("tree scrollbox missing");
91
+ const scrollSteps: number[] = [];
92
+
93
+ for (const [index, key] of Array.from({ length: 15 }, () => "j").entries()) {
94
+ const before = scrollbox.scrollTop;
95
+
96
+ setup.mockInput.pressKey(key);
97
+ // eslint-disable-next-line no-await-in-loop
98
+ await settle(setup);
99
+ scrollSteps.push(scrollbox.scrollTop - before);
100
+ const row = scrollbox.content.findDescendantById(
101
+ `tree-row-file-${String(index + 1).padStart(2, "0")}.ts`,
102
+ );
103
+
104
+ expect(row?.y).toBeGreaterThanOrEqual(scrollbox.viewport.y);
105
+ expect(row?.y).toBeLessThan(scrollbox.viewport.y + scrollbox.viewport.height);
106
+ }
107
+
108
+ expect(scrollbox.scrollTop).toBeGreaterThan(0);
109
+ expect(Math.max(...scrollSteps)).toBe(1);
110
+ expect(setup.captureCharFrame()).toContain("file-15.ts");
111
+ setup.renderer.destroy();
112
+ });
113
+
46
114
  test("without focus the keyboard opens nothing", async () => {
47
115
  const opened: string[] = [];
48
116
  const setup = await testRender(
@@ -53,9 +121,59 @@ test("without focus the keyboard opens nothing", async () => {
53
121
  await waitForText(setup, "a.ts");
54
122
 
55
123
  setup.mockInput.pressKey("j");
56
- setup.mockInput.pressKey("TAB");
124
+ setup.mockInput.pressKey("RETURN");
57
125
  await settle(setup);
58
126
  expect(opened).toEqual([]);
59
127
 
60
128
  setup.renderer.destroy();
61
129
  });
130
+
131
+ test("a selected long filename has no empty continuation row", async () => {
132
+ for (const width of [33, 32, 31, 30, 29, 28, 27, 26, 25, 24]) {
133
+ const setup = await testRender(
134
+ <ChangesFileTree
135
+ files={[
136
+ file("packages/client/src/App.workbench.test.tsx"),
137
+ file("packages/client/src/next.ts"),
138
+ ]}
139
+ onSelectFile={() => {}}
140
+ focused
141
+ theme={DARK}
142
+ />,
143
+ { width, height: 12 },
144
+ );
145
+
146
+ await settle(setup);
147
+ setup.mockInput.pressKey("j");
148
+ await settle(setup);
149
+ const frame = setup.captureCharFrame().split("\n");
150
+ const selectedLine = frame.findIndex((line) => line.includes("App.wor"));
151
+ const row = findById(
152
+ setup.renderer.root,
153
+ "tree-row-packages/client/src/App.workbench.test.tsx",
154
+ );
155
+
156
+ expect(selectedLine).toBeGreaterThanOrEqual(0);
157
+ expect(frame[selectedLine + 1]).toContain("next.ts");
158
+ expect(row?.height).toBe(1);
159
+ setup.renderer.destroy();
160
+ }
161
+ });
162
+
163
+ test("horizontal name scrolling reveals the final extension beside the status", async () => {
164
+ const path = "src/sidebar-scroll.test.tsx";
165
+ const setup = await testRender(
166
+ <ChangesFileTree files={[file(path)]} onSelectFile={() => {}} focused theme={DARK} />,
167
+ { width: 24, height: 8 },
168
+ );
169
+
170
+ await waitForText(setup, "src");
171
+ const label = findById(setup.renderer.root, `tree-label-${path}`);
172
+
173
+ if (!(label instanceof TextRenderable)) throw new Error("file label missing");
174
+ expect(label.maxScrollX).toBeGreaterThan(0);
175
+ label.scrollX = label.maxScrollX;
176
+ await settle(setup);
177
+ expect(setup.captureCharFrame()).toContain(".tsx");
178
+ setup.renderer.destroy();
179
+ });