@cueloop/client 0.1.0-alpha.44 → 0.1.0-alpha.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.plan-review.test.tsx +16 -2
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.submit-confirm.test.tsx +6 -8
- package/src/App.tsx +26 -21
- package/src/App.walk.test.tsx +2 -1
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/components/AnnotationCard.stories.tsx +7 -11
- package/src/components/AnnotationCard.tsx +13 -11
- package/src/components/ConfirmCard.stories.tsx +3 -24
- package/src/components/ConfirmCard.tsx +7 -15
- package/src/components/MarkerPopover.tsx +7 -1
- package/src/components/PlanSheet.tsx +115 -48
- package/src/components/ReviewRail.stories.tsx +0 -2
- package/src/components/ReviewRail.tsx +2 -9
- package/src/components/__snapshots__/stories.test.tsx.snap +9 -9
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +105 -0
- package/src/view-plan.ts +96 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.45",
|
|
4
4
|
"description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
30
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
29
|
+
"@cueloop/daemon": "0.1.0-alpha.45",
|
|
30
|
+
"@cueloop/extension-api": "0.1.0-alpha.45",
|
|
31
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.45",
|
|
32
|
+
"@cueloop/schema": "0.1.0-alpha.45",
|
|
33
33
|
"@opentui/core": "^0.5.2",
|
|
34
34
|
"@opentui/keymap": "0.5.2",
|
|
35
35
|
"@opentui/react": "^0.5.2",
|
|
@@ -87,6 +87,20 @@ function backgroundsOf(setup: Setup, needle: string): string[] {
|
|
|
87
87
|
return backgrounds;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
function foregroundsOf(setup: Setup, needle: string): string[] {
|
|
91
|
+
const foregrounds: string[] = [];
|
|
92
|
+
for (const line of setup.captureSpans().lines) {
|
|
93
|
+
for (const span of line.spans) {
|
|
94
|
+
if (!span.text.includes(needle)) continue;
|
|
95
|
+
const [red, green, blue] = span.fg.toInts();
|
|
96
|
+
foregrounds.push(
|
|
97
|
+
"#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return foregrounds;
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
/** Move the cursor to "The daemon persists sessions to disk atomically." */
|
|
91
105
|
async function toContextParagraph(setup: Setup): Promise<void> {
|
|
92
106
|
await press(setup, "j");
|
|
@@ -615,9 +629,9 @@ describe("edit-exit reconciliation", () => {
|
|
|
615
629
|
|
|
616
630
|
// Act
|
|
617
631
|
// deselect the card so e reaches the editor hand-off, then edit; deselection
|
|
618
|
-
// shows as the card's
|
|
632
|
+
// shows as the card's quote line dropping the selection tone (blue)
|
|
619
633
|
await press(setup, "escape");
|
|
620
|
-
await waitForState(setup, () => !
|
|
634
|
+
await waitForState(setup, () => !foregroundsOf(setup, "The daemon").includes(DARK.blue));
|
|
621
635
|
await press(setup, "e");
|
|
622
636
|
|
|
623
637
|
// Assert
|
|
@@ -150,10 +150,10 @@ describe("observer navigation still works", () => {
|
|
|
150
150
|
await press(setup, "n");
|
|
151
151
|
await setup.renderOnce();
|
|
152
152
|
|
|
153
|
-
// Assert - focus shows as the card's
|
|
154
|
-
//
|
|
153
|
+
// Assert - focus shows as the card's blue border (transparent fill); a
|
|
154
|
+
// comment card is the only blue-bordered box in the frame
|
|
155
155
|
await waitForText(setup, "COMMENT · me");
|
|
156
|
-
await waitForState(setup, () => hasBorderColor(setup, DARK.
|
|
156
|
+
await waitForState(setup, () => hasBorderColor(setup, DARK.blue));
|
|
157
157
|
});
|
|
158
158
|
});
|
|
159
159
|
|
|
@@ -87,9 +87,8 @@ describe("rail submit confirm", () => {
|
|
|
87
87
|
// Assert
|
|
88
88
|
await waitForText(setup, "submit review");
|
|
89
89
|
const frame = setup.captureCharFrame();
|
|
90
|
-
// the card: title,
|
|
90
|
+
// the card: title, verdict selector, word-buttons
|
|
91
91
|
expect(frame).toContain("submit review");
|
|
92
|
-
expect(frame).toContain("0 annotations · 0 blocking");
|
|
93
92
|
expect(frame).toContain("[Approve]"); // nothing pending: approve default
|
|
94
93
|
expect(frame).toContain(" Submit ");
|
|
95
94
|
expect(frame).toContain(" Cancel ");
|
|
@@ -136,7 +135,7 @@ describe("rail submit confirm", () => {
|
|
|
136
135
|
// Assert
|
|
137
136
|
// a bare ESC settles after the parser's escape-sequence window
|
|
138
137
|
const frame = await waitForTextGone(setup, "[Approve]");
|
|
139
|
-
expect(frame).not.toContain("
|
|
138
|
+
expect(frame).not.toContain("submit review");
|
|
140
139
|
expect(frame).toContain("Submit review");
|
|
141
140
|
});
|
|
142
141
|
|
|
@@ -149,10 +148,9 @@ describe("rail submit confirm", () => {
|
|
|
149
148
|
await press(setup, "enter");
|
|
150
149
|
|
|
151
150
|
// Assert
|
|
152
|
-
await waitForText(setup, "
|
|
151
|
+
await waitForText(setup, "[Changes]"); // pending items: request changes default
|
|
153
152
|
const frame = setup.captureCharFrame();
|
|
154
|
-
expect(frame).toContain("
|
|
155
|
-
expect(frame).toContain("[Changes]"); // pending items: request changes default
|
|
153
|
+
expect(frame).toContain("submit review");
|
|
156
154
|
|
|
157
155
|
// Act
|
|
158
156
|
await setup.mockInput.typeText("Tighten the steps.");
|
|
@@ -183,7 +181,7 @@ describe("rail submit confirm", () => {
|
|
|
183
181
|
await press(setup, "enter");
|
|
184
182
|
|
|
185
183
|
// Assert
|
|
186
|
-
await waitForText(setup, "
|
|
184
|
+
await waitForText(setup, "[Changes]");
|
|
187
185
|
// the card is pinned outside the scrollbox; the stack stays scrolled off the top
|
|
188
186
|
expect(setup.captureCharFrame()).not.toContain("note 01");
|
|
189
187
|
|
|
@@ -196,7 +194,7 @@ describe("rail submit confirm", () => {
|
|
|
196
194
|
// Assert
|
|
197
195
|
await waitForText(setup, "note 12");
|
|
198
196
|
const scrolledWithCard = setup.captureCharFrame();
|
|
199
|
-
expect(scrolledWithCard).toContain("
|
|
197
|
+
expect(scrolledWithCard).toContain("submit review");
|
|
200
198
|
expect(scrolledWithCard).not.toContain("note 01");
|
|
201
199
|
});
|
|
202
200
|
|
package/src/App.tsx
CHANGED
|
@@ -58,7 +58,7 @@ import { CLIENT_VERSION } from "./version";
|
|
|
58
58
|
import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
|
|
59
59
|
import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
|
|
60
60
|
import { DiffSheet } from "./components/DiffSheet";
|
|
61
|
-
import {
|
|
61
|
+
import { type ReviewRailHandle } from "./components/ReviewRail";
|
|
62
62
|
import type { AgentTerminalHandle } from "./components/agent-launcher";
|
|
63
63
|
import { ReviewPanel } from "./components/ReviewPanel";
|
|
64
64
|
import {
|
|
@@ -853,30 +853,36 @@ export function App({
|
|
|
853
853
|
}
|
|
854
854
|
: null;
|
|
855
855
|
|
|
856
|
+
// a mouse drag leaves a native selection: turn it into a word span so the
|
|
857
|
+
// marker popover opens at the dragged range, mirroring the `v` grammar.
|
|
858
|
+
// Returns whether a native selection existed (the release ended a drag).
|
|
859
|
+
const activateSpanFromSelection = (preferredIndex?: number): boolean => {
|
|
860
|
+
if (!renderer?.hasSelection) return false;
|
|
861
|
+
// the release block re-anchors the span, so each drag replaces the last
|
|
862
|
+
const selection = planSheetRef.current?.readSelection(preferredIndex);
|
|
863
|
+
const block = selection ? display[selection.displayIndex] : undefined;
|
|
864
|
+
const span =
|
|
865
|
+
selection && block
|
|
866
|
+
? spanFromRange(selection.displayIndex, displayText(block), selection.start, selection.end)
|
|
867
|
+
: null;
|
|
868
|
+
if (span) setMode({ type: "span", span });
|
|
869
|
+
return true;
|
|
870
|
+
};
|
|
871
|
+
|
|
856
872
|
const onLineActivate = (displayIndex: number): void => {
|
|
857
|
-
|
|
858
|
-
// marker popover opens at the dragged range, mirroring the `v` grammar
|
|
859
|
-
if (renderer?.hasSelection) {
|
|
860
|
-
// the release block re-anchors the span, so each drag replaces the last
|
|
861
|
-
const selection = planSheetRef.current?.readSelection(displayIndex);
|
|
862
|
-
const block = selection ? display[selection.displayIndex] : undefined;
|
|
863
|
-
const span =
|
|
864
|
-
selection && block
|
|
865
|
-
? spanFromRange(
|
|
866
|
-
selection.displayIndex,
|
|
867
|
-
displayText(block),
|
|
868
|
-
selection.start,
|
|
869
|
-
selection.end,
|
|
870
|
-
)
|
|
871
|
-
: null;
|
|
872
|
-
if (span) setMode({ type: "span", span });
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
873
|
+
if (activateSpanFromSelection(displayIndex)) return;
|
|
875
874
|
setCursor(displayIndex);
|
|
876
875
|
const annotationId = marks.get(displayIndex)?.[0]?.annotationId;
|
|
877
876
|
if (annotationId) selectCardFromDocument(annotationId);
|
|
878
877
|
};
|
|
879
878
|
|
|
879
|
+
// a drag released outside any block's text (the gutter, past a line end, a
|
|
880
|
+
// gap between blocks) never reaches a block handler; the sheet-level release
|
|
881
|
+
// still turns the finished drag into a span
|
|
882
|
+
const onSelectionRelease = (): void => {
|
|
883
|
+
activateSpanFromSelection();
|
|
884
|
+
};
|
|
885
|
+
|
|
880
886
|
const onEditRequest = (): void => {
|
|
881
887
|
// A share viewer/observer has no Edit affordance (the button is hidden), so
|
|
882
888
|
// this is owner-only; stay silent rather than nag if it is ever reached.
|
|
@@ -916,8 +922,6 @@ export function App({
|
|
|
916
922
|
? {
|
|
917
923
|
verdict: mode.verdict,
|
|
918
924
|
summary: mode.summary,
|
|
919
|
-
annotationCount: reviewerAnnotations(activeSession).length,
|
|
920
|
-
blockingCount: reviewerAnnotations(activeSession).filter(annotationBlocking).length,
|
|
921
925
|
// walk coverage keeps partial passes honest at the verdict
|
|
922
926
|
viewedSummary:
|
|
923
927
|
isDiff && activeSession.viewedPaths !== undefined
|
|
@@ -1044,6 +1048,7 @@ export function App({
|
|
|
1044
1048
|
popover={popoverState}
|
|
1045
1049
|
editOrphanCount={editOrphanCount}
|
|
1046
1050
|
onLineActivate={onLineActivate}
|
|
1051
|
+
onSelectionRelease={onSelectionRelease}
|
|
1047
1052
|
/>
|
|
1048
1053
|
)}
|
|
1049
1054
|
<ReviewPanel
|
package/src/App.walk.test.tsx
CHANGED
|
@@ -242,7 +242,8 @@ describe("the guided walk", () => {
|
|
|
242
242
|
await press(setup, "escape");
|
|
243
243
|
await waitForTextGone(setup, "agent note");
|
|
244
244
|
await press(setup, "enter");
|
|
245
|
-
|
|
245
|
+
// nothing pending despite the note, so the confirm card defaults to approve
|
|
246
|
+
await waitForText(setup, "[Approve]");
|
|
246
247
|
await press(setup, "enter");
|
|
247
248
|
|
|
248
249
|
// Assert
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
|
2
|
+
|
|
3
|
+
exports[`view-plan regression harness (M0) blockRuns output is stable across the representative blocks 1`] = `
|
|
4
|
+
[
|
|
5
|
+
{
|
|
6
|
+
"runs": [
|
|
7
|
+
{
|
|
8
|
+
"role": "plain",
|
|
9
|
+
"start": 0,
|
|
10
|
+
"text": "Heading with ",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"role": "strong",
|
|
14
|
+
"start": 15,
|
|
15
|
+
"text": "bold",
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
"text": "Heading with **bold**",
|
|
19
|
+
"type": "same",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"runs": [
|
|
23
|
+
{
|
|
24
|
+
"role": "plain",
|
|
25
|
+
"start": 0,
|
|
26
|
+
"text": "A paragraph with ",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"role": "strong",
|
|
30
|
+
"start": 19,
|
|
31
|
+
"text": "strong",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"role": "plain",
|
|
35
|
+
"start": 27,
|
|
36
|
+
"text": ", ",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"role": "em",
|
|
40
|
+
"start": 30,
|
|
41
|
+
"text": "em",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"role": "plain",
|
|
45
|
+
"start": 33,
|
|
46
|
+
"text": ", ",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"role": "code",
|
|
50
|
+
"start": 36,
|
|
51
|
+
"text": "code",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"role": "plain",
|
|
55
|
+
"start": 41,
|
|
56
|
+
"text": ", ",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"role": "strike",
|
|
60
|
+
"start": 45,
|
|
61
|
+
"text": "strike",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"role": "plain",
|
|
65
|
+
"start": 53,
|
|
66
|
+
"text": " and a ",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"href": "https://example.com",
|
|
70
|
+
"role": "link",
|
|
71
|
+
"start": 61,
|
|
72
|
+
"text": "label",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"role": "plain",
|
|
76
|
+
"start": 88,
|
|
77
|
+
"text": ".",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
"text": "A paragraph with **strong**, *em*, \`code\`, ~~strike~~ and a [label](https://example.com).",
|
|
81
|
+
"type": "same",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"runs": [
|
|
85
|
+
{
|
|
86
|
+
"role": "plain",
|
|
87
|
+
"start": 0,
|
|
88
|
+
"text": "a list item with ",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"role": "em",
|
|
92
|
+
"start": 18,
|
|
93
|
+
"text": "emphasis",
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
"text": "a list item with *emphasis*",
|
|
97
|
+
"type": "same",
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
`;
|
|
@@ -15,7 +15,7 @@ export const CommentDraft: Story = {
|
|
|
15
15
|
draft={{ text: "Which daemon?", ...draftCallbacks }}
|
|
16
16
|
/>
|
|
17
17
|
),
|
|
18
|
-
expectedColors: [DARK.
|
|
18
|
+
expectedColors: [DARK.blue],
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
export const SavedSelected: Story = {
|
|
@@ -27,15 +27,14 @@ export const SavedSelected: Story = {
|
|
|
27
27
|
body: "Which daemon owns this?",
|
|
28
28
|
isSelected: true,
|
|
29
29
|
isOrphan: false,
|
|
30
|
-
isBlocking: false,
|
|
31
30
|
author: "me",
|
|
32
|
-
tone: DARK.
|
|
31
|
+
tone: DARK.blue,
|
|
33
32
|
editing: null,
|
|
34
33
|
onPress: () => {},
|
|
35
34
|
}}
|
|
36
35
|
/>
|
|
37
36
|
),
|
|
38
|
-
expectedColors: [DARK.
|
|
37
|
+
expectedColors: [DARK.blue],
|
|
39
38
|
};
|
|
40
39
|
|
|
41
40
|
export const SavedCollaborator: Story = {
|
|
@@ -47,7 +46,6 @@ export const SavedCollaborator: Story = {
|
|
|
47
46
|
body: "Which daemon owns this?",
|
|
48
47
|
isSelected: false,
|
|
49
48
|
isOrphan: false,
|
|
50
|
-
isBlocking: false,
|
|
51
49
|
author: "Priya",
|
|
52
50
|
tone: DARK.blue,
|
|
53
51
|
editing: null,
|
|
@@ -58,7 +56,7 @@ export const SavedCollaborator: Story = {
|
|
|
58
56
|
expectedColors: [DARK.blue],
|
|
59
57
|
};
|
|
60
58
|
|
|
61
|
-
export const
|
|
59
|
+
export const SavedOrphan: Story = {
|
|
62
60
|
render: () => (
|
|
63
61
|
<AnnotationCard
|
|
64
62
|
kind="comment"
|
|
@@ -67,15 +65,14 @@ export const SavedOrphanBlocking: Story = {
|
|
|
67
65
|
body: "The passage is gone.",
|
|
68
66
|
isSelected: false,
|
|
69
67
|
isOrphan: true,
|
|
70
|
-
isBlocking: true,
|
|
71
68
|
author: "me",
|
|
72
|
-
tone: DARK.
|
|
69
|
+
tone: DARK.blue,
|
|
73
70
|
editing: null,
|
|
74
71
|
onPress: () => {},
|
|
75
72
|
}}
|
|
76
73
|
/>
|
|
77
74
|
),
|
|
78
|
-
expectedColors: [DARK.
|
|
75
|
+
expectedColors: [DARK.blue],
|
|
79
76
|
};
|
|
80
77
|
|
|
81
78
|
export const SavedEditing: Story = {
|
|
@@ -87,9 +84,8 @@ export const SavedEditing: Story = {
|
|
|
87
84
|
body: "old body",
|
|
88
85
|
isSelected: true,
|
|
89
86
|
isOrphan: false,
|
|
90
|
-
isBlocking: false,
|
|
91
87
|
author: "me",
|
|
92
|
-
tone: DARK.
|
|
88
|
+
tone: DARK.blue,
|
|
93
89
|
editing: { text: "new body", ...draftCallbacks },
|
|
94
90
|
onPress: () => {},
|
|
95
91
|
}}
|
|
@@ -31,10 +31,9 @@ export interface AnnotationSaved {
|
|
|
31
31
|
body: string;
|
|
32
32
|
isSelected: boolean;
|
|
33
33
|
isOrphan: boolean;
|
|
34
|
-
isBlocking: boolean;
|
|
35
34
|
/** Border-title author: a collaborator's display name, or "me" for the reviewer's own note. */
|
|
36
35
|
author: string;
|
|
37
|
-
/** Border + title color for this card (
|
|
36
|
+
/** Border + title color for this card (comment cards read blue). */
|
|
38
37
|
tone: string;
|
|
39
38
|
/** Non-null while the card body is being rewritten in place. */
|
|
40
39
|
editing: AnnotationDraft | null;
|
|
@@ -155,7 +154,8 @@ export function AnnotationCard({
|
|
|
155
154
|
theme,
|
|
156
155
|
}: AnnotationCardProps): React.ReactNode {
|
|
157
156
|
const tokens = useComponentTheme(theme);
|
|
158
|
-
|
|
157
|
+
// the composer wears the same blue as the saved comment card it becomes
|
|
158
|
+
const kindColor = tokens.blue;
|
|
159
159
|
const activeDraft = draft ?? saved?.editing ?? null;
|
|
160
160
|
// The composer's visible height, grown from the wrapped text. It is shared
|
|
161
161
|
// with the card frame (contentRows) so the border never clips the textarea
|
|
@@ -186,10 +186,8 @@ export function AnnotationCard({
|
|
|
186
186
|
);
|
|
187
187
|
}
|
|
188
188
|
const card = saved!;
|
|
189
|
-
// border = "what + who" (+
|
|
190
|
-
const flags =
|
|
191
|
-
(flag): flag is string => flag !== null,
|
|
192
|
-
);
|
|
189
|
+
// border = "what + who" (+ an orphaned flag when the anchor no longer resolves)
|
|
190
|
+
const flags = card.isOrphan ? ["ORPHANED"] : [];
|
|
193
191
|
const title = ` ${[kind.toUpperCase(), card.author, ...flags].join(" · ")} `;
|
|
194
192
|
// every saved card is bordered, so the border + side padding always eat this width
|
|
195
193
|
const borderInset = 4;
|
|
@@ -205,16 +203,20 @@ export function AnnotationCard({
|
|
|
205
203
|
border: true,
|
|
206
204
|
borderStyle: FRAME_BORDER_STYLE,
|
|
207
205
|
// the border (and its title) always wears the card's tone - salmon for
|
|
208
|
-
// own notes, blue for a collaborator's;
|
|
209
|
-
//
|
|
206
|
+
// own notes, blue for a collaborator's; the card background stays
|
|
207
|
+
// transparent so it sits flat on the terminal theme. Selection reads
|
|
208
|
+
// from the quote line taking the card's tone (below) and from the
|
|
209
|
+
// matching text highlight in the document.
|
|
210
210
|
borderColor: card.tone,
|
|
211
|
-
backgroundColor:
|
|
211
|
+
backgroundColor: "transparent",
|
|
212
212
|
paddingLeft: 1,
|
|
213
213
|
paddingRight: 1,
|
|
214
214
|
}}
|
|
215
215
|
onMouseUp={card.onPress}
|
|
216
216
|
>
|
|
217
|
-
<text
|
|
217
|
+
<text
|
|
218
|
+
fg={card.isSelected ? card.tone : tokens.textDim}
|
|
219
|
+
>{`"${truncateToSingleLine(quote, 26 - borderInset)}"`}</text>
|
|
218
220
|
{card.editing ? (
|
|
219
221
|
<DraftEditor
|
|
220
222
|
draft={card.editing}
|
|
@@ -13,28 +13,14 @@ const callbacks = {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
export const ApproveDefault: Story = {
|
|
16
|
-
render: () =>
|
|
17
|
-
<ConfirmCard
|
|
18
|
-
verdict="approve"
|
|
19
|
-
summary=""
|
|
20
|
-
annotationCount={0}
|
|
21
|
-
blockingCount={0}
|
|
22
|
-
{...callbacks}
|
|
23
|
-
/>
|
|
24
|
-
),
|
|
16
|
+
render: () => <ConfirmCard verdict="approve" summary="" {...callbacks} />,
|
|
25
17
|
expectedColors: [DARK.green, DARK.accent],
|
|
26
18
|
size: { width: 40, height: 14 },
|
|
27
19
|
};
|
|
28
20
|
|
|
29
21
|
export const RequestChanges: Story = {
|
|
30
22
|
render: () => (
|
|
31
|
-
<ConfirmCard
|
|
32
|
-
verdict="request_changes"
|
|
33
|
-
summary="Tighten the steps."
|
|
34
|
-
annotationCount={3}
|
|
35
|
-
blockingCount={1}
|
|
36
|
-
{...callbacks}
|
|
37
|
-
/>
|
|
23
|
+
<ConfirmCard verdict="request_changes" summary="Tighten the steps." {...callbacks} />
|
|
38
24
|
),
|
|
39
25
|
expectedColors: [DARK.red],
|
|
40
26
|
size: { width: 40, height: 14 },
|
|
@@ -42,14 +28,7 @@ export const RequestChanges: Story = {
|
|
|
42
28
|
|
|
43
29
|
export const WithViewedSummary: Story = {
|
|
44
30
|
render: () => (
|
|
45
|
-
<ConfirmCard
|
|
46
|
-
verdict="approve"
|
|
47
|
-
summary=""
|
|
48
|
-
annotationCount={1}
|
|
49
|
-
blockingCount={0}
|
|
50
|
-
viewedSummary="2/3 files viewed"
|
|
51
|
-
{...callbacks}
|
|
52
|
-
/>
|
|
31
|
+
<ConfirmCard verdict="approve" summary="" viewedSummary="2/3 files viewed" {...callbacks} />
|
|
53
32
|
),
|
|
54
33
|
expectedColors: [DARK.green, DARK.textDim],
|
|
55
34
|
size: { width: 40, height: 14 },
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The Submit button expanded into a bordered confirm card at the rail bottom:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* and render never drift.
|
|
3
|
+
* the verdict selector (arrow keys or click), the optional summary, and plain
|
|
4
|
+
* word-buttons - key hints live in the status line only. The bordered height
|
|
5
|
+
* derives from the content rows through Card, so layout and render never drift.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import React from "react";
|
|
@@ -26,8 +25,6 @@ export const VERDICT_LABEL: Record<VerdictKind, string> = {
|
|
|
26
25
|
export interface ConfirmCardProps {
|
|
27
26
|
verdict: VerdictKind;
|
|
28
27
|
summary: string;
|
|
29
|
-
annotationCount: number;
|
|
30
|
-
blockingCount: number;
|
|
31
28
|
/**
|
|
32
29
|
* The guided walk's honest coverage line for diff sessions, e.g.
|
|
33
30
|
* "2/3 files viewed". Undefined = no walk data, the row does not render.
|
|
@@ -40,8 +37,8 @@ export interface ConfirmCardProps {
|
|
|
40
37
|
theme?: Theme;
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
/**
|
|
44
|
-
const CONFIRM_CONTENT_ROWS =
|
|
40
|
+
/** 1-row verdict selector, spacer, summary input, spacer, buttons. */
|
|
41
|
+
const CONFIRM_CONTENT_ROWS = 5;
|
|
45
42
|
|
|
46
43
|
export function verdictColor(verdict: VerdictKind, tokens: Theme): string {
|
|
47
44
|
return verdict === "approve"
|
|
@@ -89,8 +86,6 @@ function VerdictSelector({
|
|
|
89
86
|
export function ConfirmCard({
|
|
90
87
|
verdict,
|
|
91
88
|
summary,
|
|
92
|
-
annotationCount,
|
|
93
|
-
blockingCount,
|
|
94
89
|
viewedSummary,
|
|
95
90
|
onInput,
|
|
96
91
|
onSelectVerdict,
|
|
@@ -102,17 +97,14 @@ export function ConfirmCard({
|
|
|
102
97
|
return (
|
|
103
98
|
<Card
|
|
104
99
|
title=" submit review "
|
|
105
|
-
contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ?
|
|
100
|
+
contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 2 : 0)}
|
|
106
101
|
borderColor={tokens.text}
|
|
107
102
|
backgroundColor="transparent"
|
|
108
103
|
marginRight={1}
|
|
109
104
|
theme={theme}
|
|
110
105
|
>
|
|
111
|
-
<text
|
|
112
|
-
fg={tokens.textDim}
|
|
113
|
-
>{`${annotationCount} annotations · ${blockingCount} blocking`}</text>
|
|
114
106
|
{viewedSummary !== undefined ? <text fg={tokens.textDim}>{viewedSummary}</text> : null}
|
|
115
|
-
<box style={{ height: 1 }} />
|
|
107
|
+
{viewedSummary !== undefined ? <box style={{ height: 1 }} /> : null}
|
|
116
108
|
<VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
|
|
117
109
|
<box style={{ height: 1 }} />
|
|
118
110
|
<input
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import React from "react";
|
|
11
|
+
import type { MouseEvent } from "@opentui/core";
|
|
11
12
|
import type { Theme } from "../theme";
|
|
12
13
|
import type { QuickAction } from "../config";
|
|
13
14
|
import { useComponentTheme } from "./theme-context";
|
|
@@ -56,7 +57,12 @@ export function MarkerPopover({
|
|
|
56
57
|
};
|
|
57
58
|
const open = view === "actions";
|
|
58
59
|
return (
|
|
59
|
-
<box
|
|
60
|
+
<box
|
|
61
|
+
style={{ flexDirection: "column", alignItems: "flex-start" }}
|
|
62
|
+
// a release on the popover is the popover's own click; it must never
|
|
63
|
+
// bubble to the sheet's selection-release fallback and re-enter span mode
|
|
64
|
+
onMouseUp={(event: MouseEvent) => event.stopPropagation()}
|
|
65
|
+
>
|
|
60
66
|
<box style={{ ...cardStyle, height: cardHeight(1) }}>
|
|
61
67
|
<box style={{ flexDirection: "row" }}>
|
|
62
68
|
<text fg={tokens.accent} onMouseUp={onComment}>
|
|
@@ -16,7 +16,9 @@ import {
|
|
|
16
16
|
blockRuns,
|
|
17
17
|
displayText,
|
|
18
18
|
overlayMarks,
|
|
19
|
-
|
|
19
|
+
renderedOffsetAtOrAfter,
|
|
20
|
+
renderedOffsetAtOrBefore,
|
|
21
|
+
safeLinkHref,
|
|
20
22
|
workRangeForRendered,
|
|
21
23
|
type DisplayBlock,
|
|
22
24
|
type Mark,
|
|
@@ -33,6 +35,20 @@ import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
|
33
35
|
/** A cut block reads as removed: struck through and grayed, never red. */
|
|
34
36
|
const CUT_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
|
|
35
37
|
|
|
38
|
+
/** Inline-emphasis attributes, reused per run so the render loop allocates none. */
|
|
39
|
+
const STRONG_ATTRIBUTES = createTextAttributes({ bold: true });
|
|
40
|
+
const EM_ATTRIBUTES = createTextAttributes({ italic: true });
|
|
41
|
+
const STRIKE_ATTRIBUTES = createTextAttributes({ strikethrough: true });
|
|
42
|
+
const LINK_ATTRIBUTES = createTextAttributes({ underline: true });
|
|
43
|
+
/** Block-level base attributes: headings read bold, blockquotes read italic. */
|
|
44
|
+
const HEADING_ATTRIBUTES = createTextAttributes({ bold: true });
|
|
45
|
+
const QUOTE_ATTRIBUTES = createTextAttributes({ italic: true });
|
|
46
|
+
|
|
47
|
+
/** The popover toolbar card is 3 rows tall and sits flush above the marked words. */
|
|
48
|
+
const POPOVER_ROWS_ABOVE = 3;
|
|
49
|
+
/** Widest toolbar row ("comment · cut · actions · [x]") plus border and padding. */
|
|
50
|
+
const POPOVER_TOOLBAR_COLUMNS = 33;
|
|
51
|
+
|
|
36
52
|
export interface PlanSelection {
|
|
37
53
|
displayIndex: number;
|
|
38
54
|
start: number;
|
|
@@ -73,6 +89,13 @@ export interface PlanSheetProps {
|
|
|
73
89
|
popover: PlanPopoverState | null;
|
|
74
90
|
editOrphanCount: number;
|
|
75
91
|
onLineActivate: (displayIndex: number) => void;
|
|
92
|
+
/**
|
|
93
|
+
* Fires on any mouse release inside the sheet. A drag released outside a
|
|
94
|
+
* block's text (the gutter, past a line end, a gap between blocks) never
|
|
95
|
+
* reaches a block's own handler, so this fallback lets the app still turn
|
|
96
|
+
* the finished drag's native selection into a span.
|
|
97
|
+
*/
|
|
98
|
+
onSelectionRelease?: () => void;
|
|
76
99
|
theme?: Theme;
|
|
77
100
|
}
|
|
78
101
|
|
|
@@ -108,6 +131,7 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
|
|
|
108
131
|
popover,
|
|
109
132
|
editOrphanCount,
|
|
110
133
|
onLineActivate,
|
|
134
|
+
onSelectionRelease,
|
|
111
135
|
theme,
|
|
112
136
|
}: PlanSheetProps,
|
|
113
137
|
handleRef,
|
|
@@ -146,9 +170,11 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
|
|
|
146
170
|
if (!renderer) return;
|
|
147
171
|
const blockRef = blockRefs.current.get(span.displayIndex);
|
|
148
172
|
if (!blockRef) return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
173
|
+
// a span over an emphasized word starts/ends on concealed markers with
|
|
174
|
+
// no rendered cell; snap to the visible characters inside them
|
|
175
|
+
const renderedStart = renderedOffsetAtOrAfter(blockRef.runs, span.start);
|
|
176
|
+
const renderedEnd = renderedOffsetAtOrBefore(blockRef.runs, span.end - 1);
|
|
177
|
+
if (renderedStart === null || renderedEnd === null || renderedEnd < renderedStart) return;
|
|
152
178
|
const startPosition = positionOfRenderedOffset(blockRef.renderable, renderedStart);
|
|
153
179
|
const endPosition = positionOfRenderedOffset(blockRef.renderable, renderedEnd);
|
|
154
180
|
renderer.startSelection(blockRef.renderable, startPosition.x, startPosition.y);
|
|
@@ -166,24 +192,43 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
|
|
|
166
192
|
},
|
|
167
193
|
}));
|
|
168
194
|
|
|
169
|
-
// the popover
|
|
170
|
-
//
|
|
171
|
-
|
|
195
|
+
// the popover sits flush above the marked words, anchored to the
|
|
196
|
+
// selection start mapped through the wrap geometry (line + column, not the
|
|
197
|
+
// block's linear offset). It renders as the LAST child of the scrollbox in
|
|
198
|
+
// content coordinates: content coordinates are scroll-invariant so the card
|
|
199
|
+
// tracks its block, and the last sibling paints over every block it floats
|
|
200
|
+
// across. Flips below when the line sits too near the viewport top.
|
|
201
|
+
const [popoverTop, setPopoverTop] = useState(0);
|
|
172
202
|
const [popoverLeft, setPopoverLeft] = useState(2);
|
|
203
|
+
// a terminal resize rewraps every block; re-run the anchor math against the
|
|
204
|
+
// new geometry instead of leaving the card at its pre-resize cell
|
|
205
|
+
const [resizeEpoch, setResizeEpoch] = useState(0);
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!renderer) return;
|
|
208
|
+
const onResize = (): void => setResizeEpoch((epoch) => epoch + 1);
|
|
209
|
+
renderer.on("resize", onResize);
|
|
210
|
+
return () => void renderer.off("resize", onResize);
|
|
211
|
+
}, [renderer]);
|
|
173
212
|
useEffect(() => {
|
|
174
213
|
if (!popover) return;
|
|
175
214
|
const blockRef = blockRefs.current.get(popover.displayIndex);
|
|
176
215
|
const scrollbox = scrollRef.current;
|
|
177
216
|
if (!blockRef || !scrollbox) return;
|
|
178
|
-
|
|
179
|
-
setPopoverFlipBelow(blockRef.renderable.y - scrollbox.y < 4);
|
|
180
|
-
// left-anchor to the start of the selection: gutter width + its column
|
|
181
|
-
const startColumn =
|
|
217
|
+
const renderedStart =
|
|
182
218
|
activeSpan && activeSpan.displayIndex === popover.displayIndex
|
|
183
|
-
? (
|
|
219
|
+
? (renderedOffsetAtOrAfter(blockRef.runs, activeSpan.start) ?? 0)
|
|
184
220
|
: 0;
|
|
185
|
-
|
|
186
|
-
|
|
221
|
+
const startPosition = positionOfRenderedOffset(blockRef.renderable, renderedStart);
|
|
222
|
+
const content = scrollbox.content;
|
|
223
|
+
const anchorLeft = startPosition.x - content.x;
|
|
224
|
+
const anchorTop = startPosition.y - content.y;
|
|
225
|
+
// clamp so the toolbar card never runs past the content's right edge
|
|
226
|
+
const maxLeft = Math.max(0, content.width - POPOVER_TOOLBAR_COLUMNS);
|
|
227
|
+
setPopoverLeft(Math.min(anchorLeft, maxLeft));
|
|
228
|
+
// only the toolbar (3 rows) + gap must fit above; the dropdown flows down
|
|
229
|
+
const lineViewportRow = startPosition.y - scrollbox.y;
|
|
230
|
+
setPopoverTop(anchorTop + (lineViewportRow < POPOVER_ROWS_ABOVE ? 1 : -POPOVER_ROWS_ABOVE));
|
|
231
|
+
}, [popover, activeSpan, display, cursor, resizeEpoch]);
|
|
187
232
|
|
|
188
233
|
const registerBlock = (
|
|
189
234
|
displayIndex: number,
|
|
@@ -235,13 +280,7 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
|
|
|
235
280
|
<box
|
|
236
281
|
key={displayIndex}
|
|
237
282
|
id={`plan-block-${displayIndex}`}
|
|
238
|
-
|
|
239
|
-
// later blocks it floats across (siblings paint in z-index order)
|
|
240
|
-
style={{
|
|
241
|
-
flexDirection: "row",
|
|
242
|
-
marginTop: gap,
|
|
243
|
-
zIndex: popover?.displayIndex === displayIndex ? 10 : undefined,
|
|
244
|
-
}}
|
|
283
|
+
style={{ flexDirection: "row", marginTop: gap }}
|
|
245
284
|
>
|
|
246
285
|
<text selectable={false}>
|
|
247
286
|
<span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
|
|
@@ -258,31 +297,22 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
|
|
|
258
297
|
}
|
|
259
298
|
onMouseUp={() => onLineActivate(displayIndex)}
|
|
260
299
|
>
|
|
261
|
-
{runs.map((run, runIndex) =>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
300
|
+
{runs.map((run, runIndex) => {
|
|
301
|
+
const url = run.role === "link" ? safeLinkHref(run.href) : undefined;
|
|
302
|
+
return (
|
|
303
|
+
<span
|
|
304
|
+
key={runIndex}
|
|
305
|
+
{...runStyle(run, block, tokens)}
|
|
306
|
+
{...(url ? { link: { url } } : {})}
|
|
307
|
+
>
|
|
308
|
+
{run.text}
|
|
309
|
+
</span>
|
|
310
|
+
);
|
|
311
|
+
})}
|
|
266
312
|
{showsChangeTag(block) ? (
|
|
267
313
|
<span fg={tagColor(block, tokens)}> [{tagLabel(block)}]</span>
|
|
268
314
|
) : null}
|
|
269
315
|
</text>
|
|
270
|
-
{popover && popover.displayIndex === displayIndex ? (
|
|
271
|
-
// float the card over the block, centered, with a one-row gap; flip
|
|
272
|
-
// below only when there is no room above (near the viewport top)
|
|
273
|
-
<box
|
|
274
|
-
style={{
|
|
275
|
-
position: "absolute",
|
|
276
|
-
left: popoverLeft,
|
|
277
|
-
// fixed offset: the toolbar sits one row above the selection and
|
|
278
|
-
// stays put; the dropdown flows down from it, over the selection
|
|
279
|
-
top: popoverFlipBelow ? 1 : -4,
|
|
280
|
-
flexDirection: "column",
|
|
281
|
-
}}
|
|
282
|
-
>
|
|
283
|
-
<MarkerPopover {...popover} theme={theme} />
|
|
284
|
-
</box>
|
|
285
|
-
) : null}
|
|
286
316
|
</box>,
|
|
287
317
|
);
|
|
288
318
|
}
|
|
@@ -298,9 +328,26 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
|
|
|
298
328
|
);
|
|
299
329
|
}
|
|
300
330
|
}
|
|
331
|
+
if (popover) {
|
|
332
|
+
// last child on purpose: later siblings paint on top, so the floating card
|
|
333
|
+
// covers every block it overlaps regardless of scroll position
|
|
334
|
+
children.push(
|
|
335
|
+
<box
|
|
336
|
+
key="marker-popover"
|
|
337
|
+
style={{
|
|
338
|
+
position: "absolute",
|
|
339
|
+
left: popoverLeft,
|
|
340
|
+
top: popoverTop,
|
|
341
|
+
flexDirection: "column",
|
|
342
|
+
}}
|
|
343
|
+
>
|
|
344
|
+
<MarkerPopover {...popover} theme={theme} />
|
|
345
|
+
</box>,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
301
348
|
|
|
302
349
|
return (
|
|
303
|
-
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
350
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }} onMouseUp={onSelectionRelease}>
|
|
304
351
|
<box
|
|
305
352
|
style={{
|
|
306
353
|
flexGrow: 1,
|
|
@@ -366,15 +413,25 @@ function runStyle(
|
|
|
366
413
|
): { fg?: string; bg?: string; attributes?: number } {
|
|
367
414
|
// a cut block reads as removed: every run struck through and grayed, never red
|
|
368
415
|
if (block.type === "del") return { fg: tokens.textDim, attributes: CUT_ATTRIBUTES };
|
|
416
|
+
const isHeading = block.kind === "h1" || block.kind === "h2" || block.kind === "h3";
|
|
417
|
+
const isQuote = block.kind === "quote";
|
|
418
|
+
// headings are all bold; level reads from descending brightness alone (a
|
|
419
|
+
// terminal cannot scale font size), leaving the salmon accent to annotations
|
|
369
420
|
const headingFg =
|
|
370
421
|
block.kind === "h1"
|
|
371
422
|
? tokens.text
|
|
372
|
-
: block.kind === "h2"
|
|
373
|
-
? tokens.
|
|
374
|
-
:
|
|
423
|
+
: block.kind === "h2"
|
|
424
|
+
? tokens.textMuted
|
|
425
|
+
: block.kind === "h3"
|
|
426
|
+
? tokens.textDim
|
|
427
|
+
: undefined;
|
|
428
|
+
// block-level base: headings bold, quotes muted italic; inline roles compose on top
|
|
429
|
+
const baseFg = headingFg ?? (isQuote ? tokens.textMuted : tokens.text);
|
|
430
|
+
const baseAttributes = (isHeading ? HEADING_ATTRIBUTES : 0) | (isQuote ? QUOTE_ATTRIBUTES : 0);
|
|
431
|
+
const withBase = (roleAttributes: number): number => baseAttributes | roleAttributes;
|
|
375
432
|
switch (run.role) {
|
|
376
433
|
case "ins":
|
|
377
|
-
return { fg: tokens.insertedForeground };
|
|
434
|
+
return { fg: tokens.insertedForeground, attributes: baseAttributes || undefined };
|
|
378
435
|
case "del":
|
|
379
436
|
return { fg: tokens.deletedForeground };
|
|
380
437
|
case "mark-comment":
|
|
@@ -383,7 +440,17 @@ function runStyle(
|
|
|
383
440
|
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
384
441
|
case "kspan":
|
|
385
442
|
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
443
|
+
case "strong":
|
|
444
|
+
return { fg: baseFg, attributes: withBase(STRONG_ATTRIBUTES) };
|
|
445
|
+
case "em":
|
|
446
|
+
return { fg: baseFg, attributes: withBase(EM_ATTRIBUTES) };
|
|
447
|
+
case "code":
|
|
448
|
+
return { fg: tokens.text, bg: tokens.elevated };
|
|
449
|
+
case "strike":
|
|
450
|
+
return { fg: tokens.textMuted, attributes: withBase(STRIKE_ATTRIBUTES) };
|
|
451
|
+
case "link":
|
|
452
|
+
return { fg: tokens.blue, attributes: withBase(LINK_ATTRIBUTES) };
|
|
386
453
|
default:
|
|
387
|
-
return { fg:
|
|
454
|
+
return { fg: baseFg, attributes: baseAttributes || undefined };
|
|
388
455
|
}
|
|
389
456
|
}
|
|
@@ -75,11 +75,6 @@ export interface ReviewRailProps {
|
|
|
75
75
|
theme?: Theme;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/** Forward-compatible: open annotation kinds may carry a blocking flag. */
|
|
79
|
-
export function annotationBlocking(annotation: Annotation): boolean {
|
|
80
|
-
return (annotation as Annotation & { blocking?: boolean }).blocking === true;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
78
|
export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function ReviewRail(
|
|
84
79
|
{
|
|
85
80
|
session,
|
|
@@ -133,15 +128,13 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
133
128
|
body: annotation.body,
|
|
134
129
|
isSelected: annotation.id === selectedId,
|
|
135
130
|
isOrphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
|
|
136
|
-
isBlocking: annotationBlocking(annotation),
|
|
137
131
|
// border-title author: a collaborator's name, "agent" for agent notes, else "me"
|
|
138
132
|
author: annotation.author
|
|
139
133
|
? resolveDisplayName(annotation.author, session.participants, authorNames)
|
|
140
134
|
: isAgentNote(annotation)
|
|
141
135
|
? "agent"
|
|
142
136
|
: "me",
|
|
143
|
-
|
|
144
|
-
tone: annotation.author && !isAgentNote(annotation) ? tokens.blue : tokens.accent,
|
|
137
|
+
tone: tokens.blue,
|
|
145
138
|
editing:
|
|
146
139
|
cardEdit && cardEdit.id === annotation.id
|
|
147
140
|
? {
|
|
@@ -296,7 +289,7 @@ function RemovalCard({
|
|
|
296
289
|
title={title}
|
|
297
290
|
contentRows={contentRows}
|
|
298
291
|
borderColor={tokens.red}
|
|
299
|
-
backgroundColor=
|
|
292
|
+
backgroundColor="transparent"
|
|
300
293
|
theme={theme}
|
|
301
294
|
>
|
|
302
295
|
{previewLines.length === 0 ? (
|
|
@@ -84,8 +84,8 @@ exports[`stories catalog AnnotationCard/SavedEditing renders, snapshots, and car
|
|
|
84
84
|
"
|
|
85
85
|
`;
|
|
86
86
|
|
|
87
|
-
exports[`stories catalog AnnotationCard/
|
|
88
|
-
"╭─ COMMENT · me ·
|
|
87
|
+
exports[`stories catalog AnnotationCard/SavedOrphan renders, snapshots, and carries its colors: AnnotationCard/SavedOrphan 1`] = `
|
|
88
|
+
"╭─ COMMENT · me · ORPHANED ────────────────────────────────────────────────────╮
|
|
89
89
|
│ "a removed passage" │
|
|
90
90
|
│ The passage is gone. │
|
|
91
91
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
@@ -338,8 +338,6 @@ exports[`stories catalog CompletionOverlay/FeedbackCounting renders, snapshots,
|
|
|
338
338
|
|
|
339
339
|
exports[`stories catalog ConfirmCard/ApproveDefault renders, snapshots, and carries its colors: ConfirmCard/ApproveDefault 1`] = `
|
|
340
340
|
"╭─ submit review ─────────────────────╮
|
|
341
|
-
│ 0 annotations · 0 blocking │
|
|
342
|
-
│ │
|
|
343
341
|
│ Comment [Approve] Changes │
|
|
344
342
|
│ │
|
|
345
343
|
│ summary for the agent (optional) │
|
|
@@ -351,13 +349,13 @@ exports[`stories catalog ConfirmCard/ApproveDefault renders, snapshots, and carr
|
|
|
351
349
|
|
|
352
350
|
|
|
353
351
|
|
|
352
|
+
|
|
353
|
+
|
|
354
354
|
"
|
|
355
355
|
`;
|
|
356
356
|
|
|
357
357
|
exports[`stories catalog ConfirmCard/RequestChanges renders, snapshots, and carries its colors: ConfirmCard/RequestChanges 1`] = `
|
|
358
358
|
"╭─ submit review ─────────────────────╮
|
|
359
|
-
│ 3 annotations · 1 blocking │
|
|
360
|
-
│ │
|
|
361
359
|
│ Comment Approve [Changes] │
|
|
362
360
|
│ │
|
|
363
361
|
│ Tighten the steps. │
|
|
@@ -369,12 +367,13 @@ exports[`stories catalog ConfirmCard/RequestChanges renders, snapshots, and carr
|
|
|
369
367
|
|
|
370
368
|
|
|
371
369
|
|
|
370
|
+
|
|
371
|
+
|
|
372
372
|
"
|
|
373
373
|
`;
|
|
374
374
|
|
|
375
375
|
exports[`stories catalog ConfirmCard/WithViewedSummary renders, snapshots, and carries its colors: ConfirmCard/WithViewedSummary 1`] = `
|
|
376
376
|
"╭─ submit review ─────────────────────╮
|
|
377
|
-
│ 1 annotations · 0 blocking │
|
|
378
377
|
│ 2/3 files viewed │
|
|
379
378
|
│ │
|
|
380
379
|
│ Comment [Approve] Changes │
|
|
@@ -387,6 +386,7 @@ exports[`stories catalog ConfirmCard/WithViewedSummary renders, snapshots, and c
|
|
|
387
386
|
|
|
388
387
|
|
|
389
388
|
|
|
389
|
+
|
|
390
390
|
"
|
|
391
391
|
`;
|
|
392
392
|
|
|
@@ -1121,9 +1121,9 @@ exports[`stories catalog ReviewRail/SubmitConfirmOpen renders, snapshots, and ca
|
|
|
1121
1121
|
|
|
1122
1122
|
|
|
1123
1123
|
|
|
1124
|
+
|
|
1125
|
+
|
|
1124
1126
|
╭─ submit review ───────────────╮
|
|
1125
|
-
│ 2 annotations · 0 blocking │
|
|
1126
|
-
│ │
|
|
1127
1127
|
│ Comment Approve [Changes] │
|
|
1128
1128
|
│ │
|
|
1129
1129
|
│ summary for the agent (optiona│
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
blockRuns,
|
|
4
|
+
buildDisplay,
|
|
5
|
+
displayText,
|
|
6
|
+
renderedOffsetFor,
|
|
7
|
+
workRangeForRendered,
|
|
8
|
+
} from "./view-plan";
|
|
9
|
+
|
|
10
|
+
// M0 (#232): lock the renderer's current StyleRun output and offset round-trips
|
|
11
|
+
// before the inline-tokenizer milestones (M1-M5) change blockRuns. The snapshots
|
|
12
|
+
// capture today's runs, so a later milestone's diff is explicit and reviewable.
|
|
13
|
+
// The round-trip assertions are the durable invariant: a visible word's rendered
|
|
14
|
+
// selection must always recover that word's *source* (block.text) offsets, even
|
|
15
|
+
// once inline markers are concealed - this is what keeps quote anchors exact.
|
|
16
|
+
|
|
17
|
+
const DOC = `# Heading with **bold**
|
|
18
|
+
|
|
19
|
+
A paragraph with **strong**, *em*, \`code\`, ~~strike~~ and a [label](https://example.com).
|
|
20
|
+
|
|
21
|
+
- a list item with *emphasis*
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
const blocks = buildDisplay(DOC);
|
|
25
|
+
|
|
26
|
+
describe("view-plan regression harness (M0)", () => {
|
|
27
|
+
test("blockRuns output is stable across the representative blocks", () => {
|
|
28
|
+
// Arrange / Act
|
|
29
|
+
const runsByBlock = blocks.map((block) => ({
|
|
30
|
+
type: block.type,
|
|
31
|
+
text: displayText(block),
|
|
32
|
+
runs: blockRuns(block, true),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(runsByBlock).toMatchSnapshot();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// A visible word's source offsets survive the rendered round-trip. Holds at M0
|
|
40
|
+
// (1:1 today) and must keep holding once M2 conceals markers and the rendered
|
|
41
|
+
// offset shifts - the recovered work range stays the word's block.text span.
|
|
42
|
+
const paragraph = blocks.find((block) => displayText(block).includes("strong"))!;
|
|
43
|
+
const paragraphRuns = blockRuns(paragraph, true);
|
|
44
|
+
const paragraphText = displayText(paragraph);
|
|
45
|
+
|
|
46
|
+
for (const word of ["paragraph", "strong", "strike", "label"]) {
|
|
47
|
+
test(`rendered selection of "${word}" recovers its block.text offsets`, () => {
|
|
48
|
+
// Arrange
|
|
49
|
+
const start = paragraphText.indexOf(word);
|
|
50
|
+
const rendered = renderedOffsetFor(paragraphRuns, start);
|
|
51
|
+
|
|
52
|
+
// Assert
|
|
53
|
+
expect(rendered).not.toBeNull();
|
|
54
|
+
expect(workRangeForRendered(paragraphRuns, rendered!, rendered! + word.length)).toEqual({
|
|
55
|
+
start,
|
|
56
|
+
end: start + word.length,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
package/src/view-plan.test.ts
CHANGED
|
@@ -6,7 +6,10 @@ import {
|
|
|
6
6
|
marksByDisplay,
|
|
7
7
|
nextWorkBlock,
|
|
8
8
|
overlayMarks,
|
|
9
|
+
renderedOffsetAtOrAfter,
|
|
10
|
+
renderedOffsetAtOrBefore,
|
|
9
11
|
renderedOffsetFor,
|
|
12
|
+
safeLinkHref,
|
|
10
13
|
spanFromRange,
|
|
11
14
|
spanKey,
|
|
12
15
|
startSpan,
|
|
@@ -125,6 +128,86 @@ describe("blockRuns + overlayMarks", () => {
|
|
|
125
128
|
});
|
|
126
129
|
});
|
|
127
130
|
|
|
131
|
+
describe("M4: link hrefs are scheme-validated for OSC 8 (#236)", () => {
|
|
132
|
+
test("trusts http(s) and mailto, drops everything else", () => {
|
|
133
|
+
// Assert
|
|
134
|
+
expect(safeLinkHref("https://example.com")).toBe("https://example.com");
|
|
135
|
+
expect(safeLinkHref("http://x")).toBe("http://x");
|
|
136
|
+
expect(safeLinkHref("mailto:a@b.c")).toBe("mailto:a@b.c");
|
|
137
|
+
expect(safeLinkHref("javascript:alert(1)")).toBeUndefined();
|
|
138
|
+
expect(safeLinkHref("./relative/path")).toBeUndefined();
|
|
139
|
+
expect(safeLinkHref(undefined)).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a control byte or space in the target drops the link", () => {
|
|
143
|
+
// Assert - ESC and BEL could splice into the OSC 8 escape stream
|
|
144
|
+
expect(safeLinkHref("https://x\x1b]8;;evil\x07")).toBeUndefined();
|
|
145
|
+
expect(safeLinkHref("https://x\x07")).toBeUndefined();
|
|
146
|
+
expect(safeLinkHref("https://x y")).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("blockRuns carries the link href through to the link run", () => {
|
|
150
|
+
// Act
|
|
151
|
+
const block = buildDisplay("see [docs](https://example.com) now\n")[0]!;
|
|
152
|
+
const link = blockRuns(block, true).find((run) => run.role === "link")!;
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
155
|
+
expect(link.text).toBe("docs");
|
|
156
|
+
expect(link.href).toBe("https://example.com");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("M3: inline emphasis composes with word-diff (#235)", () => {
|
|
161
|
+
test("context emphasis survives; added text keeps the green diff role; markers concealed", () => {
|
|
162
|
+
// Arrange - "**this**" is unchanged (context, stays emphasized); "safe"->"sound" is the edit
|
|
163
|
+
const block = buildDisplay("keep **this** safe\n", "keep **this** sound\n")[0]!;
|
|
164
|
+
|
|
165
|
+
// Act
|
|
166
|
+
const runs = blockRuns(block, true);
|
|
167
|
+
|
|
168
|
+
// Assert - no marker text renders anywhere
|
|
169
|
+
expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
|
|
170
|
+
// the unchanged emphasized word keeps its strong role (context, not diffed)
|
|
171
|
+
expect(runs.find((run) => run.text === "this")!.role).toBe("strong");
|
|
172
|
+
// the added word is green (ins wins over any emphasis), the removed one opaque
|
|
173
|
+
expect(runs.find((run) => run.text.includes("sound"))!.role).toBe("ins");
|
|
174
|
+
expect(runs.some((run) => run.role === "del" && run.start === null)).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("markers inside added text conceal while the run stays a positioned ins", () => {
|
|
178
|
+
// Arrange - an emphasized word is added into an otherwise unchanged line
|
|
179
|
+
const block = buildDisplay("keep it safe\n", "keep it **very** safe\n")[0]!;
|
|
180
|
+
|
|
181
|
+
// Act
|
|
182
|
+
const runs = blockRuns(block, true);
|
|
183
|
+
const added = runs.find((run) => run.text === "very")!;
|
|
184
|
+
|
|
185
|
+
// Assert - concealed markers, green role, and an exact work offset back into work text
|
|
186
|
+
expect(added.role).toBe("ins");
|
|
187
|
+
expect(added.start).not.toBeNull();
|
|
188
|
+
expect(block.work!.text.slice(added.start!, added.start! + "very".length)).toBe("very");
|
|
189
|
+
expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("an anchor over an emphasized context word round-trips to its work offsets", () => {
|
|
193
|
+
// Arrange
|
|
194
|
+
const block = buildDisplay("hold **steady** now\n", "hold **steady** later\n")[0]!;
|
|
195
|
+
const runs = blockRuns(block, true);
|
|
196
|
+
const work = block.work!.text;
|
|
197
|
+
const start = work.indexOf("steady");
|
|
198
|
+
|
|
199
|
+
// Act
|
|
200
|
+
const rendered = renderedOffsetFor(runs, start);
|
|
201
|
+
|
|
202
|
+
// Assert
|
|
203
|
+
expect(rendered).not.toBeNull();
|
|
204
|
+
expect(workRangeForRendered(runs, rendered!, rendered! + "steady".length)).toEqual({
|
|
205
|
+
start,
|
|
206
|
+
end: start + "steady".length,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
128
211
|
describe("marksByDisplay", () => {
|
|
129
212
|
test("annotations resolve into display coordinates", () => {
|
|
130
213
|
// Arrange
|
|
@@ -217,6 +300,28 @@ describe("rendered/work offset mapping", () => {
|
|
|
217
300
|
expect(renderedOffsetFor(runs, 99)).toBeNull();
|
|
218
301
|
expect(workRangeForRendered(runs, 90, 99)).toBeNull();
|
|
219
302
|
});
|
|
303
|
+
|
|
304
|
+
test("a span starting/ending on concealed markers snaps to the visible word", () => {
|
|
305
|
+
// Arrange - the word span over "**very**" covers the markers, which have
|
|
306
|
+
// no rendered cells; the clamped lookups land on "very" itself
|
|
307
|
+
const text = "keep it **very** safe\n";
|
|
308
|
+
const runs = blockRuns(buildDisplay(text)[0]!, true);
|
|
309
|
+
const spanStart = text.indexOf("**very**");
|
|
310
|
+
const spanEnd = spanStart + "**very**".length;
|
|
311
|
+
|
|
312
|
+
// Act
|
|
313
|
+
const renderedStart = renderedOffsetAtOrAfter(runs, spanStart)!;
|
|
314
|
+
const renderedEnd = renderedOffsetAtOrBefore(runs, spanEnd - 1)!;
|
|
315
|
+
|
|
316
|
+
// Assert
|
|
317
|
+
const renderedText = runs.map((run) => run.text).join("");
|
|
318
|
+
expect(renderedText.slice(renderedStart, renderedEnd + 1)).toBe("very");
|
|
319
|
+
// exact hits behave like renderedOffsetFor
|
|
320
|
+
expect(renderedOffsetAtOrAfter(runs, 0)).toBe(0);
|
|
321
|
+
expect(renderedOffsetAtOrBefore(runs, 0)).toBe(0);
|
|
322
|
+
// nothing positioned beyond the text end
|
|
323
|
+
expect(renderedOffsetAtOrAfter(runs, 99)).toBeNull();
|
|
324
|
+
});
|
|
220
325
|
});
|
|
221
326
|
|
|
222
327
|
describe("span mode", () => {
|
package/src/view-plan.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
inlineRuns,
|
|
11
12
|
isAddressed,
|
|
12
13
|
lcsDiff,
|
|
13
14
|
parseBlocks,
|
|
@@ -141,7 +142,18 @@ function numberOrderedListItems(display: DisplayBlock[]): void {
|
|
|
141
142
|
|
|
142
143
|
// ── marks (annotation highlights + keyboard span) ──
|
|
143
144
|
|
|
144
|
-
export type RunRole =
|
|
145
|
+
export type RunRole =
|
|
146
|
+
| "plain"
|
|
147
|
+
| "ins"
|
|
148
|
+
| "del"
|
|
149
|
+
| "mark-comment"
|
|
150
|
+
| "kspan"
|
|
151
|
+
| "mark-focus"
|
|
152
|
+
| "strong"
|
|
153
|
+
| "em"
|
|
154
|
+
| "code"
|
|
155
|
+
| "strike"
|
|
156
|
+
| "link";
|
|
145
157
|
|
|
146
158
|
export interface StyleRun {
|
|
147
159
|
text: string;
|
|
@@ -149,6 +161,8 @@ export interface StyleRun {
|
|
|
149
161
|
/** Offset of this run in the block's working text; null for del runs. */
|
|
150
162
|
start: number | null;
|
|
151
163
|
annotationId?: string;
|
|
164
|
+
/** Link target for `link` runs. */
|
|
165
|
+
href?: string;
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
export interface Mark {
|
|
@@ -195,6 +209,7 @@ export function marksByDisplay(
|
|
|
195
209
|
|
|
196
210
|
/** Base runs for a display block: plain text, or word-diff for mod blocks. */
|
|
197
211
|
export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
|
|
212
|
+
const literal = block.kind === "code" || block.kind === "hr";
|
|
198
213
|
if (block.type === "mod" && markup) {
|
|
199
214
|
const changes = wordLevelChanges(block.base!.text, block.work!.text);
|
|
200
215
|
const runs: StyleRun[] = [];
|
|
@@ -202,18 +217,57 @@ export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
|
|
|
202
217
|
for (const change of changes) {
|
|
203
218
|
if (change.kind === "removed") {
|
|
204
219
|
runs.push({ text: change.text, role: "del", start: null });
|
|
205
|
-
|
|
206
|
-
runs.push({
|
|
207
|
-
text: change.text,
|
|
208
|
-
role: change.kind === "added" ? "ins" : "plain",
|
|
209
|
-
start: workOffset,
|
|
210
|
-
});
|
|
211
|
-
workOffset += change.text.length;
|
|
220
|
+
continue;
|
|
212
221
|
}
|
|
222
|
+
// A code/rule block stays literal; prose segments inline-tokenize so their
|
|
223
|
+
// markers conceal while their work offsets stay intact. Added text keeps
|
|
224
|
+
// the green diff role (it wins over emphasis); context text shows emphasis.
|
|
225
|
+
const segment = literal
|
|
226
|
+
? [{ text: change.text, role: "plain" as const, start: workOffset }]
|
|
227
|
+
: inlineStyleRuns(change.text, workOffset);
|
|
228
|
+
for (const run of segment) {
|
|
229
|
+
runs.push(change.kind === "added" ? { ...run, role: "ins" } : run);
|
|
230
|
+
}
|
|
231
|
+
workOffset += change.text.length;
|
|
213
232
|
}
|
|
214
233
|
return runs;
|
|
215
234
|
}
|
|
216
|
-
|
|
235
|
+
const text = displayText(block);
|
|
236
|
+
// Prose carries inline markup; code fences and rules are literal. Concealed
|
|
237
|
+
// markers are dropped, so every rendered cell still maps to a work offset.
|
|
238
|
+
if (markup && !literal) {
|
|
239
|
+
return inlineStyleRuns(text, 0);
|
|
240
|
+
}
|
|
241
|
+
return [{ text, role: "plain", start: 0 }];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A link href safe to hand a terminal as an OSC 8 target, or undefined. Only
|
|
245
|
+
* http(s) and mailto are trusted; a scheme like javascript: is dropped. The
|
|
246
|
+
* target must stay printable ASCII: a control byte (ESC, BEL) in a reviewed
|
|
247
|
+
* document could otherwise splice into the terminal escape stream. */
|
|
248
|
+
export function safeLinkHref(href: string | undefined): string | undefined {
|
|
249
|
+
if (href === undefined) return undefined;
|
|
250
|
+
if (!/^(https?:|mailto:)/i.test(href)) return undefined;
|
|
251
|
+
return /^[\x21-\x7e]+$/.test(href) ? href : undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Map the schema inline tokens to positioned StyleRuns, dropping the concealed
|
|
256
|
+
* markers. `base` is the run's offset within the block's working text (0 for a
|
|
257
|
+
* whole prose block; a segment's start for a word-diff piece).
|
|
258
|
+
*/
|
|
259
|
+
export function inlineStyleRuns(text: string, base: number): StyleRun[] {
|
|
260
|
+
const runs: StyleRun[] = [];
|
|
261
|
+
for (const run of inlineRuns(text)) {
|
|
262
|
+
if (run.role === "marker" || run.start === null) continue;
|
|
263
|
+
runs.push({
|
|
264
|
+
text: run.text,
|
|
265
|
+
role: run.role === "text" ? "plain" : run.role,
|
|
266
|
+
start: base + run.start,
|
|
267
|
+
...(run.href !== undefined ? { href: run.href } : {}),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return runs;
|
|
217
271
|
}
|
|
218
272
|
|
|
219
273
|
/** Split runs at mark boundaries; marks only bind to runs with offsets. */
|
|
@@ -274,6 +328,39 @@ export function renderedOffsetFor(runs: StyleRun[], workOffset: number): number
|
|
|
274
328
|
return null;
|
|
275
329
|
}
|
|
276
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Rendered offset of the first positioned character at or after `workOffset`.
|
|
333
|
+
* A word span may start on a concealed marker (the `**` of an emphasized
|
|
334
|
+
* word), which has no rendered cell; the span visuals snap to the first
|
|
335
|
+
* character that does. Positioned runs appear in ascending work order.
|
|
336
|
+
*/
|
|
337
|
+
export function renderedOffsetAtOrAfter(runs: StyleRun[], workOffset: number): number | null {
|
|
338
|
+
let rendered = 0;
|
|
339
|
+
for (const run of runs) {
|
|
340
|
+
if (run.start !== null && run.start + run.text.length > workOffset) {
|
|
341
|
+
return rendered + Math.max(0, workOffset - run.start);
|
|
342
|
+
}
|
|
343
|
+
rendered += run.text.length;
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Rendered offset of the last positioned character at or before `workOffset` -
|
|
350
|
+
* the closing-marker counterpart of `renderedOffsetAtOrAfter`.
|
|
351
|
+
*/
|
|
352
|
+
export function renderedOffsetAtOrBefore(runs: StyleRun[], workOffset: number): number | null {
|
|
353
|
+
let rendered = 0;
|
|
354
|
+
let best: number | null = null;
|
|
355
|
+
for (const run of runs) {
|
|
356
|
+
if (run.start !== null && run.start <= workOffset) {
|
|
357
|
+
best = rendered + Math.min(workOffset - run.start, run.text.length - 1);
|
|
358
|
+
}
|
|
359
|
+
rendered += run.text.length;
|
|
360
|
+
}
|
|
361
|
+
return best;
|
|
362
|
+
}
|
|
363
|
+
|
|
277
364
|
/**
|
|
278
365
|
* Work-text range covered by a rendered-text selection [start, end). Del runs
|
|
279
366
|
* and other unpositioned text inside the selection contribute nothing; the
|