@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.
- package/package.json +14 -13
- package/src/App.autoclose.test.tsx +3 -3
- package/src/App.diff.test.tsx +22 -5
- package/src/App.export.test.tsx +1 -2
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +91 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +39 -8
- package/src/App.test.tsx +33 -30
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +263 -274
- package/src/App.walk.test.tsx +11 -10
- package/src/App.workbench.test.tsx +86 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +1 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-screens.tsx +19 -9
- package/src/app-view-model.ts +3 -1
- package/src/components/AnnotatableFileView.tsx +1 -1
- package/src/components/AnnotationCards.tsx +18 -2
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/CompletionOverlay.stories.tsx +15 -1
- package/src/components/CompletionOverlay.tsx +22 -5
- package/src/components/ConfirmCard.stories.tsx +2 -0
- package/src/components/ConfirmCard.tsx +45 -18
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/ConfirmDialog.tsx +11 -12
- package/src/components/DiffContentView.tsx +18 -11
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/FileContentsView.tsx +1 -1
- package/src/components/GridTabContent.tsx +8 -2
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/PromptDialog.test.tsx +2 -2
- package/src/components/PromptDialog.tsx +9 -10
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +12 -7
- package/src/components/ThreadView.selection.test.tsx +88 -1
- package/src/components/ThreadView.slash.test.tsx +0 -36
- package/src/components/ThreadView.tsx +200 -67
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -8
- package/src/components/WalkWizard.stories.tsx +2 -2
- package/src/components/WalkWizard.tsx +10 -12
- package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +5 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/session-tree.ts +7 -6
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +13 -4
- package/src/components/stories.test.tsx +12 -4
- package/src/components/story-tree.test.ts +32 -24
- package/src/components/story.ts +32 -15
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/diff-intraline.test.ts +75 -2
- package/src/diff-intraline.ts +95 -26
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.test.ts +2 -2
- package/src/keymap.ts +0 -2
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +39 -25
- package/src/serve.ts +1 -1
- package/src/share.ts +16 -2
- package/src/slash-palette.test.ts +17 -0
- package/src/slash-palette.ts +11 -0
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +386 -78
- package/src/use-annotation-surface.tsx +88 -63
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -120
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -82
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- 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:
|
|
219
|
-
end: { blockIndex: endEntry.displayIndex, char:
|
|
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.
|
|
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 =
|
|
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
|
-
});
|