@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
@@ -14,7 +14,8 @@
14
14
 
15
15
  import React, { useContext, useEffect, useRef, useState } from "react";
16
16
  import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
17
- import { useKeyboard } from "@opentui/react";
17
+ import { flushSync } from "@opentui/react";
18
+ import { useSharedKeyboard } from "./use-shared-keyboard";
18
19
  import type { Annotation, Thread } from "@cueloop/schema";
19
20
  import type { Mark } from "./view-plan";
20
21
  import type { QuickAction } from "./config";
@@ -97,6 +98,13 @@ export interface AnnotationSurfaceOptions {
97
98
  onObserverBlocked?: (reason: "observer" | "resolved") => void;
98
99
  /** Reports the caret's block, so block-level primitives (cut, restore) act where the caret is. */
99
100
  onCursorChange?: (blockIndex: number) => void;
101
+ /** Scroll a visual row before crossing a tall gap; true defers the caret move. */
102
+ onVerticalStep?: (
103
+ fromBlock: number,
104
+ toBlock: number,
105
+ direction: -1 | 1,
106
+ targetY?: number,
107
+ ) => boolean;
100
108
  /** The rail's focused card; the discussion holding it takes focus here. */
101
109
  focusedAnnotationId?: string;
102
110
  /** Reports the focused discussion's root comment, so the rail follows. */
@@ -129,6 +137,10 @@ export interface AnnotationSurface {
129
137
  focusedDiscussion: string | null;
130
138
  /** The block to keep in view: an opening card, a focused discussion, else the caret. */
131
139
  revealBlockIndex: number;
140
+ /** Screen row of the mounted visual line carrying the caret. */
141
+ headVisualY: () => number | undefined;
142
+ /** Capture mounted lines before a viewport scroll, then keep an exiting caret at its visible edge. */
143
+ prepareViewportScroll: () => (delta: number, top: number, bottom: number) => void;
132
144
  /** The text a span covers, blocks joined by a space, for previews. */
133
145
  spanQuote: (span: TextSpan) => string;
134
146
  /** Ref callback for one visual line, so a drag can hit-test it. */
@@ -192,6 +204,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
192
204
  onComposingChange,
193
205
  onObserverBlocked,
194
206
  onCursorChange,
207
+ onVerticalStep,
195
208
  focusedAnnotationId,
196
209
  onFocusAnnotation,
197
210
  onAnnotate,
@@ -336,15 +349,55 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
336
349
  x: entry.renderable.x,
337
350
  y: entry.renderable.y,
338
351
  }));
339
- const verticalTextPosition = (position: TextPosition, direction: -1 | 1): TextPosition | null => {
352
+ const headVisualY = (): number | undefined => {
353
+ const textLength = textLengthOf(head.blockIndex);
354
+ const cell = Math.max(0, Math.min(textLength - 1, head.char));
355
+
356
+ return allGeometry().find(
357
+ (line) =>
358
+ line.blockIndex === head.blockIndex &&
359
+ line.start <= cell &&
360
+ (cell < line.end || line.start === line.end),
361
+ )?.y;
362
+ };
363
+ const prepareViewportScroll = (): ((delta: number, top: number, bottom: number) => void) => {
364
+ const lines = allGeometry().filter((line) => source.annotatable(line.blockIndex));
365
+ const oldHeadY = headVisualY();
366
+
367
+ return (delta, top, bottom) => {
368
+ if (delta === 0 || oldHeadY === undefined || compose || focusedDiscussion || caretIsSelection)
369
+ return;
370
+ const shiftedHeadY = oldHeadY - delta;
371
+
372
+ if (shiftedHeadY >= top && shiftedHeadY <= bottom) return;
373
+ const visible = lines
374
+ .filter((line) => line.y - delta >= top && line.y - delta <= bottom)
375
+ .toSorted((left, right) => left.y - right.y || left.x - right.x);
376
+ const target = delta < 0 ? visible.at(-1) : visible[0];
377
+
378
+ if (!target) return;
379
+ const next = { blockIndex: target.blockIndex, char: target.start };
380
+
381
+ flushSync(() => {
382
+ setCaret({ head: next, anchor: next });
383
+ setCursor(next.blockIndex);
384
+ });
385
+ };
386
+ };
387
+ const verticalTextPosition = (
388
+ position: TextPosition,
389
+ direction: -1 | 1,
390
+ ): { position: TextPosition; y: number } | null => {
391
+ const textLength = textLengthOf(position.blockIndex);
392
+ const cell = Math.max(0, Math.min(textLength - 1, position.char));
340
393
  const lines = allGeometry()
341
394
  .filter((line) => source.annotatable(line.blockIndex))
342
395
  .toSorted((left, right) => left.y - right.y || left.x - right.x);
343
396
  const currentIndex = lines.findIndex(
344
397
  (line) =>
345
398
  line.blockIndex === position.blockIndex &&
346
- line.start <= position.char &&
347
- position.char <= line.end,
399
+ line.start <= cell &&
400
+ (cell < line.end || line.start === line.end),
348
401
  );
349
402
  const target = currentIndex === -1 ? undefined : lines[currentIndex + direction];
350
403
 
@@ -353,8 +406,11 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
353
406
  const column = Math.max(0, position.char - current.start);
354
407
 
355
408
  return {
356
- blockIndex: target.blockIndex,
357
- char: Math.min(target.end, target.start + column),
409
+ position: {
410
+ blockIndex: target.blockIndex,
411
+ char: Math.min(target.end, target.start + column),
412
+ },
413
+ y: target.y,
358
414
  };
359
415
  };
360
416
 
@@ -671,22 +727,31 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
671
727
  );
672
728
  };
673
729
 
730
+ const moveCaretVertical = (key: KeyEvent, vertical: -1 | 1): void => {
731
+ const nextBlock = nearestAnnotatable(source, cursor, vertical);
732
+ const visualTarget = verticalTextPosition(head, vertical);
733
+
734
+ if (!visualTarget && nextBlock === head.blockIndex) return;
735
+ const nextHead = visualTarget?.position ?? {
736
+ blockIndex: nextBlock,
737
+ char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
738
+ };
739
+
740
+ if (onVerticalStep?.(head.blockIndex, nextHead.blockIndex, vertical, visualTarget?.y)) return;
741
+
742
+ setCaret({
743
+ head: nextHead,
744
+ anchor: key.shift ? caret.anchor : nextHead,
745
+ });
746
+ setFocusedDiscussion(null);
747
+ setCursor(nextHead.blockIndex);
748
+ };
749
+
674
750
  const handleCaretKey = (key: KeyEvent): boolean => {
675
751
  const vertical = verticalCaretDelta(key);
676
752
 
677
753
  if (vertical !== 0) {
678
- const nextBlock = nearestAnnotatable(source, cursor, vertical);
679
- const nextHead = verticalTextPosition(head, vertical) ?? {
680
- blockIndex: nextBlock,
681
- char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
682
- };
683
-
684
- setCaret({
685
- head: nextHead,
686
- anchor: key.shift ? caret.anchor : nextHead,
687
- });
688
- setFocusedDiscussion(null);
689
- setCursor(nextHead.blockIndex);
754
+ moveCaretVertical(key, vertical);
690
755
 
691
756
  return true;
692
757
  }
@@ -726,7 +791,7 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
726
791
  openNewCompose(sequence);
727
792
  };
728
793
 
729
- useKeyboard((key) => {
794
+ useSharedKeyboard((key) => {
730
795
  if (suspended) return;
731
796
  if (key.ctrl && key.name === "q") return onExit();
732
797
  const activeCompose = composeRef.current;
@@ -963,6 +1028,8 @@ export function useAnnotationSurface(options: AnnotationSurfaceOptions): Annotat
963
1028
  navMode,
964
1029
  focusedDiscussion,
965
1030
  revealBlockIndex,
1031
+ headVisualY,
1032
+ prepareViewportScroll,
966
1033
  spanQuote,
967
1034
  registerLine,
968
1035
  onLineMouseDown,
@@ -7,13 +7,14 @@ import type { ProjectPanelMode } from "./components/AppShell";
7
7
  import type { LaunchLayout } from "./launch-layout";
8
8
  import {
9
9
  activateTab,
10
- addTab,
11
10
  changesTab,
12
11
  closeTab,
13
12
  containsGroup,
14
- fileTab,
15
13
  firstGroupId,
14
+ keepFileTab,
16
15
  makeGroup,
16
+ openChangesTab,
17
+ openFileTab,
17
18
  splitGroup,
18
19
  welcomeTab,
19
20
  type EditorNode,
@@ -73,9 +74,10 @@ export interface ChangesWorkbench {
73
74
  toggleZoom: () => void;
74
75
  focusGroup: (groupId: string) => void;
75
76
  activate: (groupId: string, tabId: string) => void;
77
+ keepTab: (groupId: string, tabId: string) => void;
76
78
  close: (groupId: string, tabId: string) => void;
77
79
  split: (groupId: string, direction: SplitDirection) => void;
78
- openFile: (path: string, fileView: "diff" | "contents") => void;
80
+ openFile: (path: string, fileView: "diff" | "contents", persistent?: boolean) => void;
79
81
  /** Re-open defaults when the session changes: a diff opens the region in changed-files mode. */
80
82
  syncSession: (sessionId: string | undefined, isDiff: boolean) => void;
81
83
  }
@@ -121,16 +123,16 @@ export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesW
121
123
  }
122
124
  };
123
125
  const toggleChanges = (): void => {
124
- if (projectMode === "changes") {
125
- setChangesOpen(false);
126
- setProjectMode("tree");
127
- // never leave zoom on with the Changes editor gone - the Thread pane would stay hidden
128
- setZoomed(false);
129
- } else {
130
- setChangesOpen(true);
131
- setProjectOpen(true);
132
- setProjectMode("changes");
133
- }
126
+ setChangesOpen(true);
127
+ setProjectOpen(true);
128
+ setProjectMode("changes");
129
+ setGrid((tree) => {
130
+ const result = openChangesTab(tree, focusedGroup ?? firstGroupId(tree));
131
+
132
+ setFocusedGroup(result.focusGroupId);
133
+
134
+ return result.tree;
135
+ });
134
136
  };
135
137
  const toggleProject = (): void => {
136
138
  setProjectOpen(true);
@@ -160,13 +162,22 @@ export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesW
160
162
  return result.tree;
161
163
  });
162
164
  };
163
- const openFile = (path: string, fileView: "diff" | "contents"): void => {
165
+ const openFile = (path: string, fileView: "diff" | "contents", persistent = false): void => {
164
166
  setProjectOpen(true);
165
167
  setChangesOpen(true);
166
- const label = path.split("/").pop() ?? path;
167
- setGrid((tree) =>
168
- addTab(tree, focusedGroup ?? firstGroupId(tree), fileTab(label, path, fileView)),
169
- );
168
+ setGrid((tree) => {
169
+ const result = openFileTab(
170
+ tree,
171
+ focusedGroup ?? firstGroupId(tree),
172
+ path,
173
+ fileView,
174
+ persistent,
175
+ );
176
+
177
+ setFocusedGroup(result.focusGroupId);
178
+
179
+ return result.tree;
180
+ });
170
181
  };
171
182
 
172
183
  return {
@@ -182,6 +193,7 @@ export function useChangesWorkbench(options?: ChangesWorkbenchOptions): ChangesW
182
193
  toggleZoom: () => setZoomed((value) => !value),
183
194
  focusGroup: setFocusedGroup,
184
195
  activate: (groupId, tabId) => setGrid((tree) => activateTab(tree, groupId, tabId)),
196
+ keepTab: (groupId, tabId) => setGrid((tree) => keepFileTab(tree, groupId, tabId)),
185
197
  close,
186
198
  split,
187
199
  openFile,
@@ -8,6 +8,45 @@
8
8
  import { useEffect, useState } from "react";
9
9
  import { useRenderer } from "@opentui/react";
10
10
 
11
+ type Renderer = NonNullable<ReturnType<typeof useRenderer>>;
12
+ type Measure = () => void;
13
+
14
+ interface FrameSubscriptions {
15
+ measures: Set<Measure>;
16
+ notify: Measure;
17
+ }
18
+
19
+ const subscribers = new WeakMap<Renderer, FrameSubscriptions>();
20
+
21
+ export function subscribeToFrames(renderer: Renderer, measure: Measure): () => void {
22
+ let subscription = subscribers.get(renderer);
23
+
24
+ if (!subscription) {
25
+ const measures = new Set<Measure>();
26
+ const notify = (): void => {
27
+ for (const subscriber of measures) subscriber();
28
+ };
29
+
30
+ subscription = { measures, notify };
31
+ subscribers.set(renderer, subscription);
32
+ renderer.on("frame", notify);
33
+ renderer.on("resize", notify);
34
+ }
35
+
36
+ const { measures, notify } = subscription;
37
+
38
+ measures.add(measure);
39
+
40
+ return () => {
41
+ measures.delete(measure);
42
+ if (measures.size > 0) return;
43
+
44
+ renderer.off("frame", notify);
45
+ renderer.off("resize", notify);
46
+ subscribers.delete(renderer);
47
+ };
48
+ }
49
+
11
50
  export function useFrameMeasure<T>(
12
51
  read: () => T,
13
52
  isEqual: (left: T, right: T) => boolean,
@@ -27,13 +66,7 @@ export function useFrameMeasure<T>(
27
66
  };
28
67
 
29
68
  measure();
30
- renderer?.on("frame", measure);
31
- renderer?.on("resize", measure);
32
-
33
- return () => {
34
- renderer?.off("frame", measure);
35
- renderer?.off("resize", measure);
36
- };
69
+ if (renderer) return subscribeToFrames(renderer, measure);
37
70
  // eslint-disable-next-line react-hooks/exhaustive-deps
38
71
  }, [renderer, active]);
39
72
 
@@ -4,7 +4,6 @@ import {
4
4
  persistActions,
5
5
  persistAutoClose,
6
6
  persistDiffView,
7
- persistReviewSkill,
8
7
  persistReviewWorkspace,
9
8
  persistTheme,
10
9
  type AutoClose,
@@ -94,8 +93,6 @@ export function useSettingsDialog(params: {
94
93
  onSyncGithubIdentity: () => void;
95
94
  onRenameDisplayName: () => void;
96
95
  reviewSkill: string;
97
- reviewSkillOptions: string[];
98
- setReviewSkill: Dispatch<SetStateAction<string>>;
99
96
  reviewWorkspace: ReviewWorkspaceMode;
100
97
  setReviewWorkspace: Dispatch<SetStateAction<ReviewWorkspaceMode>>;
101
98
  }): SettingsDialogModel {
@@ -118,8 +115,6 @@ export function useSettingsDialog(params: {
118
115
  onSyncGithubIdentity,
119
116
  onRenameDisplayName,
120
117
  reviewSkill,
121
- reviewSkillOptions,
122
- setReviewSkill,
123
118
  reviewWorkspace,
124
119
  setReviewWorkspace,
125
120
  } = params;
@@ -210,8 +205,7 @@ export function useSettingsDialog(params: {
210
205
  {
211
206
  key: "reviewSkill",
212
207
  label: "Skill",
213
- kind: "cycle",
214
- options: reviewSkillOptions,
208
+ kind: "text",
215
209
  },
216
210
  {
217
211
  key: "reviewWorkspace",
@@ -281,12 +275,6 @@ export function useSettingsDialog(params: {
281
275
  onSyncGithubIdentity();
282
276
  } else if (rowKey === "displayName") {
283
277
  onRenameDisplayName();
284
- } else if (rowKey === "reviewSkill") {
285
- const current = Math.max(0, reviewSkillOptions.indexOf(reviewSkill));
286
- const next = reviewSkillOptions[(current + 1) % reviewSkillOptions.length]!;
287
-
288
- setReviewSkill(next);
289
- persistReviewSkill(next);
290
278
  } else if (rowKey === "reviewWorkspace") {
291
279
  const next: ReviewWorkspaceMode = reviewWorkspace === "worktree" ? "current" : "worktree";
292
280
 
@@ -0,0 +1,55 @@
1
+ /** One keypress subscription per renderer, even when several editor tiles are mounted. */
2
+
3
+ import { useEffect, useEffectEvent } from "react";
4
+ import { useRenderer } from "@opentui/react";
5
+ import type { KeyEvent } from "@opentui/core";
6
+
7
+ type KeyInput = NonNullable<ReturnType<typeof useRenderer>>["keyInput"];
8
+ type KeyHandler = (key: KeyEvent) => void;
9
+
10
+ interface KeySubscriptions {
11
+ handlers: Set<KeyHandler>;
12
+ notify: KeyHandler;
13
+ }
14
+
15
+ const subscribers = new WeakMap<KeyInput, KeySubscriptions>();
16
+
17
+ function subscribeToKeypress(input: KeyInput, handler: KeyHandler): () => void {
18
+ let subscription = subscribers.get(input);
19
+
20
+ if (!subscription) {
21
+ const handlers = new Set<KeyHandler>();
22
+ const notify = (key: KeyEvent): void => {
23
+ for (const subscriber of handlers) subscriber(key);
24
+ };
25
+
26
+ subscription = { handlers, notify };
27
+ subscribers.set(input, subscription);
28
+ input.on("keypress", notify);
29
+ }
30
+
31
+ const { handlers, notify } = subscription;
32
+
33
+ handlers.add(handler);
34
+
35
+ return () => {
36
+ handlers.delete(handler);
37
+ if (handlers.size > 0) return;
38
+
39
+ input.off("keypress", notify);
40
+ subscribers.delete(input);
41
+ };
42
+ }
43
+
44
+ export function useSharedKeyboard(handler: KeyHandler): void {
45
+ const renderer = useRenderer();
46
+ const stableHandler = useEffectEvent(handler);
47
+
48
+ useEffect(() => {
49
+ const input = renderer?.keyInput;
50
+
51
+ if (!input) return;
52
+
53
+ return subscribeToKeypress(input, stableHandler);
54
+ }, [renderer]);
55
+ }
@@ -12,6 +12,7 @@ import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "re
12
12
  import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
13
13
  import { useRenderer } from "@opentui/react";
14
14
  import { Virtualizer, type VirtualItem } from "@tanstack/virtual-core";
15
+ import { subscribeToFrames } from "./use-frame-measure";
15
16
 
16
17
  export interface TerminalVirtualizerOptions {
17
18
  scrollbox: RefObject<ScrollBoxRenderable | null>;
@@ -29,6 +30,10 @@ export interface TerminalVirtualizer {
29
30
  measureRef: (index: number) => (renderable: BoxRenderable | null) => void | (() => void);
30
31
  /** Scroll the item into view; "auto" only scrolls when it is off screen. */
31
32
  scrollToIndex: (index: number, align?: "auto" | "start" | "center" | "end") => void;
33
+ /** Measured or estimated visual-row offset of an item. */
34
+ startOfIndex: (index: number) => number | undefined;
35
+ /** Move the native viewport to an exact visual-row offset. */
36
+ scrollToOffset: (offset: number) => void;
32
37
  }
33
38
 
34
39
  /** Visible rows and one-row scrolling for a held mouse mark at a viewport edge. */
@@ -150,9 +155,8 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
150
155
  // render, and the callbacks only fire the core when the value actually changed
151
156
  const onFrame = (read: () => void): (() => void) => {
152
157
  read();
153
- renderer?.on("frame", read);
154
158
 
155
- return () => renderer?.off("frame", read);
159
+ return renderer ? subscribeToFrames(renderer, read) : () => {};
156
160
  };
157
161
 
158
162
  const coreOptions = () => ({
@@ -255,5 +259,7 @@ export function useTerminalVirtualizer(options: TerminalVirtualizerOptions): Ter
255
259
  };
256
260
  },
257
261
  scrollToIndex: (index, align = "auto") => virtualizer.scrollToIndex(index, { align }),
262
+ startOfIndex: (index) => virtualizer.getOffsetForIndex(index, "start")?.[0],
263
+ scrollToOffset: (offset) => virtualizer.scrollToOffset(offset, { align: "start" }),
258
264
  };
259
265
  }