@cueloop/client 0.1.0-alpha.75 → 0.1.0-alpha.77

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 (82) hide show
  1. package/package.json +5 -5
  2. package/src/App.focus.test.ts +147 -0
  3. package/src/App.identity.test.tsx +78 -0
  4. package/src/App.regression.test.tsx +94 -0
  5. package/src/App.share-choice.test.tsx +105 -0
  6. package/src/App.test.tsx +11 -8
  7. package/src/App.toast.test.tsx +3 -1
  8. package/src/App.tsx +648 -221
  9. package/src/App.visual.test.tsx +57 -0
  10. package/src/App.workbench.test.tsx +41 -22
  11. package/src/app-screens.tsx +181 -123
  12. package/src/app-view-model.ts +1 -0
  13. package/src/components/AnnotatableFileView.tsx +9 -1
  14. package/src/components/AnnotationCards.tsx +22 -4
  15. package/src/components/AppShell.brand.test.tsx +1 -1
  16. package/src/components/AppShell.tsx +14 -1
  17. package/src/components/ChangesColumn.tsx +40 -12
  18. package/src/components/ChangesFileTree.test.tsx +61 -0
  19. package/src/components/ConfirmDialog.tsx +8 -1
  20. package/src/components/DiffContentView.tsx +12 -2
  21. package/src/components/DiscussionMarkerRail.tsx +2 -2
  22. package/src/components/EditorGrid.tsx +84 -26
  23. package/src/components/GridTabContent.tsx +12 -1
  24. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  25. package/src/components/ManageAccessDialog.test.tsx +94 -0
  26. package/src/components/ManageAccessDialog.tsx +120 -0
  27. package/src/components/PanelColumn.tsx +3 -0
  28. package/src/components/ProjectTreeView.test.tsx +39 -12
  29. package/src/components/ProjectTreeView.tsx +38 -10
  30. package/src/components/PromptDialog.test.tsx +61 -0
  31. package/src/components/PromptDialog.tsx +16 -3
  32. package/src/components/RootOverlay.stories.tsx +1 -0
  33. package/src/components/RootOverlay.tsx +19 -6
  34. package/src/components/SettingsDialog.stories.tsx +3 -3
  35. package/src/components/SettingsDialog.tsx +3 -4
  36. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  37. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  38. package/src/components/ShareChoiceDialog.tsx +82 -0
  39. package/src/components/ThreadFooter.test.tsx +12 -7
  40. package/src/components/ThreadFooter.tsx +18 -4
  41. package/src/components/ThreadTree.tsx +79 -36
  42. package/src/components/ThreadView.slash.test.tsx +36 -0
  43. package/src/components/ThreadView.tsx +12 -2
  44. package/src/components/WelcomePlayground.tsx +2 -0
  45. package/src/components/__snapshots__/stories.test.tsx.snap +147 -52
  46. package/src/components/menu-control.stories.tsx +35 -0
  47. package/src/components/menu-control.tsx +43 -0
  48. package/src/components/primitives/Dialog.test.tsx +37 -0
  49. package/src/components/primitives/Dialog.tsx +5 -0
  50. package/src/components/primitives/icons.ts +1 -1
  51. package/src/components/quick-actions-editor.stories.tsx +2 -0
  52. package/src/components/quick-actions-editor.test.tsx +35 -1
  53. package/src/components/quick-actions-editor.tsx +23 -22
  54. package/src/components/session-tree.test.ts +3 -3
  55. package/src/components/session-tree.ts +1 -1
  56. package/src/config-identity.test.ts +78 -0
  57. package/src/config.ts +45 -1
  58. package/src/diff-intraline.ts +26 -5
  59. package/src/github-identity.test.ts +58 -0
  60. package/src/github-identity.ts +41 -0
  61. package/src/intent-dispatch.test.ts +17 -2
  62. package/src/intent-dispatch.ts +7 -1
  63. package/src/keymap.ts +1 -0
  64. package/src/perf/otlp-trace.test.ts +116 -0
  65. package/src/perf/otlp-trace.ts +130 -0
  66. package/src/perf/perf-report.ts +17 -0
  67. package/src/perf/perf-timings.test.ts +18 -0
  68. package/src/perf/perf-timings.ts +38 -0
  69. package/src/run.ts +22 -2
  70. package/src/share.ts +10 -2
  71. package/src/thread-chords.test.ts +33 -1
  72. package/src/thread-chords.ts +114 -29
  73. package/src/thread-controller.curate.test.ts +1 -0
  74. package/src/thread-controller.live-diff.test.ts +1 -0
  75. package/src/thread-controller.share.test.ts +36 -2
  76. package/src/thread-controller.tree.test.ts +1 -0
  77. package/src/thread-controller.ts +18 -3
  78. package/src/use-annotation-surface.tsx +32 -2
  79. package/src/use-frame-measure.ts +4 -1
  80. package/src/use-settings-dialog.tsx +33 -7
  81. package/src/visual/contrast.test.ts +35 -0
  82. package/src/visual/contrast.ts +33 -0
@@ -7,9 +7,15 @@
7
7
  */
8
8
 
9
9
  import React, { useContext, useLayoutEffect, useRef, useState } from "react";
10
- import type { BoxRenderable, KeyBinding, TextareaRenderable } from "@opentui/core";
10
+ import type {
11
+ BoxRenderable,
12
+ KeyBinding,
13
+ MouseEvent as TerminalMouseEvent,
14
+ TextareaRenderable,
15
+ } from "@opentui/core";
11
16
  import type { Annotation } from "@cueloop/schema";
12
17
  import type { Theme } from "../theme";
18
+ import { useTooltip } from "./Tooltip";
13
19
  import { lighten } from "../annotation-palette";
14
20
  import { useFrameMeasure } from "../use-frame-measure";
15
21
  import { skillReferenceRanges, type SlashItem } from "../slash-palette";
@@ -281,22 +287,34 @@ export function DiscussionCard({
281
287
  export function CommentRow({
282
288
  annotation,
283
289
  tokens,
290
+ authorLabel,
284
291
  }: {
285
292
  annotation: Annotation;
286
293
  tokens: Theme;
294
+ /** The author's resolved display name, shown as a tooltip when the dot is hovered. */
295
+ authorLabel?: string;
287
296
  }): React.ReactNode {
288
297
  // own comments (no author) wear the filled dot, collaborators the outline
289
298
  const own = annotation.author === undefined;
290
299
  const glyph = own ? "●" : "○";
291
300
  const glyphColor = own ? tokens.text : tokens.textMuted;
301
+ const { showTooltip, hideTooltip } = useTooltip();
302
+ const dotHover = authorLabel
303
+ ? {
304
+ onMouseOver: (event: TerminalMouseEvent) => showTooltip(authorLabel, event.x, event.y),
305
+ onMouseOut: hideTooltip,
306
+ }
307
+ : {};
292
308
 
293
309
  return (
294
310
  <box style={{ flexDirection: "column" }}>
295
311
  {annotation.body.split("\n").map((line, lineIndex) => (
296
312
  <box key={lineIndex} style={{ flexDirection: "row" }}>
297
- <text selectable={false} fg={glyphColor} style={{ flexShrink: 0 }}>
298
- {lineIndex === 0 ? `${glyph} ` : " "}
299
- </text>
313
+ <box style={{ flexShrink: 0 }} {...(lineIndex === 0 ? dotHover : {})}>
314
+ <text selectable={false} fg={glyphColor}>
315
+ {lineIndex === 0 ? `${glyph} ` : " "}
316
+ </text>
317
+ </box>
300
318
  <text fg={tokens.text} style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}>
301
319
  {line}
302
320
  </text>
@@ -22,7 +22,7 @@ async function brandFrame(): Promise<string[]> {
22
22
  onOpenMenu={() => {}}
23
23
  threadsPanel={<text>threads</text>}
24
24
  threadTitle={LONG_TITLE}
25
- threadPanel={<text>review the changes on the right</text>}
25
+ threadPanel={<text>Select a thread</text>}
26
26
  changesOpen
27
27
  projectOpen
28
28
  onToggleChanges={() => {}}
@@ -69,8 +69,11 @@ export interface AppShellProps {
69
69
  theme?: Theme;
70
70
  threadsWidth?: number;
71
71
  projectWidth?: number;
72
+ onFocusPane?: (pane: FocusPane) => void;
72
73
  }
73
74
 
75
+ export type FocusPane = "threads" | "thread" | "changes" | "project";
76
+
74
77
  /** The right region's reserved width: the open project pane, nothing when the region is closed, else the collapsed rail. */
75
78
  function rightRegionColumns(
76
79
  projectOpen: boolean,
@@ -122,6 +125,7 @@ export function AppShell({
122
125
  theme,
123
126
  threadsWidth = 30,
124
127
  projectWidth = 32,
128
+ onFocusPane,
125
129
  }: AppShellProps): React.ReactNode {
126
130
  const tokens = theme ?? DARK;
127
131
  const { width: terminalWidth } = useTerminalDimensions();
@@ -214,7 +218,13 @@ export function AppShell({
214
218
  <TooltipProvider theme={tokens}>
215
219
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
216
220
  {sidebarOpen ? (
217
- <PanelColumn width={threadsWidth} border="right" header={brandChrome} theme={tokens}>
221
+ <PanelColumn
222
+ width={threadsWidth}
223
+ border="right"
224
+ header={brandChrome}
225
+ onFocus={() => onFocusPane?.("threads")}
226
+ theme={tokens}
227
+ >
218
228
  {threadsPanel}
219
229
  </PanelColumn>
220
230
  ) : null}
@@ -237,6 +247,7 @@ export function AppShell({
237
247
  reopenRightControl,
238
248
  rightRegionClosed,
239
249
  )}
250
+ onFocus={() => onFocusPane?.("thread")}
240
251
  theme={tokens}
241
252
  >
242
253
  {threadPanel}
@@ -244,6 +255,7 @@ export function AppShell({
244
255
  ) : null}
245
256
  {changesOpen ? (
246
257
  <box
258
+ onMouseDown={() => onFocusPane?.("changes")}
247
259
  style={{
248
260
  flexDirection: "column",
249
261
  flexGrow: 1,
@@ -266,6 +278,7 @@ export function AppShell({
266
278
  border="left"
267
279
  header={null}
268
280
  headerRight={projectToggles}
281
+ onFocus={() => onFocusPane?.("project")}
269
282
  theme={tokens}
270
283
  >
271
284
  {projectPanel}
@@ -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,8 +9,8 @@
9
9
  */
10
10
 
11
11
  import React, { useEffect, useMemo, useRef, useState } from "react";
12
- import { createTextAttributes, type ScrollBoxRenderable } from "@opentui/core";
13
- import type { Thread } from "@cueloop/schema";
12
+ import { createTextAttributes, type KeyEvent, type ScrollBoxRenderable } from "@opentui/core";
13
+ import type { Annotation, Thread } from "@cueloop/schema";
14
14
  import { diffRowText, fileChangeCounts, type DiffRow, type Mark } from "../view-diff";
15
15
  import type { TextSpan } from "../thread-selection";
16
16
  import type { QuickAction } from "../config";
@@ -95,6 +95,10 @@ 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
+ /** The author's display name for a comment's hover tooltip. */
99
+ resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
100
+ leaderCombos?: readonly string[];
101
+ onLeaderCommand?: (key: KeyEvent) => void;
98
102
  onExit: () => void;
99
103
  /** Row indices the owner rejected during curation; drawn struck through. */
100
104
  rejectedRows?: Set<number>;
@@ -487,6 +491,9 @@ export function DiffContentView({
487
491
  onAnnotate,
488
492
  onReply,
489
493
  onUpdateAnnotation,
494
+ resolveAuthorLabel,
495
+ leaderCombos,
496
+ onLeaderCommand,
490
497
  onExit,
491
498
  rejectedRows = EMPTY_REJECTED,
492
499
  fold,
@@ -520,6 +527,9 @@ export function DiffContentView({
520
527
  onAnnotate,
521
528
  onReply,
522
529
  onUpdateAnnotation,
530
+ resolveAuthorLabel,
531
+ leaderCombos,
532
+ onLeaderCommand,
523
533
  onExit,
524
534
  });
525
535
  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,9 @@ export type DiffSurfaceProps = Pick<
34
34
  | "onAnnotate"
35
35
  | "onReply"
36
36
  | "onUpdateAnnotation"
37
+ | "resolveAuthorLabel"
38
+ | "leaderCombos"
39
+ | "onLeaderCommand"
37
40
  | "onExit"
38
41
  >;
39
42
 
@@ -56,7 +59,14 @@ export function ChangesTabBody(props: {
56
59
 
57
60
  if (props.rows.length === 0) {
58
61
  return (
59
- <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
62
+ <box
63
+ style={{
64
+ flexGrow: 1,
65
+ flexDirection: "column",
66
+ alignItems: "center",
67
+ justifyContent: "center",
68
+ }}
69
+ >
60
70
  <text fg={props.theme.textDim}>No changes</text>
61
71
  </box>
62
72
  );
@@ -112,6 +122,7 @@ export function GridTabContent(props: {
112
122
  onAddComment={(anchor, body) => props.onAddFileComment(filePath, anchor, body)}
113
123
  onReply={props.surface.onReply}
114
124
  onUpdateAnnotation={props.surface.onUpdateAnnotation}
125
+ resolveAuthorLabel={props.surface.resolveAuthorLabel}
115
126
  onExit={props.surface.onExit}
116
127
  theme={props.theme}
117
128
  />
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ManageAccessDialog } from "./ManageAccessDialog";
5
+
6
+ export const meta: StoryMeta = { title: "Dialogs/ManageAccess" };
7
+
8
+ export const WithHandles: Story = {
9
+ render: () => (
10
+ <ManageAccessDialog
11
+ isOpen
12
+ logins={["octocat", "hubot"]}
13
+ onAdd={() => {}}
14
+ onRemove={() => {}}
15
+ onCreateLink={() => {}}
16
+ onClose={() => {}}
17
+ theme={DARK}
18
+ />
19
+ ),
20
+ size: { width: 60, height: 18 },
21
+ };
22
+
23
+ export const Empty: Story = {
24
+ render: () => (
25
+ <ManageAccessDialog
26
+ isOpen
27
+ logins={[]}
28
+ onAdd={() => {}}
29
+ onRemove={() => {}}
30
+ onCreateLink={() => {}}
31
+ onClose={() => {}}
32
+ theme={DARK}
33
+ />
34
+ ),
35
+ size: { width: 60, height: 18 },
36
+ };