@cueloop/client 0.1.0-alpha.78 → 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.diff.test.tsx +9 -4
- 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 +75 -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 +6 -4
- package/src/App.test.tsx +31 -28
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +259 -274
- package/src/App.walk.test.tsx +9 -8
- package/src/App.workbench.test.tsx +86 -0
- package/src/annotation-palette.ts +1 -0
- package/src/app-screens.tsx +12 -6
- package/src/app-view-model.ts +0 -1
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/DiffContentView.tsx +14 -7
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- 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/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +1 -1
- package/src/components/ThreadView.slash.test.tsx +0 -35
- package/src/components/ThreadView.tsx +142 -68
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +2 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- 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/share-dialog-store.ts +58 -0
- package/src/components/stories.test.tsx +4 -3
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- 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.ts +0 -1
- 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/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +8 -6
- package/src/share.ts +15 -1
- 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.live-diff.test.ts +3 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +30 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +229 -54
- package/src/use-annotation-surface.tsx +45 -36
- 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 -122
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -80
- 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
|
@@ -35,11 +35,14 @@ export interface SettingsDialogModel {
|
|
|
35
35
|
cycleSetting: (rowKey: string) => void;
|
|
36
36
|
handleSettingsKey: (name: string) => void;
|
|
37
37
|
onCategorySelect: (categoryId: string) => void;
|
|
38
|
+
/** Open the settings dialog focused on the left nav, from a fresh state. */
|
|
39
|
+
openSettings: () => void;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
const DOWN_KEYS = new Set(["j", "down"]);
|
|
41
43
|
const UP_KEYS = new Set(["k", "up"]);
|
|
42
|
-
const ENTER_BODY_KEYS = new Set(["l", "tab", "return"]);
|
|
44
|
+
const ENTER_BODY_KEYS = new Set(["l", "right", "tab", "return"]);
|
|
45
|
+
const BACK_TO_NAV_KEYS = new Set(["h", "left", "tab"]);
|
|
43
46
|
const ACTIVATE_KEYS = new Set(["return", "space", "l"]);
|
|
44
47
|
|
|
45
48
|
function moveNavZone(
|
|
@@ -64,7 +67,7 @@ function moveNavZone(
|
|
|
64
67
|
function moveBodyRow(name: string, nav: SettingsNav, rowCount: number): SettingsNav | null {
|
|
65
68
|
if (DOWN_KEYS.has(name)) return { ...nav, rowIndex: Math.min(rowCount - 1, nav.rowIndex + 1) };
|
|
66
69
|
if (UP_KEYS.has(name)) return { ...nav, rowIndex: Math.max(0, nav.rowIndex - 1) };
|
|
67
|
-
if (name
|
|
70
|
+
if (BACK_TO_NAV_KEYS.has(name)) return { ...nav, zone: "nav" };
|
|
68
71
|
|
|
69
72
|
return null;
|
|
70
73
|
}
|
|
@@ -108,10 +111,11 @@ export function useSettingsDialog(params: {
|
|
|
108
111
|
onRenameDisplayName,
|
|
109
112
|
} = params;
|
|
110
113
|
|
|
114
|
+
// open focused on the left nav, so up/down browses categories until l/tab/enter enters the body
|
|
111
115
|
const [settingsNav, setSettingsNav] = useState<SettingsNav>({
|
|
112
116
|
categoryId: "general",
|
|
113
117
|
rowIndex: 0,
|
|
114
|
-
zone: "
|
|
118
|
+
zone: "nav",
|
|
115
119
|
});
|
|
116
120
|
const [actionsExpandedIndex, setActionsExpandedIndex] = useState<number | null>(null);
|
|
117
121
|
const [actionsExpandedField, setActionsExpandedField] = useState<"prompt" | "metadata">("prompt");
|
|
@@ -247,6 +251,12 @@ export function useSettingsDialog(params: {
|
|
|
247
251
|
}
|
|
248
252
|
};
|
|
249
253
|
|
|
254
|
+
const openSettings = (): void => {
|
|
255
|
+
setSettingsNav({ categoryId: "general", rowIndex: 0, zone: "nav" });
|
|
256
|
+
setActionsExpandedIndex(null);
|
|
257
|
+
setMenuDialog("settings");
|
|
258
|
+
};
|
|
259
|
+
|
|
250
260
|
const onCategorySelect = (categoryId: string): void => {
|
|
251
261
|
// ignore a select that names no real category, so the key handler never dereferences nothing
|
|
252
262
|
if (!settingsCategories.some((category) => category.id === categoryId)) return;
|
|
@@ -305,5 +315,6 @@ export function useSettingsDialog(params: {
|
|
|
305
315
|
cycleSetting,
|
|
306
316
|
handleSettingsKey,
|
|
307
317
|
onCategorySelect,
|
|
318
|
+
openSettings,
|
|
308
319
|
};
|
|
309
320
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The thread pane's inline edit mode: owns whether the native markdown editor is
|
|
3
|
+
* open over the body, the editor element itself, and the open and exit handles.
|
|
4
|
+
* Opening is gated to an editable thread (the caller passes canEdit - owner,
|
|
5
|
+
* unresolved, not a diff); exiting saves the edited body as the working copy
|
|
6
|
+
* through the controller, so leaving the editor never loses work. The header
|
|
7
|
+
* edit/normal toggle exits through requestExit; switching threads closes the
|
|
8
|
+
* editor. Rendering the editor lives here too, so the pane pulls in one concept
|
|
9
|
+
* rather than wiring the component and its handle ref by hand.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useRef, useState } from "react";
|
|
13
|
+
import type { Theme } from "./theme";
|
|
14
|
+
import type { ReviewController } from "./thread-controller";
|
|
15
|
+
import { MarkdownThreadEditor, type MarkdownEditorHandle } from "./components/MarkdownThreadEditor";
|
|
16
|
+
|
|
17
|
+
export interface ThreadBodyEditing {
|
|
18
|
+
/** True while the inline markdown editor owns the thread pane and its keys. */
|
|
19
|
+
editing: boolean;
|
|
20
|
+
/** Open the editor when the active thread is editable; a no-op otherwise. */
|
|
21
|
+
openEditor: () => void;
|
|
22
|
+
/** Save the current text and close - the header edit/normal toggle's exit. */
|
|
23
|
+
requestExit: () => void;
|
|
24
|
+
/** The editor element for the thread pane, bound to the working copy and theme. */
|
|
25
|
+
renderEditor: (theme: Theme) => React.ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useThreadBodyEditing(options: {
|
|
29
|
+
controller: ReviewController;
|
|
30
|
+
sessionId: string | undefined;
|
|
31
|
+
canEdit: boolean;
|
|
32
|
+
}): ThreadBodyEditing {
|
|
33
|
+
const { controller, sessionId, canEdit } = options;
|
|
34
|
+
// key editing by thread id so a thread switch closes the editor with no effect or ref
|
|
35
|
+
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
|
36
|
+
const handleRef = useRef<MarkdownEditorHandle | null>(null);
|
|
37
|
+
const editing = editingSessionId !== null && editingSessionId === sessionId;
|
|
38
|
+
const exitEditor = (text: string): void => {
|
|
39
|
+
controller.saveEditedBody(text);
|
|
40
|
+
setEditingSessionId(null);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
editing,
|
|
45
|
+
openEditor: () => {
|
|
46
|
+
if (canEdit && sessionId !== undefined) setEditingSessionId(sessionId);
|
|
47
|
+
},
|
|
48
|
+
requestExit: () => handleRef.current?.requestExit(),
|
|
49
|
+
renderEditor: (theme) => (
|
|
50
|
+
<MarkdownThreadEditor
|
|
51
|
+
ref={handleRef}
|
|
52
|
+
initialText={controller.working()}
|
|
53
|
+
theme={theme}
|
|
54
|
+
onExitEditor={exitEditor}
|
|
55
|
+
/>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/view-plan.test.ts
CHANGED
|
@@ -9,10 +9,14 @@ import {
|
|
|
9
9
|
renderedOffsetAtOrAfter,
|
|
10
10
|
renderedOffsetAtOrBefore,
|
|
11
11
|
renderedOffsetFor,
|
|
12
|
+
renderedSpanToWork,
|
|
13
|
+
renderedStyleRuns,
|
|
14
|
+
renderedText,
|
|
12
15
|
safeLinkHref,
|
|
13
16
|
spanFromRange,
|
|
14
17
|
spanKey,
|
|
15
18
|
startSpan,
|
|
19
|
+
styledRunsFor,
|
|
16
20
|
workRangeForRendered,
|
|
17
21
|
} from "./view-plan";
|
|
18
22
|
import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
|
|
@@ -273,9 +277,9 @@ describe("rendered/work offset mapping", () => {
|
|
|
273
277
|
const rendered = renderedOffsetFor(runs, wordsAt)!;
|
|
274
278
|
|
|
275
279
|
// Assert
|
|
276
|
-
const
|
|
280
|
+
const renderedString = runs.map((run) => run.text).join("");
|
|
277
281
|
|
|
278
|
-
expect(
|
|
282
|
+
expect(renderedString.slice(rendered, rendered + "words".length)).toBe("words");
|
|
279
283
|
// reading a rendered selection over "words" recovers the work range
|
|
280
284
|
expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
|
|
281
285
|
start: wordsAt,
|
|
@@ -287,11 +291,11 @@ describe("rendered/work offset mapping", () => {
|
|
|
287
291
|
// Arrange
|
|
288
292
|
const block = buildDisplay("old words\n", "new words\n")[0]!;
|
|
289
293
|
const runs = blockRuns(block, true);
|
|
290
|
-
const
|
|
294
|
+
const renderedString = runs.map((run) => run.text).join("");
|
|
291
295
|
|
|
292
296
|
// Act
|
|
293
297
|
// select everything rendered: the work range is the whole work text
|
|
294
|
-
const range = workRangeForRendered(runs, 0,
|
|
298
|
+
const range = workRangeForRendered(runs, 0, renderedString.length)!;
|
|
295
299
|
|
|
296
300
|
// Assert
|
|
297
301
|
expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
|
|
@@ -319,9 +323,9 @@ describe("rendered/work offset mapping", () => {
|
|
|
319
323
|
const renderedEnd = renderedOffsetAtOrBefore(runs, spanEnd - 1)!;
|
|
320
324
|
|
|
321
325
|
// Assert
|
|
322
|
-
const
|
|
326
|
+
const renderedString = runs.map((run) => run.text).join("");
|
|
323
327
|
|
|
324
|
-
expect(
|
|
328
|
+
expect(renderedString.slice(renderedStart, renderedEnd + 1)).toBe("very");
|
|
325
329
|
// exact hits behave like renderedOffsetFor
|
|
326
330
|
expect(renderedOffsetAtOrAfter(runs, 0)).toBe(0);
|
|
327
331
|
expect(renderedOffsetAtOrBefore(runs, 0)).toBe(0);
|
|
@@ -404,3 +408,49 @@ describe("cut / restore round-trip", () => {
|
|
|
404
408
|
expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
|
|
405
409
|
});
|
|
406
410
|
});
|
|
411
|
+
|
|
412
|
+
describe("rendered-text projection (markers concealed)", () => {
|
|
413
|
+
const linkedBlock = () => buildDisplay("Use the `flag` and see [docs](https://x.dev).")[0]!;
|
|
414
|
+
|
|
415
|
+
test("renderedText drops inline markers, keeping visible characters", () => {
|
|
416
|
+
expect(renderedText(linkedBlock())).toBe("Use the flag and see docs.");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("renderedText leaves a table block's source verbatim", () => {
|
|
420
|
+
const table = buildDisplay("| a | b |\n| --- | --- |\n| 1 | 2 |")[0]!;
|
|
421
|
+
|
|
422
|
+
expect(table.kind).toBe("table");
|
|
423
|
+
expect(renderedText(table)).toBe("| a | b |\n| --- | --- |\n| 1 | 2 |");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("renderedSpanToWork maps a rendered link label back to its work offsets", () => {
|
|
427
|
+
const display = buildDisplay("Use the `flag` and see [docs](https://x.dev).");
|
|
428
|
+
// "docs" sits at rendered offset 21 ("Use the flag and see ".length)
|
|
429
|
+
const work = renderedSpanToWork(display, 0, 0, 21, 25);
|
|
430
|
+
const workText = display[0]!.work!.text;
|
|
431
|
+
|
|
432
|
+
expect(workText.slice(work.start, work.end)).toBe("docs");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("renderedSpanToWork maps rendered inline code back to the backtick content", () => {
|
|
436
|
+
const display = buildDisplay("Use the `flag` and see docs.");
|
|
437
|
+
// "flag" sits at rendered offset 8 ("Use the ".length)
|
|
438
|
+
const work = renderedSpanToWork(display, 0, 0, 8, 12);
|
|
439
|
+
|
|
440
|
+
expect(display[0]!.work!.text.slice(work.start, work.end)).toBe("flag");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("styledRunsFor colors a link run and marks the covered stretch", () => {
|
|
444
|
+
const linkRow = buildDisplay("see [docs](https://x.dev) now")[0]!;
|
|
445
|
+
const roleRuns = renderedStyleRuns(linkRow);
|
|
446
|
+
// "see docs now"; mark the whole "docs" label (rendered offsets 4..8)
|
|
447
|
+
const runs = styledRunsFor(roleRuns, { start: 0, end: renderedText(linkRow).length }, [
|
|
448
|
+
{ start: 4, end: 8 },
|
|
449
|
+
]);
|
|
450
|
+
const link = runs.find((run) => run.role === "link")!;
|
|
451
|
+
|
|
452
|
+
expect(link.text).toBe("docs");
|
|
453
|
+
expect(link.marked).toBe(true);
|
|
454
|
+
expect(runs.find((run) => run.text === "see ")!.marked).toBe(false);
|
|
455
|
+
});
|
|
456
|
+
});
|
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
|
-
});
|