@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.
- package/package.json +5 -5
- package/src/App.focus.test.ts +147 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.regression.test.tsx +94 -0
- package/src/App.share-choice.test.tsx +105 -0
- package/src/App.test.tsx +11 -8
- package/src/App.toast.test.tsx +3 -1
- package/src/App.tsx +648 -221
- package/src/App.visual.test.tsx +57 -0
- package/src/App.workbench.test.tsx +41 -22
- package/src/app-screens.tsx +181 -123
- package/src/app-view-model.ts +1 -0
- package/src/components/AnnotatableFileView.tsx +9 -1
- package/src/components/AnnotationCards.tsx +22 -4
- package/src/components/AppShell.brand.test.tsx +1 -1
- package/src/components/AppShell.tsx +14 -1
- package/src/components/ChangesColumn.tsx +40 -12
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/ConfirmDialog.tsx +8 -1
- package/src/components/DiffContentView.tsx +12 -2
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tsx +84 -26
- package/src/components/GridTabContent.tsx +12 -1
- package/src/components/ManageAccessDialog.stories.tsx +36 -0
- package/src/components/ManageAccessDialog.test.tsx +94 -0
- package/src/components/ManageAccessDialog.tsx +120 -0
- package/src/components/PanelColumn.tsx +3 -0
- package/src/components/ProjectTreeView.test.tsx +39 -12
- package/src/components/ProjectTreeView.tsx +38 -10
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +16 -3
- package/src/components/RootOverlay.stories.tsx +1 -0
- package/src/components/RootOverlay.tsx +19 -6
- package/src/components/SettingsDialog.stories.tsx +3 -3
- package/src/components/SettingsDialog.tsx +3 -4
- package/src/components/ShareChoiceDialog.stories.tsx +34 -0
- package/src/components/ShareChoiceDialog.test.tsx +103 -0
- package/src/components/ShareChoiceDialog.tsx +82 -0
- package/src/components/ThreadFooter.test.tsx +12 -7
- package/src/components/ThreadFooter.tsx +18 -4
- package/src/components/ThreadTree.tsx +79 -36
- package/src/components/ThreadView.slash.test.tsx +36 -0
- package/src/components/ThreadView.tsx +12 -2
- package/src/components/WelcomePlayground.tsx +2 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +147 -52
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +5 -0
- package/src/components/primitives/icons.ts +1 -1
- package/src/components/quick-actions-editor.stories.tsx +2 -0
- package/src/components/quick-actions-editor.test.tsx +35 -1
- package/src/components/quick-actions-editor.tsx +23 -22
- package/src/components/session-tree.test.ts +3 -3
- package/src/components/session-tree.ts +1 -1
- package/src/config-identity.test.ts +78 -0
- package/src/config.ts +45 -1
- package/src/diff-intraline.ts +26 -5
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/intent-dispatch.test.ts +17 -2
- package/src/intent-dispatch.ts +7 -1
- package/src/keymap.ts +1 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/run.ts +22 -2
- package/src/share.ts +10 -2
- package/src/thread-chords.test.ts +33 -1
- package/src/thread-chords.ts +114 -29
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +1 -0
- package/src/thread-controller.share.test.ts +36 -2
- package/src/thread-controller.tree.test.ts +1 -0
- package/src/thread-controller.ts +18 -3
- package/src/use-annotation-surface.tsx +32 -2
- package/src/use-frame-measure.ts +4 -1
- package/src/use-settings-dialog.tsx +33 -7
- package/src/visual/contrast.test.ts +35 -0
- 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 {
|
|
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
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
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>
|
|
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
|
|
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={
|
|
127
|
-
|
|
128
|
-
setCollapsedIds((current) => {
|
|
129
|
-
const next = new Set(current);
|
|
130
|
-
|
|
131
|
-
if (next.has(id)) next.delete(id);
|
|
132
|
-
else next.add(id);
|
|
158
|
+
onSelect={(id) => {
|
|
159
|
+
const index = rows.findIndex((row) => row.id === id);
|
|
133
160
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
161
|
+
if (index >= 0) setCursor(index);
|
|
162
|
+
onSelectFile(id);
|
|
163
|
+
}}
|
|
164
|
+
onToggle={toggle}
|
|
137
165
|
theme={theme}
|
|
138
166
|
/>
|
|
139
167
|
</ScrollArea>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The changed-files tree navigates by keyboard when focused: j/k move the cursor, tab folds a folder
|
|
2
|
+
* or opens a file. */
|
|
3
|
+
|
|
4
|
+
import { test, expect } from "bun:test";
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
7
|
+
import type { DiffFileContents } from "@cueloop/schema";
|
|
8
|
+
import { ChangesFileTree } from "./ChangesColumn";
|
|
9
|
+
import { settle, waitForText } from "../test-support";
|
|
10
|
+
import { DARK } from "../theme";
|
|
11
|
+
|
|
12
|
+
const file = (path: string): DiffFileContents => ({
|
|
13
|
+
path,
|
|
14
|
+
oldContents: "",
|
|
15
|
+
newContents: "x",
|
|
16
|
+
status: "modified",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const files = [file("src/a.ts"), file("src/b.ts")];
|
|
20
|
+
|
|
21
|
+
test("j moves onto a file and tab opens it", async () => {
|
|
22
|
+
const opened: string[] = [];
|
|
23
|
+
const setup = await testRender(
|
|
24
|
+
<ChangesFileTree
|
|
25
|
+
files={files}
|
|
26
|
+
onSelectFile={(path) => opened.push(path)}
|
|
27
|
+
focused
|
|
28
|
+
theme={DARK}
|
|
29
|
+
/>,
|
|
30
|
+
{ width: 40, height: 12 },
|
|
31
|
+
);
|
|
32
|
+
await settle(setup);
|
|
33
|
+
await waitForText(setup, "a.ts");
|
|
34
|
+
|
|
35
|
+
// folders open by default; cursor starts on the "src" folder, j steps onto a.ts
|
|
36
|
+
setup.mockInput.pressKey("j");
|
|
37
|
+
await settle(setup);
|
|
38
|
+
setup.mockInput.pressKey("TAB");
|
|
39
|
+
await settle(setup);
|
|
40
|
+
|
|
41
|
+
expect(opened).toEqual(["src/a.ts"]);
|
|
42
|
+
|
|
43
|
+
setup.renderer.destroy();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("without focus the keyboard opens nothing", async () => {
|
|
47
|
+
const opened: string[] = [];
|
|
48
|
+
const setup = await testRender(
|
|
49
|
+
<ChangesFileTree files={files} onSelectFile={(path) => opened.push(path)} theme={DARK} />,
|
|
50
|
+
{ width: 40, height: 12 },
|
|
51
|
+
);
|
|
52
|
+
await settle(setup);
|
|
53
|
+
await waitForText(setup, "a.ts");
|
|
54
|
+
|
|
55
|
+
setup.mockInput.pressKey("j");
|
|
56
|
+
setup.mockInput.pressKey("TAB");
|
|
57
|
+
await settle(setup);
|
|
58
|
+
expect(opened).toEqual([]);
|
|
59
|
+
|
|
60
|
+
setup.renderer.destroy();
|
|
61
|
+
});
|
|
@@ -40,7 +40,14 @@ export function ConfirmDialog({
|
|
|
40
40
|
if (!isOpen) return null;
|
|
41
41
|
|
|
42
42
|
return (
|
|
43
|
-
<Dialog
|
|
43
|
+
<Dialog
|
|
44
|
+
isOpen
|
|
45
|
+
title={title}
|
|
46
|
+
width={Math.min(54, terminalWidth - 6)}
|
|
47
|
+
height={7}
|
|
48
|
+
onDismiss={onCancel}
|
|
49
|
+
theme={theme}
|
|
50
|
+
>
|
|
44
51
|
<box
|
|
45
52
|
style={{
|
|
46
53
|
flexDirection: "column",
|
|
@@ -9,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: "
|
|
31
|
-
{ label: "
|
|
32
|
-
{ label: "
|
|
33
|
-
{ label: "
|
|
33
|
+
{ label: "left", direction: "left", arrow: "←" },
|
|
34
|
+
{ label: "right", direction: "right", arrow: "→" },
|
|
35
|
+
{ label: "up", direction: "up", arrow: "↑" },
|
|
36
|
+
{ label: "down", direction: "down", arrow: "↓" },
|
|
34
37
|
];
|
|
35
38
|
|
|
39
|
+
const MENU_WIDTH = 12;
|
|
40
|
+
|
|
41
|
+
/** An arrow key maps straight to its split direction; other keys leave the menu untouched. */
|
|
42
|
+
function splitDirectionForKey(name: string): SplitDirection | null {
|
|
43
|
+
const item = SPLIT_ITEMS.find((entry) => entry.direction === name);
|
|
44
|
+
|
|
45
|
+
return item ? item.direction : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
function EditorTabButton({
|
|
37
49
|
tab,
|
|
38
50
|
active,
|
|
@@ -151,13 +163,13 @@ function SplitMenu({
|
|
|
151
163
|
}): React.ReactNode {
|
|
152
164
|
return (
|
|
153
165
|
<box
|
|
166
|
+
onMouseUp={(event) => event.stopPropagation()}
|
|
154
167
|
style={{
|
|
168
|
+
width: MENU_WIDTH,
|
|
155
169
|
flexDirection: "column",
|
|
156
|
-
flexShrink: 0,
|
|
157
170
|
borderStyle: "single",
|
|
158
171
|
borderColor: tokens.border,
|
|
159
|
-
|
|
160
|
-
backgroundColor: tokens.panel,
|
|
172
|
+
backgroundColor: tokens.elevated,
|
|
161
173
|
}}
|
|
162
174
|
>
|
|
163
175
|
{SPLIT_ITEMS.map((item) => (
|
|
@@ -168,7 +180,7 @@ function SplitMenu({
|
|
|
168
180
|
>
|
|
169
181
|
<text fg={tokens.text}>{item.label}</text>
|
|
170
182
|
<box style={{ flexGrow: 1 }} />
|
|
171
|
-
<text fg={tokens.textDim}>{
|
|
183
|
+
<text fg={tokens.textDim}>{item.arrow}</text>
|
|
172
184
|
</box>
|
|
173
185
|
))}
|
|
174
186
|
</box>
|
|
@@ -183,9 +195,63 @@ function EditorGroupPane({
|
|
|
183
195
|
props: EditorGridProps;
|
|
184
196
|
}): React.ReactNode {
|
|
185
197
|
const tokens = props.theme ?? DARK;
|
|
186
|
-
const
|
|
198
|
+
const menuControl = useMenuControl();
|
|
199
|
+
const { setOverlay, clearOverlay } = useRootOverlay();
|
|
200
|
+
const menuId = `split:${group.id}`;
|
|
201
|
+
const menuOpen = menuControl.openMenuId === menuId;
|
|
202
|
+
const splitRef = useRef<BoxRenderable | null>(null);
|
|
203
|
+
const anchor = useFrameMeasure(
|
|
204
|
+
() => ({
|
|
205
|
+
x: splitRef.current?.x ?? 0,
|
|
206
|
+
y: splitRef.current?.y ?? 0,
|
|
207
|
+
width: splitRef.current?.width ?? 0,
|
|
208
|
+
}),
|
|
209
|
+
(left, right) => left.x === right.x && left.y === right.y && left.width === right.width,
|
|
210
|
+
{ x: 0, y: 0, width: 0 },
|
|
211
|
+
menuOpen,
|
|
212
|
+
);
|
|
187
213
|
const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
|
|
188
214
|
const isFile = active?.kind === "file";
|
|
215
|
+
|
|
216
|
+
useKeyboard((key) => {
|
|
217
|
+
if (!menuOpen) return;
|
|
218
|
+
if (key.name === "escape") return menuControl.closeMenu();
|
|
219
|
+
const direction = splitDirectionForKey(key.name);
|
|
220
|
+
if (direction) {
|
|
221
|
+
props.onSplit(group.id, direction);
|
|
222
|
+
menuControl.closeMenu();
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (!menuOpen) return;
|
|
228
|
+
setOverlay(
|
|
229
|
+
"split-menu",
|
|
230
|
+
<box
|
|
231
|
+
onMouseUp={menuControl.closeMenu}
|
|
232
|
+
style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%" }}
|
|
233
|
+
>
|
|
234
|
+
<box
|
|
235
|
+
style={{
|
|
236
|
+
position: "absolute",
|
|
237
|
+
top: anchor.y + 1,
|
|
238
|
+
left: Math.max(0, anchor.x + anchor.width - MENU_WIDTH),
|
|
239
|
+
}}
|
|
240
|
+
>
|
|
241
|
+
<SplitMenu
|
|
242
|
+
onPick={(direction) => {
|
|
243
|
+
props.onSplit(group.id, direction);
|
|
244
|
+
menuControl.closeMenu();
|
|
245
|
+
}}
|
|
246
|
+
tokens={tokens}
|
|
247
|
+
/>
|
|
248
|
+
</box>
|
|
249
|
+
</box>,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return () => clearOverlay("split-menu");
|
|
253
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
254
|
+
}, [menuOpen, anchor, tokens]);
|
|
189
255
|
// The strip windows its tabs rather than scrolling a nested scrollbox (which stalls the
|
|
190
256
|
// renderer inside this header): tabs keep the width of their names, only the run that fits
|
|
191
257
|
// renders, and the window follows the active tab so a tab opened past the edge is never hidden.
|
|
@@ -281,13 +347,14 @@ function EditorGroupPane({
|
|
|
281
347
|
<box style={{ flexDirection: "row", flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
|
|
282
348
|
{/* search sits out until it does something; split takes its place */}
|
|
283
349
|
{isFile ? (
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
350
|
+
<box ref={splitRef} style={{ flexShrink: 0, marginRight: 2 }}>
|
|
351
|
+
<IconButton
|
|
352
|
+
glyph="split"
|
|
353
|
+
active={menuOpen}
|
|
354
|
+
onPress={() => menuControl.toggleMenu(menuId)}
|
|
355
|
+
theme={tokens}
|
|
356
|
+
/>
|
|
357
|
+
</box>
|
|
291
358
|
) : null}
|
|
292
359
|
<IconButton
|
|
293
360
|
glyph={NERD.zoom}
|
|
@@ -298,15 +365,6 @@ function EditorGroupPane({
|
|
|
298
365
|
/>
|
|
299
366
|
</box>
|
|
300
367
|
</box>
|
|
301
|
-
{menuOpen ? (
|
|
302
|
-
<SplitMenu
|
|
303
|
-
onPick={(direction) => {
|
|
304
|
-
setMenuOpen(false);
|
|
305
|
-
props.onSplit(group.id, direction);
|
|
306
|
-
}}
|
|
307
|
-
tokens={tokens}
|
|
308
|
-
/>
|
|
309
|
-
) : null}
|
|
310
368
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
311
369
|
{active ? props.renderTab(active, props.focusedGroupId === group.id) : null}
|
|
312
370
|
</box>
|
|
@@ -34,6 +34,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
|
|
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
|
+
};
|