@cueloop/client 0.1.0-alpha.66 → 0.1.0-alpha.68
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 +6 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +71 -39
- package/src/App.plan-review.test.tsx +20 -21
- package/src/App.readonly.test.tsx +7 -3
- package/src/App.test.tsx +73 -14
- package/src/App.tsx +375 -129
- package/src/App.walk.test.tsx +14 -15
- package/src/App.workbench.test.tsx +390 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
- package/src/annotation-palette.ts +61 -0
- package/src/app-screens.tsx +176 -45
- package/src/app-view-model.ts +7 -65
- package/src/components/AnnotationCard.tsx +1 -1
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +47 -42
- package/src/components/AppShell.tsx +212 -100
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/ChangesColumn.tsx +49 -26
- package/src/components/DiffSheet.scroll.test.tsx +38 -55
- package/src/components/DiffSheet.split.test.tsx +140 -0
- package/src/components/DiffSheet.stories.tsx +55 -53
- package/src/components/DiffSheet.syntax.test.tsx +24 -7
- package/src/components/DiffSheet.tsx +607 -276
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +105 -0
- package/src/components/EditorGrid.tsx +338 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +78 -0
- package/src/components/InboxList.tsx +159 -30
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +82 -0
- package/src/components/PromptDialog.tsx +8 -1
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/SettingsDialog.tsx +2 -1
- package/src/components/SurfaceScrollbar.stories.tsx +34 -0
- package/src/components/SurfaceScrollbar.test.tsx +111 -0
- package/src/components/SurfaceScrollbar.tsx +93 -0
- package/src/components/ThreadFooter.stories.tsx +2 -2
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +12 -7
- package/src/components/ThreadView.tsx +62 -1440
- package/src/components/ThreadsSidebar.tsx +14 -1
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/WelcomeSurface.stories.tsx +12 -0
- package/src/components/WelcomeSurface.tsx +52 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
- package/src/components/diff-sheet-layout.test.ts +125 -55
- package/src/components/diff-sheet-layout.ts +3 -103
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +15 -0
- package/src/components/file-tree.ts +59 -4
- package/src/components/pages/Workspace.stories.tsx +2 -2
- package/src/components/primitives/IconButton.tsx +21 -2
- package/src/components/primitives/Tree.tsx +13 -4
- package/src/components/primitives/icons.ts +27 -4
- package/src/components/session-tree.test.ts +20 -0
- package/src/components/session-tree.ts +16 -2
- package/src/components/stories-app.tsx +2 -2
- package/src/config.test.ts +20 -49
- package/src/config.ts +26 -28
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-syntax.test.ts +16 -0
- package/src/diff-syntax.ts +6 -3
- package/src/discussions.ts +79 -0
- package/src/ghostty-terminal.test.ts +2 -1
- package/src/ghostty-terminal.ts +27 -4
- package/src/intent-dispatch.test.ts +31 -11
- package/src/intent-dispatch.ts +93 -48
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +3 -25
- package/src/keymap.ts +17 -18
- package/src/mark-runs.ts +120 -0
- package/src/pty.ts +10 -2
- package/src/run.ts +17 -7
- package/src/serve.test.ts +4 -8
- package/src/session-controller.curate.test.ts +75 -0
- package/src/session-controller.share.test.ts +1 -0
- package/src/session-controller.tree.test.ts +26 -0
- package/src/session-controller.ts +153 -29
- package/src/slash-palette.ts +83 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +15 -3
- package/src/test-support.ts +66 -15
- package/src/thread-chords.test.ts +25 -9
- package/src/thread-chords.ts +33 -8
- package/src/use-annotation-surface.tsx +837 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +14 -24
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/view-diff.ts +114 -0
- package/src/components/ReviewPanel.stories.tsx +0 -100
- package/src/components/ReviewPanel.tsx +0 -103
- package/src/components/ReviewRail.stories.tsx +0 -232
- package/src/components/ReviewRail.tsx +0 -327
- package/src/review-panel.test.ts +0 -113
- package/src/review-panel.ts +0 -83
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The thread view: the plan surface where discussions live inline in the
|
|
3
|
-
* document instead of the rail.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* and the right-edge scroll markers navigate between discussions.
|
|
7
|
-
* CUELOOP_THREAD_VIEW=1 while it graduates from the UX spike.
|
|
3
|
+
* document instead of the rail. The caret, marks, composer, palette, and cards
|
|
4
|
+
* come from the shared annotation surface (the diff sheet drives the same hook);
|
|
5
|
+
* this view paints the plan's blocks - headings, lists, quotes, tracked changes -
|
|
6
|
+
* and the right-edge scroll markers that navigate between discussions.
|
|
8
7
|
*
|
|
9
8
|
* Grammar: arrows move (caret flows across blocks) · shift+arrows select ·
|
|
10
9
|
* click/drag mark · typing comments (or edits my trailing comment / replies
|
|
@@ -13,726 +12,23 @@
|
|
|
13
12
|
* dismisses · tab folds · cmd+[ / cmd+] cycle discussions · blur-save on click.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
|
-
import React, { useEffect, useRef
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
type BoxRenderable,
|
|
20
|
-
type KeyBinding,
|
|
21
|
-
type KeyEvent,
|
|
22
|
-
type MouseEvent as TerminalMouseEvent,
|
|
23
|
-
type ScrollBoxRenderable,
|
|
24
|
-
type TextareaRenderable,
|
|
25
|
-
type TextRenderable,
|
|
26
|
-
} from "@opentui/core";
|
|
27
|
-
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
28
|
-
import type { Annotation, ReviewSession } from "@cueloop/schema";
|
|
15
|
+
import React, { useEffect, useRef } from "react";
|
|
16
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
17
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
29
18
|
import { displayText, type DisplayBlock, type Mark } from "../view-plan";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
isDoubleClick,
|
|
33
|
-
nextWordStart,
|
|
34
|
-
orderedSpan,
|
|
35
|
-
positionAt,
|
|
36
|
-
previousWordStart,
|
|
37
|
-
snapSpanToWords,
|
|
38
|
-
spanRangeInBlock,
|
|
39
|
-
tightenSpan,
|
|
40
|
-
wordRangeAt,
|
|
41
|
-
wordRanges,
|
|
42
|
-
type ClickStamp,
|
|
43
|
-
type LineGeometry,
|
|
44
|
-
type TextPosition,
|
|
45
|
-
type TextSpan,
|
|
46
|
-
} from "../thread-selection";
|
|
47
|
-
import { quickActionBody, type QuickAction } from "../config";
|
|
19
|
+
import type { TextSpan } from "../thread-selection";
|
|
20
|
+
import type { QuickAction } from "../config";
|
|
48
21
|
import type { CheatsheetSection } from "../key-bindings";
|
|
49
22
|
import type { Theme } from "../theme";
|
|
23
|
+
import { BOLD, CUT, UNDERLINE } from "../annotation-palette";
|
|
24
|
+
import { lineMarkRanges, runsFor, wrapLines, type MarkRange, type VisualLine } from "../mark-runs";
|
|
25
|
+
import { useFrameMeasure } from "../use-frame-measure";
|
|
26
|
+
import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
|
|
27
|
+
import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
|
|
50
28
|
import { useComponentTheme } from "./theme-context";
|
|
51
29
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const UNDERLINE = createTextAttributes({ underline: true });
|
|
55
|
-
const CUT = createTextAttributes({ strikethrough: true, dim: true });
|
|
56
|
-
const BOLD = createTextAttributes({ bold: true });
|
|
57
|
-
|
|
58
|
-
/** The marked-words treatment: a violet backdrop under an underline. */
|
|
59
|
-
const MARK_BACKDROP_DARK = "#463852";
|
|
60
|
-
const MARK_BACKDROP_LIGHT = "#e6ddf5";
|
|
61
|
-
/** The discussion card's accent left edge. */
|
|
62
|
-
const CARD_EDGE_DARK = "#ab7aca";
|
|
63
|
-
const CARD_EDGE_LIGHT = "#8b5fd6";
|
|
64
|
-
/** The caret cell: a visible cursor painted on the character under the caret. */
|
|
65
|
-
const CARET_CELL_DARK = "#565b68";
|
|
66
|
-
const CARET_CELL_LIGHT = "#b8bcc8";
|
|
67
|
-
|
|
68
|
-
interface ThreadViewPalette {
|
|
69
|
-
markBackdrop: string;
|
|
70
|
-
cardEdge: string;
|
|
71
|
-
caretCell: string;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Dark tokens carry light text; use that to pick the palette variant. */
|
|
75
|
-
function paletteFor(tokens: Theme): ThreadViewPalette {
|
|
76
|
-
const dark = Number.parseInt(tokens.text.slice(1, 3) || "e4", 16) > 128;
|
|
77
|
-
|
|
78
|
-
return dark
|
|
79
|
-
? {
|
|
80
|
-
markBackdrop: MARK_BACKDROP_DARK,
|
|
81
|
-
cardEdge: CARD_EDGE_DARK,
|
|
82
|
-
caretCell: CARET_CELL_DARK,
|
|
83
|
-
}
|
|
84
|
-
: {
|
|
85
|
-
markBackdrop: MARK_BACKDROP_LIGHT,
|
|
86
|
-
cardEdge: CARD_EDGE_LIGHT,
|
|
87
|
-
caretCell: CARET_CELL_LIGHT,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Focus cue: mix a hex color toward white, keeping its hue readable. */
|
|
92
|
-
export function lighten(hex: string, amount = 0.25): string {
|
|
93
|
-
const channels = hex.match(/^#(..)(..)(..)$/);
|
|
94
|
-
|
|
95
|
-
if (!channels) return hex;
|
|
96
|
-
const lifted = channels
|
|
97
|
-
.slice(1)
|
|
98
|
-
.map((channel) => {
|
|
99
|
-
const value = Number.parseInt(channel, 16);
|
|
100
|
-
|
|
101
|
-
return Math.round(value + (255 - value) * amount)
|
|
102
|
-
.toString(16)
|
|
103
|
-
.padStart(2, "0");
|
|
104
|
-
})
|
|
105
|
-
.join("");
|
|
106
|
-
|
|
107
|
-
return `#${lifted}`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/* ------------------------------------------------------------ measurement */
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Read a layout-dependent value after every rendered frame (and resize),
|
|
114
|
-
* committing only on change. Layout lands after the commit, so a timer
|
|
115
|
-
* would race the visual-idle wait; measuring on the frame event converges
|
|
116
|
-
* deterministically within a frame or two.
|
|
117
|
-
*/
|
|
118
|
-
function useFrameMeasure<T>(read: () => T, isEqual: (left: T, right: T) => boolean, initial: T): T {
|
|
119
|
-
const renderer = useRenderer();
|
|
120
|
-
const [value, setValue] = useState(initial);
|
|
121
|
-
|
|
122
|
-
useEffect(() => {
|
|
123
|
-
const measure = (): void => {
|
|
124
|
-
const next = read();
|
|
125
|
-
|
|
126
|
-
setValue((current) => (isEqual(current, next) ? current : next));
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
measure();
|
|
130
|
-
renderer?.on("frame", measure);
|
|
131
|
-
renderer?.on("resize", measure);
|
|
132
|
-
|
|
133
|
-
return () => {
|
|
134
|
-
renderer?.off("frame", measure);
|
|
135
|
-
renderer?.off("resize", measure);
|
|
136
|
-
};
|
|
137
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
138
|
-
}, [renderer]);
|
|
139
|
-
|
|
140
|
-
return value;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/* -------------------------------------------------------------- geometry */
|
|
144
|
-
|
|
145
|
-
interface MarkRange {
|
|
146
|
-
start: number;
|
|
147
|
-
end: number;
|
|
148
|
-
/** The bare caret's single cell - a cursor, not yet a mark. */
|
|
149
|
-
caretOnly?: boolean;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
interface Run {
|
|
153
|
-
text: string;
|
|
154
|
-
marked: boolean;
|
|
155
|
-
caretOnly: boolean;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Split a block's text into plain/marked runs from the union of ranges. */
|
|
159
|
-
function runsFor(text: string, ranges: MarkRange[]): Run[] {
|
|
160
|
-
const cuts = new Set<number>([0, text.length]);
|
|
161
|
-
|
|
162
|
-
for (const range of ranges) {
|
|
163
|
-
cuts.add(Math.max(0, range.start));
|
|
164
|
-
cuts.add(Math.min(text.length, range.end));
|
|
165
|
-
}
|
|
166
|
-
const edges = [...cuts].toSorted((left, right) => left - right);
|
|
167
|
-
const runs: Run[] = [];
|
|
168
|
-
|
|
169
|
-
for (let index = 0; index < edges.length - 1; index++) {
|
|
170
|
-
const start = edges[index]!;
|
|
171
|
-
const end = edges[index + 1]!;
|
|
172
|
-
|
|
173
|
-
if (end <= start) continue;
|
|
174
|
-
const covering = ranges.filter((range) => range.start <= start && end <= range.end);
|
|
175
|
-
// the caret cell shows through a mark, so the head of a selection is visible
|
|
176
|
-
runs.push({
|
|
177
|
-
text: text.slice(start, end),
|
|
178
|
-
marked: covering.some((range) => range.caretOnly !== true),
|
|
179
|
-
caretOnly: covering.some((range) => range.caretOnly === true),
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return runs.length > 0 ? runs : [{ text, marked: false, caretOnly: false }];
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Greedy word wrap into visual-line char ranges, so a comment card can slot
|
|
188
|
-
* in directly below the line its mark ends on (mid-paragraph when needed).
|
|
189
|
-
*/
|
|
190
|
-
function wrapLines(text: string, width: number): Array<{ start: number; end: number }> {
|
|
191
|
-
const lines: Array<{ start: number; end: number }> = [];
|
|
192
|
-
let segmentStart = 0;
|
|
193
|
-
|
|
194
|
-
// hard breaks first: every visual line must be exactly one terminal row,
|
|
195
|
-
// or hit-testing and the mark geometry drift from what is painted
|
|
196
|
-
for (const segment of text.split("\n")) {
|
|
197
|
-
const segmentEnd = segmentStart + segment.length;
|
|
198
|
-
|
|
199
|
-
if (width <= 0 || segment.length <= width) {
|
|
200
|
-
lines.push({ start: segmentStart, end: segmentEnd });
|
|
201
|
-
} else {
|
|
202
|
-
// greedy word wrap inside the segment; the first line keeps its
|
|
203
|
-
// leading indentation, continuation lines start at a word
|
|
204
|
-
let lineStart = segmentStart;
|
|
205
|
-
let lineEnd = segmentStart;
|
|
206
|
-
|
|
207
|
-
for (const word of wordRanges(segment)) {
|
|
208
|
-
const wordStart = segmentStart + word.start;
|
|
209
|
-
const wordEnd = segmentStart + word.end;
|
|
210
|
-
|
|
211
|
-
if (wordEnd - lineStart > width && lineEnd > lineStart) {
|
|
212
|
-
lines.push({ start: lineStart, end: lineEnd });
|
|
213
|
-
lineStart = wordStart;
|
|
214
|
-
}
|
|
215
|
-
lineEnd = wordEnd;
|
|
216
|
-
}
|
|
217
|
-
lines.push({ start: lineStart, end: Math.max(lineEnd, lineStart) });
|
|
218
|
-
}
|
|
219
|
-
segmentStart = segmentEnd + 1;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return lines;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** A printable character: single-width input that should reach a composer. */
|
|
226
|
-
function printableSequence(key: KeyEvent): string | null {
|
|
227
|
-
const printable =
|
|
228
|
-
key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta && key.sequence >= " ";
|
|
229
|
-
|
|
230
|
-
return printable ? key.sequence : null;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/* ---------------------------------------------------------------- discussions */
|
|
234
|
-
|
|
235
|
-
/** One conversation: a root annotation, its replies, and the span they mark. */
|
|
236
|
-
interface Discussion {
|
|
237
|
-
key: string;
|
|
238
|
-
/** The annotation replies attach to. */
|
|
239
|
-
rootId: string;
|
|
240
|
-
/** The block the card renders under: where the span ends. */
|
|
241
|
-
blockIndex: number;
|
|
242
|
-
span: TextSpan;
|
|
243
|
-
annotations: Annotation[];
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function spanKey(span: TextSpan): string {
|
|
247
|
-
return `${span.start.blockIndex}:${span.start.char}:${span.end.blockIndex}:${span.end.char}`;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Roots group by the span they resolved to (two roots on the very same text
|
|
252
|
-
* read as one conversation); replies join the discussion their replyTo names, or
|
|
253
|
-
* stand as roots when that root is gone.
|
|
254
|
-
*/
|
|
255
|
-
function discussionsFrom(session: ReviewSession, marks: Map<number, Mark[]>): Discussion[] {
|
|
256
|
-
const byId = new Map(session.annotations.map((annotation) => [annotation.id, annotation]));
|
|
257
|
-
const spanOf = new Map<string, TextSpan>();
|
|
258
|
-
|
|
259
|
-
for (const [displayIndex, blockMarks] of marks) {
|
|
260
|
-
for (const mark of blockMarks) {
|
|
261
|
-
if (!mark.annotationId || spanOf.has(mark.annotationId)) continue;
|
|
262
|
-
spanOf.set(
|
|
263
|
-
mark.annotationId,
|
|
264
|
-
mark.span ?? {
|
|
265
|
-
start: { blockIndex: displayIndex, char: mark.start },
|
|
266
|
-
end: { blockIndex: displayIndex, char: mark.end },
|
|
267
|
-
},
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
const resolved = [...spanOf.keys()].map((id) => byId.get(id)!).filter(Boolean);
|
|
272
|
-
const isRoot = (annotation: Annotation): boolean =>
|
|
273
|
-
annotation.replyTo === undefined || !spanOf.has(annotation.replyTo);
|
|
274
|
-
const grouped = new Map<string, Discussion>();
|
|
275
|
-
const discussionOfRoot = new Map<string, Discussion>();
|
|
276
|
-
|
|
277
|
-
for (const annotation of resolved.filter(isRoot)) {
|
|
278
|
-
const span = spanOf.get(annotation.id)!;
|
|
279
|
-
const key = spanKey(span);
|
|
280
|
-
const discussion = grouped.get(key) ?? {
|
|
281
|
-
key,
|
|
282
|
-
rootId: annotation.id,
|
|
283
|
-
blockIndex: span.end.blockIndex,
|
|
284
|
-
span,
|
|
285
|
-
annotations: [],
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
discussion.annotations.push(annotation);
|
|
289
|
-
grouped.set(key, discussion);
|
|
290
|
-
discussionOfRoot.set(annotation.id, discussion);
|
|
291
|
-
}
|
|
292
|
-
for (const annotation of resolved.filter((candidate) => !isRoot(candidate))) {
|
|
293
|
-
discussionOfRoot.get(annotation.replyTo!)?.annotations.push(annotation);
|
|
294
|
-
}
|
|
295
|
-
for (const discussion of grouped.values()) {
|
|
296
|
-
discussion.annotations.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
return [...grouped.values()].toSorted((left, right) =>
|
|
300
|
-
comparePositions(left.span.start, right.span.start),
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/* -------------------------------------------------------------- composer */
|
|
305
|
-
|
|
306
|
-
// cmd+enter sends (super under the kitty protocol, meta where cmd arrives
|
|
307
|
-
// ESC-prefixed, ctrl as the fallback where the terminal itself consumes
|
|
308
|
-
// cmd+enter); plain enter breaks the line
|
|
309
|
-
const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
|
|
310
|
-
{ name: "return", super: true, action: "submit" },
|
|
311
|
-
{ name: "return", meta: true, action: "submit" },
|
|
312
|
-
{ name: "return", ctrl: true, action: "submit" },
|
|
313
|
-
{ name: "return", action: "newline" },
|
|
314
|
-
{ name: "return", shift: true, action: "newline" },
|
|
315
|
-
];
|
|
316
|
-
|
|
317
|
-
function composeRowCount(text: string, contentWidth: number): number {
|
|
318
|
-
const usableWidth = contentWidth > 0 ? contentWidth : Number.MAX_SAFE_INTEGER;
|
|
319
|
-
let visualRowCount = 0;
|
|
320
|
-
|
|
321
|
-
for (const line of text.split("\n")) {
|
|
322
|
-
visualRowCount += Math.max(1, Math.ceil(line.length / usableWidth));
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// no cap: the card is the discussion, and folding is its containment
|
|
326
|
-
return Math.max(1, visualRowCount);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function Composer({
|
|
330
|
-
seed,
|
|
331
|
-
glyph,
|
|
332
|
-
tokens,
|
|
333
|
-
onSave,
|
|
334
|
-
onReady,
|
|
335
|
-
onInput,
|
|
336
|
-
}: {
|
|
337
|
-
seed: string;
|
|
338
|
-
glyph: string;
|
|
339
|
-
tokens: Theme;
|
|
340
|
-
onSave: (body: string) => void;
|
|
341
|
-
onReady: () => void;
|
|
342
|
-
onInput: (text: string) => void;
|
|
343
|
-
}): React.ReactNode {
|
|
344
|
-
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
345
|
-
const [rows, setRows] = useState(1);
|
|
346
|
-
|
|
347
|
-
// once per mount (the composer is keyed by its seed): later re-renders
|
|
348
|
-
// must NOT reset the caret, or input lands before a just-typed newline
|
|
349
|
-
useEffect(() => {
|
|
350
|
-
const editor = editorRef.current;
|
|
351
|
-
|
|
352
|
-
if (!editor) return;
|
|
353
|
-
editor.cursorOffset = seed.length;
|
|
354
|
-
setRows(composeRowCount(editor.plainText, editor.width));
|
|
355
|
-
onReady();
|
|
356
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
357
|
-
}, []);
|
|
358
|
-
|
|
359
|
-
return (
|
|
360
|
-
<box style={{ flexDirection: "row" }}>
|
|
361
|
-
<text selectable={false} fg={tokens.text} style={{ flexShrink: 0 }}>{`${glyph} `}</text>
|
|
362
|
-
<textarea
|
|
363
|
-
ref={editorRef}
|
|
364
|
-
focused
|
|
365
|
-
initialValue={seed}
|
|
366
|
-
cursorStyle={{ style: "block", blinking: true }}
|
|
367
|
-
keyBindings={COMPOSE_KEY_BINDINGS}
|
|
368
|
-
onSubmit={() => onSave(editorRef.current?.plainText ?? "")}
|
|
369
|
-
onContentChange={() => {
|
|
370
|
-
const editor = editorRef.current;
|
|
371
|
-
|
|
372
|
-
if (!editor) return;
|
|
373
|
-
setRows(composeRowCount(editor.plainText, editor.width));
|
|
374
|
-
onInput(editor.plainText);
|
|
375
|
-
}}
|
|
376
|
-
style={{
|
|
377
|
-
height: rows,
|
|
378
|
-
flexGrow: 1,
|
|
379
|
-
backgroundColor: "transparent",
|
|
380
|
-
focusedBackgroundColor: "transparent",
|
|
381
|
-
textColor: tokens.text,
|
|
382
|
-
focusedTextColor: tokens.text,
|
|
383
|
-
}}
|
|
384
|
-
/>
|
|
385
|
-
</box>
|
|
386
|
-
);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/* ---------------------------------------------------------- slash palette */
|
|
390
|
-
|
|
391
|
-
interface SlashItem {
|
|
392
|
-
name: string;
|
|
393
|
-
description: string;
|
|
394
|
-
body: string;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function slashItemsFrom(quickActions: QuickAction[]): SlashItem[] {
|
|
398
|
-
return quickActions.map((action) => ({
|
|
399
|
-
name: action.prompt
|
|
400
|
-
.toLowerCase()
|
|
401
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
402
|
-
.replace(/^-|-$/g, ""),
|
|
403
|
-
description: action.metadata ?? action.prompt,
|
|
404
|
-
body: quickActionBody(action),
|
|
405
|
-
}));
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/** Prefix beats substring beats subsequence. */
|
|
409
|
-
function slashFilter(items: SlashItem[], query: string): SlashItem[] {
|
|
410
|
-
const needle = query.toLowerCase();
|
|
411
|
-
|
|
412
|
-
if (needle.length === 0) return items;
|
|
413
|
-
const scored: Array<{ item: SlashItem; score: number }> = [];
|
|
414
|
-
|
|
415
|
-
for (const item of items) {
|
|
416
|
-
const name = item.name.toLowerCase();
|
|
417
|
-
let score = 0;
|
|
418
|
-
|
|
419
|
-
if (name.startsWith(needle)) score = 3;
|
|
420
|
-
else if (name.includes(needle)) score = 2;
|
|
421
|
-
else {
|
|
422
|
-
let matched = 0;
|
|
423
|
-
|
|
424
|
-
for (const character of name) {
|
|
425
|
-
if (character === needle[matched]) matched++;
|
|
426
|
-
}
|
|
427
|
-
if (matched === needle.length) score = 1;
|
|
428
|
-
}
|
|
429
|
-
if (score > 0) scored.push({ item, score });
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
return scored.toSorted((left, right) => right.score - left.score).map((entry) => entry.item);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* A skill invoked mid-sentence: the trailing "/word" token when text already
|
|
437
|
-
* precedes it (a draft that starts with "/" is the palette, not an inline
|
|
438
|
-
* completion). Newline-safe, since the token may sit at the start of a new line.
|
|
439
|
-
*/
|
|
440
|
-
export function inlineSlashToken(text: string): string | null {
|
|
441
|
-
if (text.startsWith("/")) return null;
|
|
442
|
-
const match = /(?:^|\s)(\/[a-zA-Z0-9-]*)$/.exec(text);
|
|
443
|
-
|
|
444
|
-
return match ? match[1]! : null;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
interface InlineSlash {
|
|
448
|
-
token: string;
|
|
449
|
-
suggestion: SlashItem;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/** The inline completion state: the trailing "/word" and its closest skill, or null. */
|
|
453
|
-
export function resolveInlineSuggestion(
|
|
454
|
-
slashActive: boolean,
|
|
455
|
-
text: string,
|
|
456
|
-
quickActions: QuickAction[],
|
|
457
|
-
): InlineSlash | null {
|
|
458
|
-
if (slashActive) return null;
|
|
459
|
-
const token = inlineSlashToken(text);
|
|
460
|
-
|
|
461
|
-
if (token === null) return null;
|
|
462
|
-
const suggestion = slashFilter(slashItemsFrom(quickActions), token.slice(1))[0];
|
|
463
|
-
|
|
464
|
-
return suggestion ? { token, suggestion } : null;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function SlashList({
|
|
468
|
-
items,
|
|
469
|
-
selected,
|
|
470
|
-
tokens,
|
|
471
|
-
}: {
|
|
472
|
-
items: SlashItem[];
|
|
473
|
-
selected: number;
|
|
474
|
-
tokens: Theme;
|
|
475
|
-
}): React.ReactNode {
|
|
476
|
-
const WINDOW = 5;
|
|
477
|
-
const start = Math.max(0, Math.min(selected - 2, items.length - WINDOW));
|
|
478
|
-
const visible = items.slice(start, start + WINDOW);
|
|
479
|
-
|
|
480
|
-
if (items.length === 0) return <text fg={tokens.textDim}>no matching actions</text>;
|
|
481
|
-
|
|
482
|
-
return (
|
|
483
|
-
<box style={{ flexDirection: "column" }}>
|
|
484
|
-
{visible.map((item, offset) => {
|
|
485
|
-
const index = start + offset;
|
|
486
|
-
const isSelected = index === selected;
|
|
487
|
-
|
|
488
|
-
return (
|
|
489
|
-
<text key={item.name} style={{ flexShrink: 1 }}>
|
|
490
|
-
<span fg={isSelected ? tokens.accent : tokens.textDim}>{isSelected ? "→ " : " "}</span>
|
|
491
|
-
<span fg={isSelected ? tokens.text : tokens.textMuted}>
|
|
492
|
-
{`action:${item.name}`.padEnd(34)}
|
|
493
|
-
</span>
|
|
494
|
-
<span fg={tokens.textDim}>{item.description.slice(0, 52)}</span>
|
|
495
|
-
</text>
|
|
496
|
-
);
|
|
497
|
-
})}
|
|
498
|
-
{items.length > WINDOW ? (
|
|
499
|
-
<text fg={tokens.textDim}>{`(${selected + 1}/${items.length})`}</text>
|
|
500
|
-
) : null}
|
|
501
|
-
</box>
|
|
502
|
-
);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/** Below the composer: the palette list for a leading "/", else the inline tab-hint, else nothing. */
|
|
506
|
-
function ComposerPalette({
|
|
507
|
-
slashActive,
|
|
508
|
-
slashItems,
|
|
509
|
-
slashIndex,
|
|
510
|
-
inline,
|
|
511
|
-
tokens,
|
|
512
|
-
}: {
|
|
513
|
-
slashActive: boolean;
|
|
514
|
-
slashItems: SlashItem[];
|
|
515
|
-
slashIndex: number;
|
|
516
|
-
inline: InlineSlash | null;
|
|
517
|
-
tokens: Theme;
|
|
518
|
-
}): React.ReactNode {
|
|
519
|
-
if (slashActive) {
|
|
520
|
-
return (
|
|
521
|
-
<box style={{ flexDirection: "column", marginLeft: 3 }}>
|
|
522
|
-
<SlashList
|
|
523
|
-
items={slashItems}
|
|
524
|
-
selected={Math.min(slashIndex, Math.max(0, slashItems.length - 1))}
|
|
525
|
-
tokens={tokens}
|
|
526
|
-
/>
|
|
527
|
-
</box>
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
if (inline === null) return null;
|
|
531
|
-
|
|
532
|
-
return (
|
|
533
|
-
<box style={{ flexDirection: "row", marginLeft: 3 }}>
|
|
534
|
-
<text>
|
|
535
|
-
<span fg={tokens.textDim}>{"⇥ "}</span>
|
|
536
|
-
<span fg={tokens.accent}>{`/${inline.suggestion.name}`}</span>
|
|
537
|
-
<span fg={tokens.textDim}>{` ${inline.suggestion.description}`}</span>
|
|
538
|
-
</text>
|
|
539
|
-
</box>
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/* ------------------------------------------------------------ discussion card */
|
|
544
|
-
|
|
545
|
-
/** One card row group with its own accent-edge color, one per voice. */
|
|
546
|
-
interface EdgeSegment {
|
|
547
|
-
color: string;
|
|
548
|
-
node: React.ReactNode;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
/**
|
|
552
|
-
* The comment-card frame. The accent edge is drawn by hand so that (a) its
|
|
553
|
-
* corner cells are half-height glyphs ("╷"/"╵") terminating flush with the
|
|
554
|
-
* hairline rows, and (b) each voice's rows carry their own edge color with
|
|
555
|
-
* zero gap. Focus lightens every segment; the hairlines never change.
|
|
556
|
-
*/
|
|
557
|
-
function DiscussionCard({
|
|
558
|
-
segments,
|
|
559
|
-
tokens,
|
|
560
|
-
focused = false,
|
|
561
|
-
onFocus,
|
|
562
|
-
}: {
|
|
563
|
-
segments: EdgeSegment[];
|
|
564
|
-
tokens: Theme;
|
|
565
|
-
focused?: boolean;
|
|
566
|
-
onFocus?: () => void;
|
|
567
|
-
}): React.ReactNode {
|
|
568
|
-
const segmentRefs = useRef<Array<BoxRenderable | null>>([]);
|
|
569
|
-
const noHeights: number[] = [];
|
|
570
|
-
const heights = useFrameMeasure(
|
|
571
|
-
() => segmentRefs.current.map((renderable) => renderable?.height ?? 0),
|
|
572
|
-
(left, right) => left.join() === right.join(),
|
|
573
|
-
noHeights,
|
|
574
|
-
);
|
|
575
|
-
|
|
576
|
-
const edgeColor = (color: string): string => (focused ? lighten(color) : color);
|
|
577
|
-
const glyphs = focused
|
|
578
|
-
? { top: "╻", middle: "┃", bottom: "╹" }
|
|
579
|
-
: { top: "╷", middle: "│", bottom: "╵" };
|
|
580
|
-
const edgeRows: Array<{ glyph: string; color: string }> = [
|
|
581
|
-
{
|
|
582
|
-
glyph: glyphs.top,
|
|
583
|
-
color: edgeColor(segments[0]?.color ?? tokens.border),
|
|
584
|
-
},
|
|
585
|
-
];
|
|
586
|
-
|
|
587
|
-
for (let index = 0; index < segments.length; index++) {
|
|
588
|
-
const segmentRows = Math.max(1, heights[index] ?? 1);
|
|
589
|
-
|
|
590
|
-
for (let row = 0; row < segmentRows; row++) {
|
|
591
|
-
edgeRows.push({
|
|
592
|
-
glyph: glyphs.middle,
|
|
593
|
-
color: edgeColor(segments[index]!.color),
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
edgeRows.push({
|
|
598
|
-
glyph: glyphs.bottom,
|
|
599
|
-
color: edgeColor(segments[segments.length - 1]?.color ?? tokens.border),
|
|
600
|
-
});
|
|
601
|
-
|
|
602
|
-
return (
|
|
603
|
-
<box style={{ flexDirection: "row", marginTop: 1, marginLeft: 2 }}>
|
|
604
|
-
<text selectable={false} style={{ flexShrink: 0, width: 1 }}>
|
|
605
|
-
{edgeRows.map((row, index) => (
|
|
606
|
-
<span key={index} fg={row.color}>
|
|
607
|
-
{index < edgeRows.length - 1 ? `${row.glyph}\n` : row.glyph}
|
|
608
|
-
</span>
|
|
609
|
-
))}
|
|
610
|
-
</text>
|
|
611
|
-
<box
|
|
612
|
-
onMouseDown={onFocus}
|
|
613
|
-
style={{
|
|
614
|
-
flexGrow: 1,
|
|
615
|
-
flexDirection: "column",
|
|
616
|
-
border: ["top", "right", "bottom"],
|
|
617
|
-
borderStyle: "single",
|
|
618
|
-
borderColor: tokens.border,
|
|
619
|
-
backgroundColor: "transparent",
|
|
620
|
-
paddingLeft: 1,
|
|
621
|
-
paddingRight: 1,
|
|
622
|
-
}}
|
|
623
|
-
>
|
|
624
|
-
{segments.map((segment, index) => (
|
|
625
|
-
<box
|
|
626
|
-
key={index}
|
|
627
|
-
ref={(renderable: BoxRenderable | null) => {
|
|
628
|
-
segmentRefs.current[index] = renderable;
|
|
629
|
-
}}
|
|
630
|
-
style={{ flexDirection: "column" }}
|
|
631
|
-
>
|
|
632
|
-
{segment.node}
|
|
633
|
-
</box>
|
|
634
|
-
))}
|
|
635
|
-
</box>
|
|
636
|
-
</box>
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function CommentRow({
|
|
641
|
-
annotation,
|
|
642
|
-
tokens,
|
|
643
|
-
}: {
|
|
644
|
-
annotation: Annotation;
|
|
645
|
-
tokens: Theme;
|
|
646
|
-
}): React.ReactNode {
|
|
647
|
-
// own comments (no author) wear the filled dot, collaborators the outline
|
|
648
|
-
const own = annotation.author === undefined;
|
|
649
|
-
const glyph = own ? "●" : "○";
|
|
650
|
-
const glyphColor = own ? tokens.text : tokens.textMuted;
|
|
651
|
-
|
|
652
|
-
return (
|
|
653
|
-
<box style={{ flexDirection: "column" }}>
|
|
654
|
-
{annotation.body.split("\n").map((line, lineIndex) => (
|
|
655
|
-
<box key={lineIndex} style={{ flexDirection: "row" }}>
|
|
656
|
-
<text selectable={false} fg={glyphColor} style={{ flexShrink: 0 }}>
|
|
657
|
-
{lineIndex === 0 ? `${glyph} ` : " "}
|
|
658
|
-
</text>
|
|
659
|
-
<text fg={tokens.text} style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}>
|
|
660
|
-
{line}
|
|
661
|
-
</text>
|
|
662
|
-
</box>
|
|
663
|
-
))}
|
|
664
|
-
</box>
|
|
665
|
-
);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/* ---------------------------------------------------------- scroll markers */
|
|
669
|
-
|
|
670
|
-
function ScrollMarkers({
|
|
671
|
-
discussions,
|
|
672
|
-
hovered,
|
|
673
|
-
tokens,
|
|
674
|
-
onHover,
|
|
675
|
-
onJump,
|
|
676
|
-
}: {
|
|
677
|
-
discussions: Discussion[];
|
|
678
|
-
hovered: string | null;
|
|
679
|
-
tokens: Theme;
|
|
680
|
-
onHover: (marker: { key: string; row: number } | null) => void;
|
|
681
|
-
onJump: (key: string) => void;
|
|
682
|
-
}): React.ReactNode {
|
|
683
|
-
const railRef = useRef<BoxRenderable | null>(null);
|
|
684
|
-
const height = useFrameMeasure(
|
|
685
|
-
() => railRef.current?.height ?? 0,
|
|
686
|
-
(left, right) => left === right,
|
|
687
|
-
0,
|
|
688
|
-
);
|
|
689
|
-
|
|
690
|
-
const firstRow = Math.max(0, Math.floor((height - discussions.length) / 2));
|
|
691
|
-
const rowFor = (index: number): number => firstRow + index;
|
|
692
|
-
const indexAt = (row: number): number | null => {
|
|
693
|
-
const index = row - firstRow;
|
|
694
|
-
|
|
695
|
-
return index >= 0 && index < discussions.length ? index : null;
|
|
696
|
-
};
|
|
697
|
-
const hoveredIndex = discussions.findIndex((discussion) => discussion.key === hovered);
|
|
698
|
-
const colorFor = (index: number): string => {
|
|
699
|
-
if (index === hoveredIndex) return tokens.text;
|
|
700
|
-
if (hoveredIndex >= 0 && discussions.length >= 5 && Math.abs(index - hoveredIndex) === 1) {
|
|
701
|
-
return tokens.textMuted;
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
return tokens.textDim;
|
|
705
|
-
};
|
|
706
|
-
|
|
707
|
-
return (
|
|
708
|
-
<box
|
|
709
|
-
ref={railRef}
|
|
710
|
-
style={{ width: 3, flexShrink: 0, flexDirection: "column" }}
|
|
711
|
-
onMouseMove={(event) => {
|
|
712
|
-
const index = indexAt(event.y - (railRef.current?.y ?? 0));
|
|
713
|
-
|
|
714
|
-
onHover(index === null ? null : { key: discussions[index]!.key, row: rowFor(index) });
|
|
715
|
-
}}
|
|
716
|
-
onMouseOut={() => onHover(null)}
|
|
717
|
-
onMouseDown={(event) => {
|
|
718
|
-
const index = indexAt(event.y - (railRef.current?.y ?? 0));
|
|
719
|
-
|
|
720
|
-
if (index !== null) onJump(discussions[index]!.key);
|
|
721
|
-
}}
|
|
722
|
-
>
|
|
723
|
-
{discussions.map((discussion, index) => (
|
|
724
|
-
<text
|
|
725
|
-
key={discussion.key}
|
|
726
|
-
selectable={false}
|
|
727
|
-
style={{ position: "absolute", top: rowFor(index), left: 0 }}
|
|
728
|
-
fg={colorFor(index)}
|
|
729
|
-
>
|
|
730
|
-
{(index === hoveredIndex ? "●" : "○").padStart(2)}
|
|
731
|
-
</text>
|
|
732
|
-
))}
|
|
733
|
-
</box>
|
|
734
|
-
);
|
|
735
|
-
}
|
|
30
|
+
export { lighten } from "../annotation-palette";
|
|
31
|
+
export { inlineSlashToken, resolveInlineSuggestion } from "../slash-palette";
|
|
736
32
|
|
|
737
33
|
/**
|
|
738
34
|
* How a block's rows are painted: heading weight, muted kinds, the list or
|
|
@@ -775,17 +71,6 @@ function blockStyle(block: DisplayBlock, tokens: Theme): BlockStyle {
|
|
|
775
71
|
|
|
776
72
|
/* ------------------------------------------------------------------ view */
|
|
777
73
|
|
|
778
|
-
interface ComposeState {
|
|
779
|
-
/** The block the card renders under: where the span ends. */
|
|
780
|
-
blockIndex: number;
|
|
781
|
-
/** Reply target; null composes a new discussion. */
|
|
782
|
-
discussionKey: string | null;
|
|
783
|
-
span: TextSpan | null;
|
|
784
|
-
seed: string;
|
|
785
|
-
/** When set, the composer rewrites this annotation instead of appending. */
|
|
786
|
-
editAnnotationId: string | null;
|
|
787
|
-
}
|
|
788
|
-
|
|
789
74
|
/** The grammar as the keybinds dialog lists it; the view owns these keys, so they are not rebindable. */
|
|
790
75
|
export const THREAD_VIEW_CHEATSHEET: CheatsheetSection[] = [
|
|
791
76
|
{
|
|
@@ -868,598 +153,50 @@ export function ThreadView({
|
|
|
868
153
|
theme,
|
|
869
154
|
}: ThreadViewProps): React.ReactNode {
|
|
870
155
|
const tokens = useComponentTheme(theme);
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
// selection (the same position when collapsed); both are full positions, so
|
|
876
|
-
// the cursor block (cards, folding) can move without conjuring a selection
|
|
877
|
-
const [caret, setCaret] = useState<{
|
|
878
|
-
head: TextPosition;
|
|
879
|
-
anchor: TextPosition;
|
|
880
|
-
}>({
|
|
881
|
-
head: { blockIndex: 0, char: 0 },
|
|
882
|
-
anchor: { blockIndex: 0, char: 0 },
|
|
883
|
-
});
|
|
884
|
-
const [compose, setCompose] = useState<ComposeState | null>(null);
|
|
885
|
-
|
|
886
|
-
useEffect(() => {
|
|
887
|
-
onComposingChange?.(compose !== null);
|
|
888
|
-
}, [compose, onComposingChange]);
|
|
889
|
-
useEffect(() => {
|
|
890
|
-
onCursorChange?.(caret.head.blockIndex);
|
|
891
|
-
}, [caret.head.blockIndex, onCursorChange]);
|
|
892
|
-
|
|
893
|
-
// discussion focus is the rail's card focus seen from the document: when the
|
|
894
|
-
// app owns it (onFocusAnnotation), the focused discussion is the one holding
|
|
895
|
-
// the focused card and focusing here names the root comment; standalone, it
|
|
896
|
-
// is local state
|
|
897
|
-
const [localFocus, setLocalFocus] = useState<string | null>(null);
|
|
898
|
-
const focusedDiscussion =
|
|
899
|
-
onFocusAnnotation === undefined
|
|
900
|
-
? localFocus
|
|
901
|
-
: (discussions.find((discussion) =>
|
|
902
|
-
discussion.annotations.some((annotation) => annotation.id === focusedAnnotationId),
|
|
903
|
-
)?.key ?? null);
|
|
904
|
-
const setFocusedDiscussion = (key: string | null): void => {
|
|
905
|
-
if (onFocusAnnotation === undefined) return setLocalFocus(key);
|
|
906
|
-
onFocusAnnotation(discussions.find((discussion) => discussion.key === key)?.rootId);
|
|
156
|
+
const source: LineSource = {
|
|
157
|
+
count: display.length,
|
|
158
|
+
textAt: (blockIndex) => displayText(display[blockIndex]!),
|
|
159
|
+
annotatable: () => true,
|
|
907
160
|
};
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
161
|
+
const surface = useAnnotationSurface({
|
|
162
|
+
source,
|
|
163
|
+
session,
|
|
164
|
+
marks,
|
|
165
|
+
quickActions,
|
|
166
|
+
tokens,
|
|
167
|
+
observer,
|
|
168
|
+
resolved,
|
|
169
|
+
suspended,
|
|
170
|
+
onComposingChange,
|
|
171
|
+
onObserverBlocked,
|
|
172
|
+
onCursorChange,
|
|
173
|
+
focusedAnnotationId,
|
|
174
|
+
onFocusAnnotation,
|
|
175
|
+
onAnnotate,
|
|
176
|
+
onReply,
|
|
177
|
+
onUpdateAnnotation,
|
|
178
|
+
onExit,
|
|
179
|
+
});
|
|
180
|
+
const { palette, discussions } = surface;
|
|
915
181
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
916
|
-
const composerReady = useRef(false);
|
|
917
|
-
const composeRef = useRef<ComposeState | null>(null);
|
|
918
|
-
// every visual line registers its renderable so a drag can hit-test any
|
|
919
|
-
// row on screen, across blocks (rows without text resolve to the block above)
|
|
920
|
-
const lineRenderables = useRef(
|
|
921
|
-
new Map<
|
|
922
|
-
string,
|
|
923
|
-
{
|
|
924
|
-
blockIndex: number;
|
|
925
|
-
start: number;
|
|
926
|
-
end: number;
|
|
927
|
-
renderable: TextRenderable;
|
|
928
|
-
}
|
|
929
|
-
>(),
|
|
930
|
-
);
|
|
931
|
-
// the press anchors the gesture; the first drag samples can land before
|
|
932
|
-
// React has re-rendered, so the anchor travels with the gesture, not the closure
|
|
933
|
-
const dragging = useRef<{
|
|
934
|
-
wordMode: boolean;
|
|
935
|
-
anchor: TextPosition;
|
|
936
|
-
head: TextPosition;
|
|
937
|
-
} | null>(null);
|
|
938
|
-
const lastClick = useRef<ClickStamp | null>(null);
|
|
939
182
|
const viewWidth = useFrameMeasure(
|
|
940
183
|
() => scrollRef.current?.content?.width ?? 0,
|
|
941
184
|
(left, right) => left === right,
|
|
942
185
|
0,
|
|
943
186
|
);
|
|
944
187
|
|
|
945
|
-
// an opening card shifts the layout, so the block it belongs to is revealed
|
|
946
|
-
// again; a discussion focused from the rail is scrolled into view the same way
|
|
947
|
-
const revealBlockIndex =
|
|
948
|
-
compose?.blockIndex ??
|
|
949
|
-
discussions.find((discussion) => discussion.key === focusedDiscussion)?.blockIndex ??
|
|
950
|
-
cursor;
|
|
951
|
-
|
|
952
188
|
useEffect(() => {
|
|
953
189
|
try {
|
|
954
|
-
scrollRef.current?.scrollChildIntoView(`discussion-block-${revealBlockIndex}`);
|
|
190
|
+
scrollRef.current?.scrollChildIntoView(`discussion-block-${surface.revealBlockIndex}`);
|
|
955
191
|
} catch {
|
|
956
192
|
// best-effort reveal
|
|
957
193
|
}
|
|
958
|
-
}, [revealBlockIndex]);
|
|
959
|
-
|
|
960
|
-
const blockText = (blockIndex: number): string => displayText(display[blockIndex]!);
|
|
961
|
-
const textLengthOf = (blockIndex: number): number => blockText(blockIndex).length;
|
|
962
|
-
const { head } = caret;
|
|
963
|
-
/** The held selection in document order, off block edges; null when collapsed. */
|
|
964
|
-
const heldSpan = ((): TextSpan | null => {
|
|
965
|
-
const span = orderedSpan({ anchor: caret.anchor, head });
|
|
966
|
-
|
|
967
|
-
return span ? tightenSpan(span, textLengthOf) : null;
|
|
968
|
-
})();
|
|
969
|
-
const caretIsSelection = heldSpan !== null;
|
|
970
|
-
/** The typing anchor: the held selection (char-precise), else the marked word. */
|
|
971
|
-
const caretSpan = (): TextSpan | null => {
|
|
972
|
-
if (heldSpan) return heldSpan;
|
|
973
|
-
const word = wordRangeAt(blockText(head.blockIndex), head.char);
|
|
974
|
-
|
|
975
|
-
return word
|
|
976
|
-
? {
|
|
977
|
-
start: { blockIndex: cursor, char: word.start },
|
|
978
|
-
end: { blockIndex: cursor, char: word.end },
|
|
979
|
-
}
|
|
980
|
-
: null;
|
|
981
|
-
};
|
|
982
|
-
const collapseCaret = (): void => setCaret({ head, anchor: head });
|
|
983
|
-
/** The text a span covers, blocks joined by a space, for previews. */
|
|
984
|
-
const spanQuote = (span: TextSpan): string => {
|
|
985
|
-
const parts: string[] = [];
|
|
986
|
-
|
|
987
|
-
for (let blockIndex = span.start.blockIndex; blockIndex <= span.end.blockIndex; blockIndex++) {
|
|
988
|
-
const range = spanRangeInBlock(span, blockIndex, textLengthOf(blockIndex));
|
|
989
|
-
|
|
990
|
-
if (range) parts.push(blockText(blockIndex).slice(range.start, range.end));
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
return parts.join(" ");
|
|
994
|
-
};
|
|
995
|
-
const allGeometry = (): LineGeometry[] =>
|
|
996
|
-
[...lineRenderables.current.values()].map((entry) => ({
|
|
997
|
-
blockIndex: entry.blockIndex,
|
|
998
|
-
start: entry.start,
|
|
999
|
-
end: entry.end,
|
|
1000
|
-
x: entry.renderable.x,
|
|
1001
|
-
y: entry.renderable.y,
|
|
1002
|
-
}));
|
|
1003
|
-
|
|
1004
|
-
const openCompose = (state: ComposeState): void => {
|
|
1005
|
-
if (observer) return onObserverBlocked?.("observer");
|
|
1006
|
-
if (resolved) return onObserverBlocked?.("resolved");
|
|
1007
|
-
composerReady.current = false;
|
|
1008
|
-
composeRef.current = state;
|
|
1009
|
-
setCompose(state);
|
|
1010
|
-
setComposeText(state.seed);
|
|
1011
|
-
setSlashIndex(0);
|
|
1012
|
-
};
|
|
1013
|
-
const closeCompose = (): void => {
|
|
1014
|
-
composeRef.current = null;
|
|
1015
|
-
setCompose(null);
|
|
1016
|
-
setComposeText("");
|
|
1017
|
-
};
|
|
1018
|
-
const slashActive = compose !== null && composeText.startsWith("/");
|
|
1019
|
-
const slashItems = slashActive
|
|
1020
|
-
? slashFilter(slashItemsFrom(quickActions), composeText.slice(1).trim())
|
|
1021
|
-
: [];
|
|
1022
|
-
// inline skill completion: the trailing "/word" token and its closest match,
|
|
1023
|
-
// offered as a hint below the composer that tab completes to the full name
|
|
1024
|
-
const inlineSlash = resolveInlineSuggestion(slashActive, composeText, quickActions);
|
|
1025
|
-
|
|
1026
|
-
const saveComment = (body: string): void => {
|
|
1027
|
-
// the body saves verbatim - typed newlines are the author's choice;
|
|
1028
|
-
// trimming only decides whether the draft is empty enough to discard
|
|
1029
|
-
const target = composeRef.current;
|
|
1030
|
-
|
|
1031
|
-
closeCompose();
|
|
1032
|
-
collapseCaret();
|
|
1033
|
-
if (!target || body.trim().length === 0) return;
|
|
1034
|
-
if (target.editAnnotationId !== null) {
|
|
1035
|
-
onUpdateAnnotation(target.editAnnotationId, body);
|
|
1036
|
-
|
|
1037
|
-
return;
|
|
1038
|
-
}
|
|
1039
|
-
if (target.discussionKey !== null) {
|
|
1040
|
-
const discussion = discussions.find((candidate) => candidate.key === target.discussionKey);
|
|
1041
|
-
|
|
1042
|
-
if (discussion) {
|
|
1043
|
-
onReply(discussion.rootId, body);
|
|
1044
|
-
|
|
1045
|
-
return;
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
const span = target.span ?? caretSpan();
|
|
1049
|
-
|
|
1050
|
-
if (span) onAnnotate(span, body);
|
|
1051
|
-
};
|
|
1052
|
-
/** A new discussion on the typing anchor; the card renders under the span's last block. */
|
|
1053
|
-
const openNewCompose = (seed: string): void => {
|
|
1054
|
-
const span = caretSpan();
|
|
1055
|
-
|
|
1056
|
-
openCompose({
|
|
1057
|
-
blockIndex: span?.end.blockIndex ?? head.blockIndex,
|
|
1058
|
-
discussionKey: null,
|
|
1059
|
-
span,
|
|
1060
|
-
seed,
|
|
1061
|
-
editAnnotationId: null,
|
|
1062
|
-
});
|
|
1063
|
-
};
|
|
1064
|
-
|
|
1065
|
-
// clicking away from an open composer commits the draft (blur-save);
|
|
1066
|
-
// a half-typed slash query is never a comment, so it discards instead
|
|
1067
|
-
const blurSaveCompose = (): void => {
|
|
1068
|
-
if (!composeRef.current) return;
|
|
1069
|
-
if (slashActive || composeText.trim().length === 0) return closeCompose();
|
|
1070
|
-
saveComment(composeText);
|
|
1071
|
-
};
|
|
1072
|
-
|
|
1073
|
-
/** Extend the held selection to the pointer's position (word mode snaps both ends). */
|
|
1074
|
-
const extendSelectionTo = (pointer: TextPosition): void => {
|
|
1075
|
-
const drag = dragging.current;
|
|
1076
|
-
|
|
1077
|
-
if (!drag) return;
|
|
1078
|
-
let next = { head: pointer, anchor: drag.anchor };
|
|
1079
|
-
|
|
1080
|
-
if (drag.wordMode) {
|
|
1081
|
-
const span = orderedSpan({ anchor: drag.anchor, head: pointer });
|
|
1082
|
-
|
|
1083
|
-
if (span) {
|
|
1084
|
-
const snapped = snapSpanToWords(span, blockText);
|
|
1085
|
-
const forward = comparePositions(pointer, drag.anchor) >= 0;
|
|
1086
|
-
|
|
1087
|
-
next = forward
|
|
1088
|
-
? { anchor: snapped.start, head: snapped.end }
|
|
1089
|
-
: { anchor: snapped.end, head: snapped.start };
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
if (comparePositions(next.head, drag.head) === 0) return;
|
|
1093
|
-
drag.head = next.head;
|
|
1094
|
-
setCursor(next.head.blockIndex);
|
|
1095
|
-
setCaret({ head: next.head, anchor: next.anchor });
|
|
1096
|
-
};
|
|
1097
|
-
|
|
1098
|
-
// Drag routing lives on the view root: the renderer captures the drag on
|
|
1099
|
-
// its FIRST drag sample, to whatever is under the pointer then, so a fast
|
|
1100
|
-
// flick off the text row would otherwise strand the gesture. Events
|
|
1101
|
-
// bubble, so the root sees every drag and resolves it against every line
|
|
1102
|
-
// on screen - the mark follows the pointer across blocks.
|
|
1103
|
-
const handleRootDrag = (event: TerminalMouseEvent): void => {
|
|
1104
|
-
if (!dragging.current) return;
|
|
1105
|
-
const position = positionAt(allGeometry(), event.x, event.y);
|
|
1106
|
-
|
|
1107
|
-
if (position) extendSelectionTo(position);
|
|
1108
|
-
};
|
|
1109
|
-
const endDrag = (): void => {
|
|
1110
|
-
dragging.current = null;
|
|
1111
|
-
};
|
|
1112
|
-
|
|
1113
|
-
const jumpToDiscussion = (key: string): void => {
|
|
1114
|
-
const target = discussions.find((discussion) => discussion.key === key);
|
|
1115
|
-
|
|
1116
|
-
if (!target) return;
|
|
1117
|
-
blurSaveCompose();
|
|
1118
|
-
setCursor(target.blockIndex);
|
|
1119
|
-
setFocusedDiscussion(key);
|
|
1120
|
-
};
|
|
1121
|
-
|
|
1122
|
-
// ── keyboard, split per concern to stay within the complexity budget ──
|
|
1123
|
-
|
|
1124
|
-
const handleSlashKey = (key: KeyEvent, activeCompose: ComposeState): boolean => {
|
|
1125
|
-
if (!slashActive || slashItems.length === 0) return false;
|
|
1126
|
-
const selected = Math.min(slashIndex, slashItems.length - 1);
|
|
1127
|
-
|
|
1128
|
-
if (key.name === "up") {
|
|
1129
|
-
setSlashIndex(Math.max(0, selected - 1));
|
|
1130
|
-
|
|
1131
|
-
return true;
|
|
1132
|
-
}
|
|
1133
|
-
if (key.name === "down") {
|
|
1134
|
-
setSlashIndex(Math.min(slashItems.length - 1, selected + 1));
|
|
1135
|
-
|
|
1136
|
-
return true;
|
|
1137
|
-
}
|
|
1138
|
-
if (key.name === "return" || key.name === "tab") {
|
|
1139
|
-
openCompose({ ...activeCompose, seed: slashItems[selected]!.body });
|
|
1140
|
-
|
|
1141
|
-
return true;
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
return false;
|
|
1145
|
-
};
|
|
1146
|
-
|
|
1147
|
-
/** Tab completes the trailing "/word" to the matched skill's full name, then a space to chain. */
|
|
1148
|
-
const handleInlineSlashKey = (key: KeyEvent, activeCompose: ComposeState): boolean => {
|
|
1149
|
-
if (inlineSlash === null || key.name !== "tab") return false;
|
|
1150
|
-
const cut = composeText.length - inlineSlash.token.length;
|
|
1151
|
-
const completed = `${composeText.slice(0, cut)}/${inlineSlash.suggestion.name} `;
|
|
1152
|
-
|
|
1153
|
-
openCompose({ ...activeCompose, seed: completed });
|
|
1154
|
-
|
|
1155
|
-
return true;
|
|
1156
|
-
};
|
|
1157
|
-
|
|
1158
|
-
/** Pre-mount window: buffer printables, honor a fast cmd+enter or newline. */
|
|
1159
|
-
const handlePremountKey = (key: KeyEvent, activeCompose: ComposeState): void => {
|
|
1160
|
-
if (key.name === "return") {
|
|
1161
|
-
if (key.super || key.meta || key.ctrl) return saveComment(activeCompose.seed);
|
|
1162
|
-
const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
|
|
1163
|
-
|
|
1164
|
-
composeRef.current = grown;
|
|
1165
|
-
setComposeText(grown.seed);
|
|
1166
|
-
|
|
1167
|
-
return setCompose(grown);
|
|
1168
|
-
}
|
|
1169
|
-
const sequence = printableSequence(key);
|
|
1170
|
-
|
|
1171
|
-
if (sequence) {
|
|
1172
|
-
const grown = { ...activeCompose, seed: activeCompose.seed + sequence };
|
|
1173
|
-
|
|
1174
|
-
composeRef.current = grown;
|
|
1175
|
-
setComposeText(grown.seed);
|
|
1176
|
-
setCompose(grown);
|
|
1177
|
-
}
|
|
1178
|
-
};
|
|
1179
|
-
|
|
1180
|
-
const handleComposeKey = (key: KeyEvent, activeCompose: ComposeState): void => {
|
|
1181
|
-
// the textarea owns every key while open; the view takes dismiss (which
|
|
1182
|
-
// also releases the discussion focus), the slash palette, and pre-mount input
|
|
1183
|
-
if (key.name === "escape") {
|
|
1184
|
-
setFocusedDiscussion(null);
|
|
1185
|
-
|
|
1186
|
-
return closeCompose();
|
|
1187
|
-
}
|
|
1188
|
-
// backspace on an already empty draft undoes it: the card dissolves
|
|
1189
|
-
// and the caret sits back on the still-held selection, ready to re-type
|
|
1190
|
-
// (an edit of an existing comment is never deleted this way)
|
|
1191
|
-
if (
|
|
1192
|
-
key.name === "backspace" &&
|
|
1193
|
-
composeText.length === 0 &&
|
|
1194
|
-
activeCompose.editAnnotationId === null
|
|
1195
|
-
) {
|
|
1196
|
-
return closeCompose();
|
|
1197
|
-
}
|
|
1198
|
-
if (handleSlashKey(key, activeCompose)) return;
|
|
1199
|
-
if (handleInlineSlashKey(key, activeCompose)) return;
|
|
1200
|
-
if (!composerReady.current) handlePremountKey(key, activeCompose);
|
|
1201
|
-
};
|
|
1202
|
-
|
|
1203
|
-
/** m / cmd+[ / cmd+] / tab / return - true when the key was a discussion primitive. */
|
|
1204
|
-
const handleDiscussionVerb = (key: KeyEvent): boolean => {
|
|
1205
|
-
// comment on selection: cmd+option+m (alt+m where cmd arrives ESC-prefixed)
|
|
1206
|
-
if (key.name === "m" && (key.super || key.meta || key.option)) {
|
|
1207
|
-
openNewCompose("");
|
|
1208
|
-
|
|
1209
|
-
return true;
|
|
1210
|
-
}
|
|
1211
|
-
if ((key.super || key.meta || key.ctrl) && (key.name === "[" || key.name === "]")) {
|
|
1212
|
-
if (discussions.length === 0) return true;
|
|
1213
|
-
const currentIndex = discussions.findIndex(
|
|
1214
|
-
(discussion) => discussion.key === focusedDiscussion,
|
|
1215
|
-
);
|
|
1216
|
-
const nextIndex =
|
|
1217
|
-
key.name === "]"
|
|
1218
|
-
? (currentIndex + 1) % discussions.length
|
|
1219
|
-
: currentIndex <= 0
|
|
1220
|
-
? discussions.length - 1
|
|
1221
|
-
: currentIndex - 1;
|
|
1222
|
-
|
|
1223
|
-
jumpToDiscussion(discussions[nextIndex]!.key);
|
|
1224
|
-
|
|
1225
|
-
return true;
|
|
1226
|
-
}
|
|
1227
|
-
if (key.name === "tab") {
|
|
1228
|
-
setFolded((current) => {
|
|
1229
|
-
const next = new Set(current);
|
|
1230
|
-
|
|
1231
|
-
for (const discussion of discussions) {
|
|
1232
|
-
if (discussion.blockIndex !== cursor) continue;
|
|
1233
|
-
if (next.has(discussion.key)) next.delete(discussion.key);
|
|
1234
|
-
else next.add(discussion.key);
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
return next;
|
|
1238
|
-
});
|
|
1239
|
-
|
|
1240
|
-
return true;
|
|
1241
|
-
}
|
|
1242
|
-
// enter replies into the focused discussion, else the cursor block's discussion
|
|
1243
|
-
if (key.name === "return") {
|
|
1244
|
-
const replyTarget =
|
|
1245
|
-
discussions.find((discussion) => discussion.key === focusedDiscussion) ??
|
|
1246
|
-
discussions.findLast((discussion) => discussion.blockIndex === cursor);
|
|
1247
|
-
|
|
1248
|
-
if (replyTarget) {
|
|
1249
|
-
openCompose({
|
|
1250
|
-
blockIndex: replyTarget.blockIndex,
|
|
1251
|
-
discussionKey: replyTarget.key,
|
|
1252
|
-
span: null,
|
|
1253
|
-
seed: "",
|
|
1254
|
-
editAnnotationId: null,
|
|
1255
|
-
});
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
return true;
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
return false;
|
|
1262
|
-
};
|
|
1263
|
-
|
|
1264
|
-
/** Arrows step by word start; shift holds the anchor; the caret flows across blocks. */
|
|
1265
|
-
const moveCaretHorizontal = (delta: 1 | -1, extend: boolean): void => {
|
|
1266
|
-
const text = blockText(head.blockIndex);
|
|
1267
|
-
const target =
|
|
1268
|
-
delta === 1 ? nextWordStart(text, head.char) : previousWordStart(text, head.char);
|
|
1269
|
-
const moveTo = (blockIndex: number, char: number): void => {
|
|
1270
|
-
const next = { blockIndex, char };
|
|
1271
|
-
|
|
1272
|
-
setCursor(blockIndex);
|
|
1273
|
-
setCaret({ head: next, anchor: extend ? caret.anchor : next });
|
|
1274
|
-
};
|
|
1275
|
-
|
|
1276
|
-
if (target !== null) return moveTo(head.blockIndex, target);
|
|
1277
|
-
if (extend && head.char !== (delta === 1 ? text.length : 0)) {
|
|
1278
|
-
// the block edge first, then the next step crosses into the neighbour
|
|
1279
|
-
return moveTo(head.blockIndex, delta === 1 ? text.length : 0);
|
|
1280
|
-
}
|
|
1281
|
-
if (delta === 1 && head.blockIndex < display.length - 1) return moveTo(head.blockIndex + 1, 0);
|
|
1282
|
-
if (delta === -1 && head.blockIndex > 0) {
|
|
1283
|
-
const previousText = blockText(head.blockIndex - 1);
|
|
1284
|
-
|
|
1285
|
-
moveTo(
|
|
1286
|
-
head.blockIndex - 1,
|
|
1287
|
-
extend ? previousText.length : (previousWordStart(previousText, previousText.length) ?? 0),
|
|
1288
|
-
);
|
|
1289
|
-
}
|
|
1290
|
-
};
|
|
1291
|
-
|
|
1292
|
-
const handleCaretKey = (key: KeyEvent): boolean => {
|
|
1293
|
-
const vertical =
|
|
1294
|
-
key.name === "up" || (key.ctrl && key.name === "p")
|
|
1295
|
-
? -1
|
|
1296
|
-
: key.name === "down" || (key.ctrl && key.name === "n")
|
|
1297
|
-
? 1
|
|
1298
|
-
: 0;
|
|
1299
|
-
|
|
1300
|
-
if (vertical !== 0) {
|
|
1301
|
-
const next = Math.max(0, Math.min(display.length - 1, cursor + vertical));
|
|
1302
|
-
|
|
1303
|
-
setCaret({
|
|
1304
|
-
head: { blockIndex: next, char: 0 },
|
|
1305
|
-
anchor: { blockIndex: next, char: 0 },
|
|
1306
|
-
});
|
|
1307
|
-
setFocusedDiscussion(null);
|
|
1308
|
-
setCursor(next);
|
|
1309
|
-
|
|
1310
|
-
return true;
|
|
1311
|
-
}
|
|
1312
|
-
if (key.name === "right" || (key.ctrl && key.name === "l")) {
|
|
1313
|
-
moveCaretHorizontal(1, Boolean(key.shift || key.meta || key.ctrl));
|
|
1314
|
-
|
|
1315
|
-
return true;
|
|
1316
|
-
}
|
|
1317
|
-
if (key.name === "left" || (key.ctrl && key.name === "h")) {
|
|
1318
|
-
moveCaretHorizontal(-1, Boolean(key.shift || key.meta || key.ctrl));
|
|
1319
|
-
|
|
1320
|
-
return true;
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
return false;
|
|
1324
|
-
};
|
|
1325
|
-
|
|
1326
|
-
/** A printable starts a comment: edit my trailing, reply, or a new discussion. */
|
|
1327
|
-
const startTyping = (key: KeyEvent): void => {
|
|
1328
|
-
const sequence = printableSequence(key);
|
|
1329
|
-
|
|
1330
|
-
if (!sequence) return;
|
|
1331
|
-
const discussion = discussions.find((candidate) => candidate.key === focusedDiscussion);
|
|
1332
|
-
|
|
1333
|
-
if (discussion) {
|
|
1334
|
-
const last = discussion.annotations.at(-1)!;
|
|
1335
|
-
const editingOwn = last.author === undefined;
|
|
1336
|
-
|
|
1337
|
-
return openCompose({
|
|
1338
|
-
blockIndex: discussion.blockIndex,
|
|
1339
|
-
discussionKey: discussion.key,
|
|
1340
|
-
span: null,
|
|
1341
|
-
seed: editingOwn ? `${last.body}${sequence}` : sequence,
|
|
1342
|
-
editAnnotationId: editingOwn ? last.id : null,
|
|
1343
|
-
});
|
|
1344
|
-
}
|
|
1345
|
-
openNewCompose(sequence);
|
|
1346
|
-
};
|
|
1347
|
-
|
|
1348
|
-
useKeyboard((key) => {
|
|
1349
|
-
if (suspended) return;
|
|
1350
|
-
if (key.ctrl && key.name === "q") return onExit();
|
|
1351
|
-
const activeCompose = composeRef.current;
|
|
1352
|
-
|
|
1353
|
-
if (activeCompose) return handleComposeKey(key, activeCompose);
|
|
1354
|
-
if (key.name === "escape") {
|
|
1355
|
-
if (focusedDiscussion !== null) return setFocusedDiscussion(null);
|
|
1356
|
-
|
|
1357
|
-
return collapseCaret();
|
|
1358
|
-
}
|
|
1359
|
-
if (handleDiscussionVerb(key)) return;
|
|
1360
|
-
if (handleCaretKey(key)) return;
|
|
1361
|
-
startTyping(key);
|
|
1362
|
-
});
|
|
1363
|
-
|
|
1364
|
-
// ── render, split per concern to stay within the complexity budget ──
|
|
1365
|
-
|
|
1366
|
-
// one composer element (keyed by seed so slash acceptance remounts it);
|
|
1367
|
-
// the slash palette renders BELOW the whole card, not inside it
|
|
1368
|
-
const composerNode = compose ? (
|
|
1369
|
-
<Composer
|
|
1370
|
-
key={compose.seed}
|
|
1371
|
-
seed={compose.seed}
|
|
1372
|
-
glyph="●"
|
|
1373
|
-
tokens={tokens}
|
|
1374
|
-
onSave={saveComment}
|
|
1375
|
-
onReady={() => (composerReady.current = true)}
|
|
1376
|
-
onInput={setComposeText}
|
|
1377
|
-
/>
|
|
1378
|
-
) : null;
|
|
1379
|
-
// one node below the composer: the palette list for a leading "/", otherwise the
|
|
1380
|
-
// inline completion hint. Renders null when neither applies, so it always pushes.
|
|
1381
|
-
const paletteNode = (
|
|
1382
|
-
<ComposerPalette
|
|
1383
|
-
key="composer-palette"
|
|
1384
|
-
slashActive={slashActive}
|
|
1385
|
-
slashItems={slashItems}
|
|
1386
|
-
slashIndex={slashIndex}
|
|
1387
|
-
inline={inlineSlash}
|
|
1388
|
-
tokens={tokens}
|
|
1389
|
-
/>
|
|
1390
|
-
);
|
|
1391
|
-
|
|
1392
|
-
const foldedSummaryFor = (discussion: Discussion): React.ReactNode => (
|
|
1393
|
-
<box key={discussion.key} style={{ flexDirection: "row", marginTop: 1, marginLeft: 2 }}>
|
|
1394
|
-
<text fg={tokens.textDim}>
|
|
1395
|
-
{`○ ${discussion.annotations.length} comment${discussion.annotations.length === 1 ? "" : "s"} ›`}
|
|
1396
|
-
</text>
|
|
1397
|
-
</box>
|
|
1398
|
-
);
|
|
1399
|
-
|
|
1400
|
-
const discussionCardFor = (discussion: Discussion, composingHere: boolean): React.ReactNode => {
|
|
1401
|
-
const segments: EdgeSegment[] = discussion.annotations.map((annotation) => {
|
|
1402
|
-
const editingHere =
|
|
1403
|
-
composingHere && compose?.editAnnotationId === annotation.id && composerNode !== null;
|
|
1404
|
-
|
|
1405
|
-
return {
|
|
1406
|
-
color: annotation.author === undefined ? palette.cardEdge : tokens.text,
|
|
1407
|
-
node: editingHere ? (
|
|
1408
|
-
composerNode
|
|
1409
|
-
) : (
|
|
1410
|
-
<CommentRow key={annotation.id} annotation={annotation} tokens={tokens} />
|
|
1411
|
-
),
|
|
1412
|
-
};
|
|
1413
|
-
});
|
|
1414
|
-
|
|
1415
|
-
if (composingHere && compose?.editAnnotationId === null && composerNode) {
|
|
1416
|
-
segments.push({ color: palette.cardEdge, node: composerNode });
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
return (
|
|
1420
|
-
<DiscussionCard
|
|
1421
|
-
key={discussion.key}
|
|
1422
|
-
segments={segments}
|
|
1423
|
-
tokens={tokens}
|
|
1424
|
-
focused={focusedDiscussion === discussion.key}
|
|
1425
|
-
onFocus={() => {
|
|
1426
|
-
if (composeRef.current?.discussionKey === discussion.key) return;
|
|
1427
|
-
blurSaveCompose();
|
|
1428
|
-
setFocusedDiscussion(discussion.key);
|
|
1429
|
-
setCursor(discussion.blockIndex);
|
|
1430
|
-
}}
|
|
1431
|
-
/>
|
|
1432
|
-
);
|
|
1433
|
-
};
|
|
1434
|
-
|
|
1435
|
-
/** Ranges to paint on a block: discussion marks, the compose mark, the caret. */
|
|
1436
|
-
const blockRangesFor = (blockIndex: number): MarkRange[] => {
|
|
1437
|
-
const textLength = textLengthOf(blockIndex);
|
|
1438
|
-
const ranges: MarkRange[] = [];
|
|
1439
|
-
const pushSpan = (span: TextSpan | null): void => {
|
|
1440
|
-
const range = span ? spanRangeInBlock(span, blockIndex, textLength) : null;
|
|
1441
|
-
|
|
1442
|
-
if (range) ranges.push(range);
|
|
1443
|
-
};
|
|
1444
|
-
|
|
1445
|
-
for (const discussion of discussions) pushSpan(discussion.span);
|
|
1446
|
-
if (compose) pushSpan(compose.span);
|
|
1447
|
-
if (caretIsSelection && !compose) pushSpan(heldSpan);
|
|
1448
|
-
// the idle caret cell sits at the head, also at the end of a held mark;
|
|
1449
|
-
// an open card takes the cursor with it, so no cell is painted then
|
|
1450
|
-
if (blockIndex === head.blockIndex && !compose && textLength > 0) {
|
|
1451
|
-
const cell = Math.max(0, Math.min(textLength - 1, head.char));
|
|
1452
|
-
|
|
1453
|
-
ranges.push({ start: cell, end: cell + 1, caretOnly: true });
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
return ranges;
|
|
1457
|
-
};
|
|
194
|
+
}, [surface.revealBlockIndex]);
|
|
1458
195
|
|
|
1459
196
|
interface LineContext {
|
|
1460
197
|
blockIndex: number;
|
|
1461
198
|
text: string;
|
|
1462
|
-
line:
|
|
199
|
+
line: VisualLine;
|
|
1463
200
|
lineIndex: number;
|
|
1464
201
|
ranges: MarkRange[];
|
|
1465
202
|
marker: string;
|
|
@@ -1472,13 +209,7 @@ export function ThreadView({
|
|
|
1472
209
|
const lineRowFor = (context: LineContext): React.ReactNode => {
|
|
1473
210
|
const { blockIndex, text, line, lineIndex, ranges, marker, baseFg, baseAttributes, trailing } =
|
|
1474
211
|
context;
|
|
1475
|
-
const lineRanges = ranges
|
|
1476
|
-
.map((range) => ({
|
|
1477
|
-
start: Math.max(range.start, line.start) - line.start,
|
|
1478
|
-
end: Math.min(range.end, line.end) - line.start,
|
|
1479
|
-
caretOnly: range.caretOnly,
|
|
1480
|
-
}))
|
|
1481
|
-
.filter((range) => range.end > range.start);
|
|
212
|
+
const lineRanges = lineMarkRanges(ranges, line);
|
|
1482
213
|
|
|
1483
214
|
return (
|
|
1484
215
|
<box key={`line-${lineIndex}`} style={{ flexDirection: "row" }}>
|
|
@@ -1489,36 +220,8 @@ export function ThreadView({
|
|
|
1489
220
|
<text
|
|
1490
221
|
selectable={false}
|
|
1491
222
|
style={{ flexGrow: 1, flexShrink: 1 }}
|
|
1492
|
-
ref={(
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
if (renderable) {
|
|
1496
|
-
lineRenderables.current.set(key, {
|
|
1497
|
-
blockIndex,
|
|
1498
|
-
start: line.start,
|
|
1499
|
-
end: line.end,
|
|
1500
|
-
renderable,
|
|
1501
|
-
});
|
|
1502
|
-
} else lineRenderables.current.delete(key);
|
|
1503
|
-
}}
|
|
1504
|
-
onMouseDown={(event) => {
|
|
1505
|
-
blurSaveCompose();
|
|
1506
|
-
const pressed = positionAt(allGeometry(), event.x, event.y);
|
|
1507
|
-
|
|
1508
|
-
if (!pressed) return;
|
|
1509
|
-
const stamp = { time: Date.now(), x: event.x, y: event.y };
|
|
1510
|
-
const wordMode = isDoubleClick(lastClick.current, stamp);
|
|
1511
|
-
|
|
1512
|
-
lastClick.current = stamp;
|
|
1513
|
-
const word = wordMode ? wordRangeAt(blockText(pressed.blockIndex), pressed.char) : null;
|
|
1514
|
-
const anchor = word ? { blockIndex: pressed.blockIndex, char: word.start } : pressed;
|
|
1515
|
-
const pressHead = word ? { blockIndex: pressed.blockIndex, char: word.end } : pressed;
|
|
1516
|
-
|
|
1517
|
-
dragging.current = { wordMode, anchor, head: pressHead };
|
|
1518
|
-
setFocusedDiscussion(null);
|
|
1519
|
-
setCursor(pressed.blockIndex);
|
|
1520
|
-
setCaret({ head: pressHead, anchor });
|
|
1521
|
-
}}
|
|
223
|
+
ref={surface.registerLine(blockIndex, lineIndex, line)}
|
|
224
|
+
onMouseDown={surface.onLineMouseDown}
|
|
1522
225
|
>
|
|
1523
226
|
{runsFor(text.slice(line.start, line.end), lineRanges).map((run, runIndex) => (
|
|
1524
227
|
<span
|
|
@@ -1536,54 +239,10 @@ export function ThreadView({
|
|
|
1536
239
|
);
|
|
1537
240
|
};
|
|
1538
241
|
|
|
1539
|
-
/** Cards anchored to a visual line: discussion cards, the new-discussion composer. */
|
|
1540
|
-
const cardsAfterLine = (
|
|
1541
|
-
blockIndex: number,
|
|
1542
|
-
line: { start: number; end: number },
|
|
1543
|
-
isLastLine: boolean,
|
|
1544
|
-
blockDiscussions: Discussion[],
|
|
1545
|
-
): React.ReactNode[] => {
|
|
1546
|
-
const endsInLine = (end: number): boolean => end - 1 >= line.start && end - 1 < line.end;
|
|
1547
|
-
const nodes: React.ReactNode[] = [];
|
|
1548
|
-
const composeHere = compose && compose.blockIndex === blockIndex;
|
|
1549
|
-
|
|
1550
|
-
for (const discussion of blockDiscussions) {
|
|
1551
|
-
if (!endsInLine(discussion.span.end.char)) continue;
|
|
1552
|
-
const composingHere = Boolean(composeHere && compose.discussionKey === discussion.key);
|
|
1553
|
-
|
|
1554
|
-
if (folded.has(discussion.key) && !composingHere) {
|
|
1555
|
-
nodes.push(foldedSummaryFor(discussion));
|
|
1556
|
-
continue;
|
|
1557
|
-
}
|
|
1558
|
-
nodes.push(discussionCardFor(discussion, composingHere));
|
|
1559
|
-
if (composingHere) nodes.push(paletteNode);
|
|
1560
|
-
}
|
|
1561
|
-
if (composeHere && compose.discussionKey === null && composerNode) {
|
|
1562
|
-
const anchoredHere = compose.span ? endsInLine(compose.span.end.char) : isLastLine;
|
|
1563
|
-
|
|
1564
|
-
if (anchoredHere) {
|
|
1565
|
-
nodes.push(
|
|
1566
|
-
<DiscussionCard
|
|
1567
|
-
key="compose-new"
|
|
1568
|
-
tokens={tokens}
|
|
1569
|
-
segments={[{ color: palette.cardEdge, node: composerNode }]}
|
|
1570
|
-
/>,
|
|
1571
|
-
);
|
|
1572
|
-
nodes.push(paletteNode);
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
return nodes;
|
|
1577
|
-
};
|
|
1578
|
-
|
|
1579
242
|
const blockNodeFor = (blockIndex: number): React.ReactNode => {
|
|
1580
243
|
const block = display[blockIndex]!;
|
|
1581
|
-
const text =
|
|
1582
|
-
|
|
1583
|
-
const blockDiscussions = discussions.filter(
|
|
1584
|
-
(discussion) => discussion.blockIndex === blockIndex,
|
|
1585
|
-
);
|
|
1586
|
-
const ranges = blockRangesFor(blockIndex);
|
|
244
|
+
const text = displayText(block);
|
|
245
|
+
const ranges = surface.rangesFor(blockIndex);
|
|
1587
246
|
const { baseFg, baseAttributes, marker, changeTag } = blockStyle(block, tokens);
|
|
1588
247
|
const lines = wrapLines(text, viewWidth > 0 ? viewWidth - marker.length - 6 : 0);
|
|
1589
248
|
const lineRows: React.ReactNode[] = [];
|
|
@@ -1605,7 +264,7 @@ export function ThreadView({
|
|
|
1605
264
|
trailing: lineIndex === 0 ? changeTag : null,
|
|
1606
265
|
}),
|
|
1607
266
|
);
|
|
1608
|
-
const cards = cardsAfterLine(blockIndex, line, isLastLine
|
|
267
|
+
const cards = surface.cardsAfterLine(blockIndex, line, isLastLine);
|
|
1609
268
|
|
|
1610
269
|
lineRows.push(...cards);
|
|
1611
270
|
if (cards.length > 0 && !isLastLine) {
|
|
@@ -1639,50 +298,8 @@ export function ThreadView({
|
|
|
1639
298
|
);
|
|
1640
299
|
};
|
|
1641
300
|
|
|
1642
|
-
const markerPreview = (): React.ReactNode => {
|
|
1643
|
-
if (hoveredMarker === null) return null;
|
|
1644
|
-
const preview = discussions.find((discussion) => discussion.key === hoveredMarker.key);
|
|
1645
|
-
|
|
1646
|
-
if (!preview) return null;
|
|
1647
|
-
const quote = spanQuote(preview.span);
|
|
1648
|
-
const lastComment = preview.annotations.at(-1)!;
|
|
1649
|
-
|
|
1650
|
-
return (
|
|
1651
|
-
<box
|
|
1652
|
-
style={{
|
|
1653
|
-
position: "absolute",
|
|
1654
|
-
top: Math.max(0, hoveredMarker.row - 1),
|
|
1655
|
-
right: 4,
|
|
1656
|
-
width: 48,
|
|
1657
|
-
flexDirection: "column",
|
|
1658
|
-
border: true,
|
|
1659
|
-
borderStyle: "single",
|
|
1660
|
-
borderColor: tokens.border,
|
|
1661
|
-
backgroundColor: tokens.elevated,
|
|
1662
|
-
paddingLeft: 1,
|
|
1663
|
-
paddingRight: 1,
|
|
1664
|
-
}}
|
|
1665
|
-
>
|
|
1666
|
-
<text fg={tokens.textDim}>{`"${quote.slice(0, 42)}"`}</text>
|
|
1667
|
-
<text fg={tokens.text}>
|
|
1668
|
-
{`${lastComment.author === undefined ? "●" : "○"} ${lastComment.body}`.slice(0, 44)}
|
|
1669
|
-
</text>
|
|
1670
|
-
<text fg={tokens.textDim}>
|
|
1671
|
-
{`${preview.annotations.length} comment${
|
|
1672
|
-
preview.annotations.length === 1 ? "" : "s"
|
|
1673
|
-
} · click to jump`}
|
|
1674
|
-
</text>
|
|
1675
|
-
</box>
|
|
1676
|
-
);
|
|
1677
|
-
};
|
|
1678
|
-
|
|
1679
301
|
return (
|
|
1680
|
-
<box
|
|
1681
|
-
style={{ flexGrow: 1, flexDirection: "row" }}
|
|
1682
|
-
onMouseDrag={handleRootDrag}
|
|
1683
|
-
onMouseDragEnd={endDrag}
|
|
1684
|
-
onMouseUp={endDrag}
|
|
1685
|
-
>
|
|
302
|
+
<box style={{ flexGrow: 1, flexDirection: "row" }} {...surface.rootMouseProps}>
|
|
1686
303
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
1687
304
|
{editOrphanCount > 0 ? (
|
|
1688
305
|
<box style={{ height: 1, backgroundColor: palette.markBackdrop, paddingLeft: 2 }}>
|
|
@@ -1691,18 +308,23 @@ export function ThreadView({
|
|
|
1691
308
|
</text>
|
|
1692
309
|
</box>
|
|
1693
310
|
) : null}
|
|
1694
|
-
|
|
311
|
+
{/* the rail draws this box's scrollbar past the dots, so the built-in one stays hidden */}
|
|
312
|
+
<scrollbox
|
|
313
|
+
ref={scrollRef}
|
|
314
|
+
style={{ flexGrow: 1, paddingTop: 1 }}
|
|
315
|
+
focused={false}
|
|
316
|
+
verticalScrollbarOptions={{ visible: false }}
|
|
317
|
+
>
|
|
1695
318
|
{display.map((_block, blockIndex) => blockNodeFor(blockIndex))}
|
|
1696
319
|
</scrollbox>
|
|
1697
320
|
</box>
|
|
1698
|
-
<
|
|
321
|
+
<DiscussionMarkerRail
|
|
1699
322
|
discussions={discussions}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
323
|
+
spanQuote={surface.spanQuote}
|
|
324
|
+
onJump={surface.jumpToDiscussion}
|
|
325
|
+
scrollbox={scrollRef}
|
|
326
|
+
theme={theme}
|
|
1704
327
|
/>
|
|
1705
|
-
{markerPreview()}
|
|
1706
328
|
</box>
|
|
1707
329
|
);
|
|
1708
330
|
}
|