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

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 (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. package/src/components/agent-launcher.tsx +0 -149
package/src/view-plan.ts CHANGED
@@ -17,7 +17,8 @@ import {
17
17
  type Block,
18
18
  } from "@cueloop/schema";
19
19
  import { wordLevelChanges } from "./diff-intraline";
20
- import { spanRangeInBlock, type TextSpan } from "./thread-selection";
20
+ import { spanRangeInBlock, type CharRange, type TextSpan } from "./thread-selection";
21
+ import type { MarkRange, VisualLine } from "./mark-runs";
21
22
 
22
23
  // ── display model ─────────────────────────────
23
24
 
@@ -37,6 +38,18 @@ export function displayText(block: DisplayBlock): string {
37
38
  return (block.work ?? block.base)!.text;
38
39
  }
39
40
 
41
+ /**
42
+ * The text a block actually paints: every rendered run's text in order, with
43
+ * inline markers concealed. This is the coordinate space the read-only thread
44
+ * surface hit-tests and paints in; it differs from displayText wherever a block
45
+ * carries inline markup (a link, code, or emphasis) whose markers drop away.
46
+ */
47
+ export function renderedText(block: DisplayBlock): string {
48
+ return blockRuns(block, true)
49
+ .map((run) => run.text)
50
+ .join("");
51
+ }
52
+
40
53
  /** Word-overlap similarity gate: an unrelated cut + insert must not merge. */
41
54
  function similar(baseBlock: Block, workBlock: Block): boolean {
42
55
  if (baseBlock.kind !== workBlock.kind) return false;
@@ -214,15 +227,27 @@ export function marksByDisplay(
214
227
  const endEntry = workEntries[resolved.endBlockIndex];
215
228
 
216
229
  if (!startEntry || !endEntry) continue;
230
+ // resolveAnchor works in the block's work text; the surface paints and hit-tests in rendered
231
+ // text (markers concealed), so map the span onto the visible characters it covers
232
+ const renderedStart = renderedOffsetAtOrAfter(
233
+ blockRuns(startEntry.block, true),
234
+ resolved.start,
235
+ );
236
+ const renderedEndInclusive = renderedOffsetAtOrBefore(
237
+ blockRuns(endEntry.block, true),
238
+ Math.max(0, resolved.end - 1),
239
+ );
240
+
241
+ if (renderedStart === null || renderedEndInclusive === null) continue;
217
242
  const span: TextSpan = {
218
- start: { blockIndex: startEntry.displayIndex, char: resolved.start },
219
- end: { blockIndex: endEntry.displayIndex, char: resolved.end },
243
+ start: { blockIndex: startEntry.displayIndex, char: renderedStart },
244
+ end: { blockIndex: endEntry.displayIndex, char: renderedEndInclusive + 1 },
220
245
  };
221
246
 
222
247
  // a span paints on every block it covers; the mark carries the whole span
223
248
  for (let workIndex = resolved.blockIndex; workIndex <= resolved.endBlockIndex; workIndex++) {
224
249
  const entry = workEntries[workIndex]!;
225
- const range = spanRangeInBlock(span, entry.displayIndex, entry.block.work!.text.length);
250
+ const range = spanRangeInBlock(span, entry.displayIndex, renderedText(entry.block).length);
226
251
 
227
252
  if (!range) continue;
228
253
  const marks = marksByIndex.get(entry.displayIndex) ?? [];
@@ -243,7 +268,11 @@ export function marksByDisplay(
243
268
 
244
269
  /** Base runs for a display block: plain text, or word-diff for mod blocks. */
245
270
  export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
246
- const literal = block.kind === "code" || block.kind === "hr";
271
+ const literal =
272
+ block.kind === "code" ||
273
+ block.kind === "hr" ||
274
+ block.kind === "table" ||
275
+ block.kind === "frontmatter";
247
276
 
248
277
  if (block.type === "mod" && markup) {
249
278
  const changes = wordLevelChanges(block.base!.text, block.work!.text);
@@ -315,6 +344,94 @@ export function inlineStyleRuns(text: string, base: number): StyleRun[] {
315
344
  return runs;
316
345
  }
317
346
 
347
+ /** A rendered run positioned by its offset in the block's rendered (concealed) text. */
348
+ export interface RenderedRun {
349
+ text: string;
350
+ role: RunRole;
351
+ /** Offset of this run in the block's rendered text. */
352
+ start: number;
353
+ href?: string;
354
+ }
355
+
356
+ /** The block's role runs positioned in rendered-text coordinates, for painting and hit-testing. */
357
+ export function renderedStyleRuns(block: DisplayBlock): RenderedRun[] {
358
+ const runs: RenderedRun[] = [];
359
+ let rendered = 0;
360
+
361
+ for (const run of blockRuns(block, true)) {
362
+ const positioned: RenderedRun = { text: run.text, role: run.role, start: rendered };
363
+
364
+ if (run.href !== undefined) positioned.href = run.href;
365
+ runs.push(positioned);
366
+ rendered += run.text.length;
367
+ }
368
+
369
+ return runs;
370
+ }
371
+
372
+ /** A painted run of one visual line: its role, its href, and whether a mark or the caret cell covers it. */
373
+ export interface StyledRun {
374
+ text: string;
375
+ role: RunRole;
376
+ marked: boolean;
377
+ caretOnly: boolean;
378
+ href?: string;
379
+ }
380
+
381
+ /**
382
+ * Slice a block's rendered role runs to one visual line and split them further
383
+ * at mark boundaries, so each painted run carries both its markdown role and
384
+ * whether a discussion mark or the caret cell covers it. Every coordinate here
385
+ * is a rendered-text offset; `markRanges` are block-level and clipped to the line.
386
+ */
387
+ export function styledRunsFor(
388
+ roleRuns: RenderedRun[],
389
+ line: VisualLine,
390
+ markRanges: MarkRange[],
391
+ ): StyledRun[] {
392
+ const cuts = new Set<number>([line.start, line.end]);
393
+
394
+ for (const run of roleRuns) {
395
+ if (run.start > line.start && run.start < line.end) cuts.add(run.start);
396
+ const runEnd = run.start + run.text.length;
397
+
398
+ if (runEnd > line.start && runEnd < line.end) cuts.add(runEnd);
399
+ }
400
+ for (const range of markRanges) {
401
+ const start = Math.max(line.start, range.start);
402
+ const end = Math.min(line.end, range.end);
403
+
404
+ if (start < end) {
405
+ cuts.add(start);
406
+ cuts.add(end);
407
+ }
408
+ }
409
+ const edges = [...cuts].toSorted((left, right) => left - right);
410
+ const runs: StyledRun[] = [];
411
+
412
+ for (let index = 0; index < edges.length - 1; index++) {
413
+ const start = edges[index]!;
414
+ const end = edges[index + 1]!;
415
+
416
+ if (end <= start) continue;
417
+ const roleRun = roleRuns.find(
418
+ (candidate) => candidate.start <= start && start < candidate.start + candidate.text.length,
419
+ );
420
+ const covering = markRanges.filter((range) => range.start <= start && end <= range.end);
421
+ const styled: StyledRun = {
422
+ text: roleRun ? roleRun.text.slice(start - roleRun.start, end - roleRun.start) : "",
423
+ role: roleRun?.role ?? "plain",
424
+ marked: covering.some((range) => range.caretOnly !== true),
425
+ caretOnly: covering.some((range) => range.caretOnly === true),
426
+ };
427
+
428
+ if (roleRun?.href !== undefined) styled.href = roleRun.href;
429
+ runs.push(styled);
430
+ }
431
+
432
+ return runs.length > 0 ? runs : [{ text: "", role: "plain", marked: false, caretOnly: false }];
433
+ }
434
+
318
435
  /** Split runs at mark boundaries; marks only bind to runs with offsets. */
319
436
  export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
320
437
  if (!marks.length) return runs;
@@ -455,6 +572,38 @@ export function workRangeForRendered(
455
572
  return range;
456
573
  }
457
574
 
575
+ /**
576
+ * Map a rendered-text selection back to the work-text range an anchor is cut
577
+ * from. The read-only surface yields rendered offsets (markers concealed); an
578
+ * anchor's quote must come from the block's work text, so convert at the seam
579
+ * before makeAnchor. A selection may cross blocks: the start rendered offset
580
+ * lives in `displayIndex`, the end in `endDisplayIndex`.
581
+ */
582
+ export function renderedSpanToWork(
583
+ display: DisplayBlock[],
584
+ displayIndex: number,
585
+ endDisplayIndex: number,
586
+ renderedStart: number,
587
+ renderedEnd: number,
588
+ ): CharRange {
589
+ const startBlock = display[displayIndex]!;
590
+
591
+ if (displayIndex === endDisplayIndex) {
592
+ const range = workRangeForRendered(blockRuns(startBlock, true), renderedStart, renderedEnd);
593
+
594
+ return range ?? { start: renderedStart, end: renderedEnd };
595
+ }
596
+ const startRuns = blockRuns(startBlock, true);
597
+ const startRange = workRangeForRendered(
598
+ startRuns,
599
+ renderedStart,
600
+ renderedText(startBlock).length,
601
+ );
602
+ const endRange = workRangeForRendered(blockRuns(display[endDisplayIndex]!, true), 0, renderedEnd);
603
+
604
+ return { start: startRange?.start ?? renderedStart, end: endRange?.end ?? renderedEnd };
605
+ }
606
+
458
607
  /** Line delta between two revision contents, for the sheet-header summary. */
459
608
  export function revisionDelta(previousContent: string, nextContent: string) {
460
609
  const ops = lcsDiff(previousContent.split("\n"), nextContent.split("\n"));
@@ -1,105 +0,0 @@
1
- /** The share shortcut opens an app-level public/private choice reachable from any view. */
2
-
3
- import { afterEach, beforeEach, describe, mock, test } from "bun:test";
4
- import { mkdtempSync, rmSync } from "node:fs";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
7
- import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
- import { DaemonServer } from "@cueloop/daemon";
10
- import type { Thread } from "@cueloop/schema";
11
- import { App } from "./App";
12
- import type { ShareTransport } from "./thread-controller";
13
- import { isolateUserConfig, press, pressKey, waitForText, waitForTextGone } from "./test-support";
14
-
15
- const shareTransport: ShareTransport = {
16
- publish: mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true })),
17
- pull: mock(async () => {
18
- throw new Error("Unexpected share pull");
19
- }),
20
- push: mock(async () => {}),
21
- watch: () => () => {},
22
- parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
23
- collaboratorAnnotations: () => [],
24
- mergeFromShare: () => ({ annotations: [] }),
25
- };
26
-
27
- const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
28
-
29
- let home: string;
30
- let restoreUserConfig: () => void;
31
- let server: DaemonServer;
32
- let session: Thread;
33
-
34
- beforeEach(() => {
35
- home = mkdtempSync(join(tmpdir(), "cueloop-share-choice-"));
36
- restoreUserConfig = isolateUserConfig(home);
37
- server = new DaemonServer({ home, idleExitMs: 0 });
38
- server.start();
39
- session = server.core.sessionCreate({
40
- workspace: { repoRoot: "/repo", branch: "main" },
41
- artifact: {
42
- type: "plan",
43
- content: PLAN,
44
- meta: { title: "Migration Plan", planPath: "plan.md" },
45
- },
46
- });
47
- });
48
- afterEach(() => {
49
- restoreUserConfig();
50
- server.stop();
51
- rmSync(home, { recursive: true, force: true });
52
- });
53
-
54
- describe("share choice", () => {
55
- test("the shortcut opens the public/private choice", async () => {
56
- // Arrange
57
- const setup = await testRender(
58
- <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
59
- { width: 120, height: 32 },
60
- );
61
- await waitForText(setup, "cueloop");
62
-
63
- // Act
64
- await pressKey(setup, "s", { ctrl: true });
65
-
66
- // Assert
67
- await waitForText(setup, "public link");
68
- await waitForText(setup, "private link");
69
- });
70
-
71
- test("the private choice opens the manage-access surface", async () => {
72
- // Arrange
73
- const setup = await testRender(
74
- <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
75
- { width: 120, height: 32 },
76
- );
77
- await waitForText(setup, "cueloop");
78
-
79
- // Act: open the choice, move to private, select it
80
- await pressKey(setup, "s", { ctrl: true });
81
- await waitForText(setup, "private link");
82
- await press(setup, "down");
83
- await press(setup, "enter");
84
-
85
- // Assert
86
- await waitForText(setup, "Manage access");
87
- });
88
-
89
- test("escape cancels the choice without sharing", async () => {
90
- // Arrange
91
- const setup = await testRender(
92
- <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
93
- { width: 120, height: 32 },
94
- );
95
- await waitForText(setup, "cueloop");
96
-
97
- // Act
98
- await pressKey(setup, "s", { ctrl: true });
99
- await waitForText(setup, "public link");
100
- await press(setup, "escape");
101
-
102
- // Assert
103
- await waitForTextGone(setup, "public link");
104
- });
105
- });
@@ -1,36 +0,0 @@
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
- };
@@ -1,94 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import React, { useState } from "react";
3
- import { testRender } from "@opentui/react/test-utils";
4
- import { DARK } from "../theme";
5
- import { clickText, locateTextInFrame, settle, typeText } from "../test-support";
6
- import { ManageAccessDialog } from "./ManageAccessDialog";
7
-
8
- function AccessHarness({ initial }: { initial: string[] }): React.ReactNode {
9
- const [logins, setLogins] = useState<string[]>(initial);
10
- const [open, setOpen] = useState(true);
11
-
12
- return (
13
- <box style={{ width: 60, height: 18 }}>
14
- <ManageAccessDialog
15
- isOpen={open}
16
- logins={logins}
17
- onAdd={(login) => setLogins((prev) => [...prev, login])}
18
- onRemove={(login) => setLogins((prev) => prev.filter((entry) => entry !== login))}
19
- onCreateLink={() => {}}
20
- onClose={() => setOpen(false)}
21
- theme={DARK}
22
- />
23
- </box>
24
- );
25
- }
26
-
27
- async function renderAccess(initial: string[]): Promise<Awaited<ReturnType<typeof testRender>>> {
28
- const setup = await testRender(<AccessHarness initial={initial} />, { width: 60, height: 18 });
29
-
30
- await settle(setup);
31
-
32
- return setup;
33
- }
34
-
35
- describe("ManageAccessDialog", () => {
36
- test("renders the allowlist of GitHub handles", async () => {
37
- const setup = await renderAccess(["octocat", "hubot"]);
38
- const frame = setup.captureCharFrame();
39
-
40
- expect(locateTextInFrame(frame, "@octocat")).not.toBeNull();
41
- expect(locateTextInFrame(frame, "@hubot")).not.toBeNull();
42
- });
43
-
44
- test("clicking a row removes that handle from the allowlist", async () => {
45
- const setup = await renderAccess(["octocat", "hubot"]);
46
-
47
- await clickText(setup, "@octocat");
48
-
49
- expect(locateTextInFrame(setup.captureCharFrame(), "@octocat")).toBeNull();
50
- expect(locateTextInFrame(setup.captureCharFrame(), "@hubot")).not.toBeNull();
51
- });
52
-
53
- test("typing a handle and pressing add appends it to the allowlist", async () => {
54
- const setup = await renderAccess([]);
55
-
56
- await typeText(setup, "octocat");
57
- await clickText(setup, "add");
58
-
59
- expect(locateTextInFrame(setup.captureCharFrame(), "@octocat")).not.toBeNull();
60
- });
61
-
62
- test("clicking create link publishes the private link", async () => {
63
- let created = false;
64
- const setup = await testRender(
65
- <box style={{ width: 60, height: 18 }}>
66
- <ManageAccessDialog
67
- isOpen
68
- logins={["octocat"]}
69
- onAdd={() => {}}
70
- onRemove={() => {}}
71
- onCreateLink={() => {
72
- created = true;
73
- }}
74
- onClose={() => {}}
75
- theme={DARK}
76
- />
77
- </box>,
78
- { width: 60, height: 18 },
79
- );
80
-
81
- await settle(setup);
82
- await clickText(setup, "create link");
83
-
84
- expect(created).toBe(true);
85
- });
86
-
87
- test("clicking close dismisses the dialog", async () => {
88
- const setup = await renderAccess([]);
89
-
90
- await clickText(setup, "close");
91
-
92
- expect(locateTextInFrame(setup.captureCharFrame(), "Manage access")).toBeNull();
93
- });
94
- });
@@ -1,120 +0,0 @@
1
- /**
2
- * Owner surface for a private share's allowlist: the GitHub logins allowed to
3
- * open the private link. Type a handle and add it; click a row to remove one.
4
- * Editing persists through the controller, so the list survives a reload.
5
- */
6
-
7
- import React, { useEffect, useRef, useState } from "react";
8
- import { useKeyboard } from "@opentui/react";
9
- import { useTerminalDimensions } from "@opentui/react";
10
- import type { KeyBinding, TextareaRenderable } from "@opentui/core";
11
- import type { Theme } from "../theme";
12
- import { useComponentTheme } from "./theme-context";
13
- import { Dialog } from "./primitives/Dialog";
14
-
15
- // enter adds the handle rather than inserting a newline into the one-line field
16
- const ADD_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
17
-
18
- export interface ManageAccessDialogProps {
19
- isOpen: boolean;
20
- logins: string[];
21
- onAdd: (login: string) => void;
22
- onRemove: (login: string) => void;
23
- /** Publish the private link with the current allowlist and copy its connection line. */
24
- onCreateLink: () => void;
25
- onClose: () => void;
26
- theme?: Theme;
27
- }
28
-
29
- export function ManageAccessDialog({
30
- isOpen,
31
- logins,
32
- onAdd,
33
- onRemove,
34
- onCreateLink,
35
- onClose,
36
- theme,
37
- }: ManageAccessDialogProps): React.ReactNode {
38
- const tokens = useComponentTheme(theme);
39
- const { width: terminalWidth } = useTerminalDimensions();
40
- const inputRef = useRef<TextareaRenderable | null>(null);
41
- const [draft, setDraft] = useState("");
42
- const [inputGeneration, setInputGeneration] = useState(0);
43
-
44
- useEffect(() => {
45
- inputRef.current?.focus();
46
- }, [inputGeneration]);
47
-
48
- const addDraft = (): void => {
49
- const handle = draft.trim().replace(/^@/, "").toLowerCase();
50
-
51
- if (handle && !logins.includes(handle)) onAdd(handle);
52
- setDraft("");
53
- setInputGeneration((generation) => generation + 1);
54
- };
55
-
56
- useKeyboard((key) => {
57
- if (isOpen && key.name === "escape") onClose();
58
- });
59
-
60
- if (!isOpen) return null;
61
-
62
- return (
63
- <Dialog
64
- isOpen
65
- title=" Manage access "
66
- width={Math.min(54, terminalWidth - 6)}
67
- height={Math.min(18, 9 + logins.length)}
68
- background={tokens.elevated}
69
- onDismiss={onClose}
70
- theme={theme}
71
- >
72
- <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}>
73
- <text fg={tokens.textDim}>Only these GitHub users can open the private link.</text>
74
- <box style={{ height: 1 }} />
75
- {logins.length === 0 ? (
76
- <text fg={tokens.textDim}>no one yet - add a handle below</text>
77
- ) : (
78
- logins.map((login) => (
79
- <box key={login} onMouseUp={() => onRemove(login)} style={{ flexDirection: "row" }}>
80
- <text fg={tokens.text}>{`@${login}`}</text>
81
- <box style={{ flexGrow: 1 }} />
82
- <text fg={tokens.textDim}>remove</text>
83
- </box>
84
- ))
85
- )}
86
- <box style={{ flexGrow: 1 }} />
87
- <box style={{ flexDirection: "row" }}>
88
- <text fg={tokens.textDim}>{"@"}</text>
89
- <textarea
90
- key={inputGeneration}
91
- ref={inputRef}
92
- focused
93
- placeholder="handle"
94
- keyBindings={ADD_KEY_BINDINGS}
95
- onContentChange={() => setDraft(inputRef.current?.plainText ?? "")}
96
- style={{
97
- height: 1,
98
- flexGrow: 1,
99
- backgroundColor: tokens.elevated,
100
- focusedBackgroundColor: tokens.elevated,
101
- textColor: tokens.text,
102
- focusedTextColor: tokens.text,
103
- }}
104
- />
105
- </box>
106
- <box style={{ flexDirection: "row" }}>
107
- <box onMouseUp={addDraft} style={{ marginRight: 3 }}>
108
- <text fg={tokens.accent}>add</text>
109
- </box>
110
- <box onMouseUp={onCreateLink} style={{ marginRight: 3 }}>
111
- <text fg={tokens.accent}>create link</text>
112
- </box>
113
- <box onMouseUp={onClose}>
114
- <text fg={tokens.textDim}>close</text>
115
- </box>
116
- </box>
117
- </box>
118
- </Dialog>
119
- );
120
- }
@@ -1,34 +0,0 @@
1
- import React from "react";
2
- import { DARK } from "../theme";
3
- import type { Story, StoryMeta } from "./story";
4
- import { ShareChoiceDialog } from "./ShareChoiceDialog";
5
-
6
- export const meta: StoryMeta = { title: "Dialogs/ShareChoice" };
7
-
8
- export const PublicSelected: Story = {
9
- render: () => (
10
- <ShareChoiceDialog
11
- isOpen
12
- selectedIndex={0}
13
- onPublicShare={() => {}}
14
- onPrivateShare={() => {}}
15
- onClose={() => {}}
16
- theme={DARK}
17
- />
18
- ),
19
- size: { width: 60, height: 12 },
20
- };
21
-
22
- export const PrivateSelected: Story = {
23
- render: () => (
24
- <ShareChoiceDialog
25
- isOpen
26
- selectedIndex={1}
27
- onPublicShare={() => {}}
28
- onPrivateShare={() => {}}
29
- onClose={() => {}}
30
- theme={DARK}
31
- />
32
- ),
33
- size: { width: 60, height: 12 },
34
- };
@@ -1,103 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import React from "react";
3
- import { testRender } from "@opentui/react/test-utils";
4
- import { DARK } from "../theme";
5
- import { clickText, settle } from "../test-support";
6
- import { ShareChoiceDialog } from "./ShareChoiceDialog";
7
-
8
- interface ShareCounts {
9
- publicShares: number;
10
- privateShares: number;
11
- closes: number;
12
- }
13
-
14
- async function renderDialog(selectedIndex = 0): Promise<{
15
- setup: Awaited<ReturnType<typeof testRender>>;
16
- counts: ShareCounts;
17
- }> {
18
- const counts: ShareCounts = { publicShares: 0, privateShares: 0, closes: 0 };
19
- const setup = await testRender(
20
- <box style={{ width: 60, height: 12 }}>
21
- <ShareChoiceDialog
22
- isOpen
23
- selectedIndex={selectedIndex}
24
- onPublicShare={() => {
25
- counts.publicShares += 1;
26
- }}
27
- onPrivateShare={() => {
28
- counts.privateShares += 1;
29
- }}
30
- onClose={() => {
31
- counts.closes += 1;
32
- }}
33
- theme={DARK}
34
- />
35
- </box>,
36
- { width: 60, height: 12 },
37
- );
38
-
39
- await settle(setup);
40
-
41
- return { setup, counts };
42
- }
43
-
44
- describe("ShareChoiceDialog", () => {
45
- test("offers both the public and private choice", async () => {
46
- // Arrange + Act
47
- const { setup } = await renderDialog();
48
-
49
- // Assert
50
- const frame = setup.captureCharFrame();
51
-
52
- expect(frame).toContain("public link");
53
- expect(frame).toContain("private link");
54
- });
55
-
56
- test("clicking the public choice publishes and closes", async () => {
57
- // Arrange
58
- const { setup, counts } = await renderDialog();
59
-
60
- // Act
61
- await clickText(setup, "public link");
62
-
63
- // Assert
64
- expect(counts.publicShares).toBe(1);
65
- expect(counts.privateShares).toBe(0);
66
- expect(counts.closes).toBe(1);
67
- });
68
-
69
- test("clicking the private choice opens manage-access and closes", async () => {
70
- // Arrange
71
- const { setup, counts } = await renderDialog();
72
-
73
- // Act
74
- await clickText(setup, "private link");
75
-
76
- // Assert
77
- expect(counts.privateShares).toBe(1);
78
- expect(counts.publicShares).toBe(0);
79
- expect(counts.closes).toBe(1);
80
- });
81
-
82
- test("renders nothing while closed", async () => {
83
- // Arrange + Act
84
- const setup = await testRender(
85
- <box style={{ width: 60, height: 12 }}>
86
- <ShareChoiceDialog
87
- isOpen={false}
88
- selectedIndex={0}
89
- onPublicShare={() => {}}
90
- onPrivateShare={() => {}}
91
- onClose={() => {}}
92
- theme={DARK}
93
- />
94
- </box>,
95
- { width: 60, height: 12 },
96
- );
97
-
98
- await settle(setup);
99
-
100
- // Assert
101
- expect(setup.captureCharFrame()).not.toContain("public link");
102
- });
103
- });