@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +42 -34
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +259 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +330 -161
- package/src/App.prototype-markdown.test.tsx +128 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +264 -0
- package/src/App.test.tsx +402 -126
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1402 -1060
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +774 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +577 -0
- package/src/app-view-model.ts +168 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +362 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +150 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +149 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +906 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- 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 +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +75 -0
- package/src/components/ProjectTreeView.tsx +125 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +951 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +525 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +49 -0
- package/src/components/WelcomePlayground.tsx +139 -0
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +59 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +579 -34
- package/src/config.ts +533 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +238 -0
- package/src/diff-intraline.ts +236 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +28 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +724 -0
- package/src/intent-dispatch.ts +690 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +456 -115
- package/src/keymap.ts +315 -81
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +67 -0
- package/src/launch-layout.ts +47 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +81 -0
- package/src/prototype-browser.ts +329 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +85 -12
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +219 -0
- package/src/theme.ts +88 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.curate.test.ts +476 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +2050 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +153 -0
- package/src/use-annotation-surface.tsx +975 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-repo-changes.ts +87 -0
- package/src/use-settings-dialog.tsx +364 -0
- package/src/use-terminal-virtualizer.ts +259 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +279 -9
- package/src/view-diff.ts +460 -24
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +545 -0
- package/src/view-plan.ts +1019 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +99 -0
- package/src/workbench-snapshot.ts +59 -0
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marking-feel harness: drives real mouse events through the virtual
|
|
3
|
+
* terminal and reads the PAINTED highlight back from styled spans, so it
|
|
4
|
+
* measures what the reviewer sees - which characters carry the mark, and how
|
|
5
|
+
* many frames / milliseconds after each drag step they appear. Character
|
|
6
|
+
* precision, backward drags, multi-line drags, and double-click word mode are
|
|
7
|
+
* all asserted on the frame, not on internal state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, mock, spyOn, test, type Mock } from "bun:test";
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
13
|
+
import { TextAttributes } from "@opentui/core";
|
|
14
|
+
import type { TestRendererSetup } from "@opentui/core/testing";
|
|
15
|
+
import { DEFAULT_QUICK_ACTIONS } from "../config";
|
|
16
|
+
import { settle, typeText } from "../test-support";
|
|
17
|
+
import { buildDisplay, marksByDisplay } from "../view-plan";
|
|
18
|
+
import { makeAnchor, parseBlocks, type Annotation } from "@cueloop/schema";
|
|
19
|
+
import type { TextSpan } from "../thread-selection";
|
|
20
|
+
import * as markRuns from "../mark-runs";
|
|
21
|
+
import { fixturePlanSession } from "./story-fixtures";
|
|
22
|
+
import { ThreadView } from "./ThreadView";
|
|
23
|
+
|
|
24
|
+
const PARAGRAPH =
|
|
25
|
+
"The daemon persists sessions to disk atomically through a temp file and a rename so that a crash never leaves a torn document behind.";
|
|
26
|
+
/** A paragraph with hard source line breaks: one terminal row per source line. */
|
|
27
|
+
const STANZA_ROWS = [
|
|
28
|
+
"Sessions are written as one JSON document per",
|
|
29
|
+
"session, through a temp file and an atomic rename,",
|
|
30
|
+
"so a crash can never leave a torn file on disk.",
|
|
31
|
+
];
|
|
32
|
+
const STANZA = STANZA_ROWS.join("\n");
|
|
33
|
+
const BULLETS = ["store.ts - the ThreadStore class", "schema.ts - the on-disk record shape"];
|
|
34
|
+
const PLAN = `# Plan\n\n${PARAGRAPH}\n\n${STANZA}\n\n- ${BULLETS[0]}\n- ${BULLETS[1]}\n`;
|
|
35
|
+
/** Display block indices: title, paragraph, stanza, two bullets. */
|
|
36
|
+
const STANZA_BLOCK = 2;
|
|
37
|
+
const FIRST_BULLET_BLOCK = 3;
|
|
38
|
+
/** The mark backdrop the dark palette paints under selected text. */
|
|
39
|
+
const MARK_RGB = [70, 56, 82];
|
|
40
|
+
/** The idle caret cell of the dark palette. */
|
|
41
|
+
const CARET_RGB = [86, 91, 104];
|
|
42
|
+
|
|
43
|
+
type AnnotateHandler = (span: TextSpan, body: string) => void;
|
|
44
|
+
type ReplyHandler = (rootAnnotationId: string, body: string) => void;
|
|
45
|
+
|
|
46
|
+
let setup: TestRendererSetup;
|
|
47
|
+
let annotate: Mock<AnnotateHandler>;
|
|
48
|
+
let reply: Mock<ReplyHandler>;
|
|
49
|
+
|
|
50
|
+
function session(annotations: Annotation[] = []) {
|
|
51
|
+
return fixturePlanSession({
|
|
52
|
+
artifact: {
|
|
53
|
+
type: "plan",
|
|
54
|
+
content: PLAN,
|
|
55
|
+
meta: { title: "Plan", planPath: "plan.md" },
|
|
56
|
+
},
|
|
57
|
+
annotations,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function mount(annotations: Annotation[] = [], height = 24): Promise<void> {
|
|
62
|
+
annotate = mock<AnnotateHandler>(() => {});
|
|
63
|
+
reply = mock<ReplyHandler>(() => {});
|
|
64
|
+
const display = buildDisplay(PLAN, undefined);
|
|
65
|
+
|
|
66
|
+
setup = await testRender(
|
|
67
|
+
<ThreadView
|
|
68
|
+
session={session(annotations)}
|
|
69
|
+
display={display}
|
|
70
|
+
marks={marksByDisplay(annotations, display)}
|
|
71
|
+
quickActions={DEFAULT_QUICK_ACTIONS}
|
|
72
|
+
observer={false}
|
|
73
|
+
onAnnotate={annotate}
|
|
74
|
+
onReply={reply}
|
|
75
|
+
onUpdateAnnotation={() => {}}
|
|
76
|
+
onExit={() => {}}
|
|
77
|
+
/>,
|
|
78
|
+
{ width: 64, height },
|
|
79
|
+
);
|
|
80
|
+
await settle(setup);
|
|
81
|
+
await settle(setup);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Rows with their concatenated mark-highlighted text (empty when none). */
|
|
85
|
+
function highlightedByRow(): Map<number, string> {
|
|
86
|
+
const rows = new Map<number, string>();
|
|
87
|
+
const frame = setup.captureSpans();
|
|
88
|
+
|
|
89
|
+
frame.lines.forEach((line, rowIndex) => {
|
|
90
|
+
let marked = "";
|
|
91
|
+
|
|
92
|
+
for (const span of line.spans) {
|
|
93
|
+
const [red, green, blue] = span.bg.toInts();
|
|
94
|
+
const backdrop = red === MARK_RGB[0] && green === MARK_RGB[1] && blue === MARK_RGB[2];
|
|
95
|
+
const caretCell = red === CARET_RGB[0] && green === CARET_RGB[1] && blue === CARET_RGB[2];
|
|
96
|
+
// the caret cell shows through a mark; the underline says it is still marked
|
|
97
|
+
const underlined = (span.attributes & TextAttributes.UNDERLINE) !== 0;
|
|
98
|
+
|
|
99
|
+
if (backdrop || (caretCell && underlined)) marked += span.text;
|
|
100
|
+
}
|
|
101
|
+
if (marked.length > 0) rows.set(rowIndex, marked);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function highlightedText(): string {
|
|
108
|
+
return [...highlightedByRow().values()].join("");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Every painted caret cell as (row, column, character). */
|
|
112
|
+
function caretCells(): Array<{ row: number; column: number; text: string }> {
|
|
113
|
+
const cells: Array<{ row: number; column: number; text: string }> = [];
|
|
114
|
+
|
|
115
|
+
setup.captureSpans().lines.forEach((line, row) => {
|
|
116
|
+
let column = 0;
|
|
117
|
+
|
|
118
|
+
for (const span of line.spans) {
|
|
119
|
+
const [red, green, blue] = span.bg.toInts();
|
|
120
|
+
|
|
121
|
+
if (red === CARET_RGB[0] && green === CARET_RGB[1] && blue === CARET_RGB[2]) {
|
|
122
|
+
cells.push({ row, column, text: span.text });
|
|
123
|
+
}
|
|
124
|
+
column += span.width;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return cells;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The highlighted rows read as one string, the way the source reads across its line breaks. */
|
|
132
|
+
function highlightedAcrossRows(): string {
|
|
133
|
+
return [...highlightedByRow().values()].join(" ");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The stanza's char offset of a column on one of its rows. */
|
|
137
|
+
function stanzaOffset(rowIndex: number, column: number): number {
|
|
138
|
+
return STANZA_ROWS.slice(0, rowIndex).reduce((total, row) => total + row.length + 1, 0) + column;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Column of a word on a row, from the char frame. */
|
|
142
|
+
function locate(needle: string) {
|
|
143
|
+
const rows = setup.captureCharFrame().split("\n");
|
|
144
|
+
const row = rows.findIndex((line) => line.includes(needle));
|
|
145
|
+
|
|
146
|
+
expect(row, `"${needle}" not on screen`).toBeGreaterThanOrEqual(0);
|
|
147
|
+
|
|
148
|
+
return { row, column: rows[row]!.indexOf(needle) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Render frames until the highlight equals `expected`, returning how many
|
|
153
|
+
* frames it took - the feel metric: 1 means the very next frame after the
|
|
154
|
+
* event already carries the mark.
|
|
155
|
+
*/
|
|
156
|
+
async function framesUntilHighlight(expected: string, limit = 4): Promise<number> {
|
|
157
|
+
for (let frame = 1; frame <= limit; frame++) {
|
|
158
|
+
// one scheduler turn (the input parser and React commit), then one frame
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
160
|
+
await setup.renderOnce();
|
|
161
|
+
if (highlightedText() === expected) return frame;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
throw new Error(`highlight never became "${expected}"; last: "${highlightedText()}"`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
beforeEach(async () => {
|
|
168
|
+
await mount();
|
|
169
|
+
});
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
setup.renderer.destroy();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("marking feel", () => {
|
|
175
|
+
test("a drag from mid-word paints a character-precise selection within one frame per step", async () => {
|
|
176
|
+
// Arrange: press inside "daemon" (on the "e"), not at a word boundary
|
|
177
|
+
const { row, column } = locate("daemon");
|
|
178
|
+
const pressColumn = column + 2;
|
|
179
|
+
|
|
180
|
+
await setup.mockMouse.pressDown(pressColumn, row);
|
|
181
|
+
await settle(setup);
|
|
182
|
+
|
|
183
|
+
// Act + Assert: each step to the right extends the mark by exactly one
|
|
184
|
+
// character, and the mark is painted by the next frame
|
|
185
|
+
const latencies: number[] = [];
|
|
186
|
+
const frames: number[] = [];
|
|
187
|
+
|
|
188
|
+
for (let step = 1; step <= 8; step++) {
|
|
189
|
+
const started = performance.now();
|
|
190
|
+
|
|
191
|
+
await setup.mockMouse.moveTo(pressColumn + step, row);
|
|
192
|
+
frames.push(await framesUntilHighlight(PARAGRAPH.slice(6, 6 + step)));
|
|
193
|
+
latencies.push(performance.now() - started);
|
|
194
|
+
}
|
|
195
|
+
await setup.mockMouse.release(pressColumn + 8, row);
|
|
196
|
+
|
|
197
|
+
expect(highlightedText()).toBe("emon per");
|
|
198
|
+
// the target is the very next frame; a scheduler starved by the rest of
|
|
199
|
+
// the suite may land the paint one frame later, which is still immediate
|
|
200
|
+
expect(Math.max(...frames)).toBeLessThanOrEqual(2);
|
|
201
|
+
expect(frames.filter((frame) => frame === 1).length).toBeGreaterThanOrEqual(frames.length / 2);
|
|
202
|
+
const sorted = latencies.toSorted((left, right) => left - right);
|
|
203
|
+
|
|
204
|
+
console.log(
|
|
205
|
+
`drag step latency p50 ${sorted[Math.floor(sorted.length / 2)]!.toFixed(
|
|
206
|
+
2,
|
|
207
|
+
)}ms p95 ${sorted[sorted.length - 1]!.toFixed(2)}ms`,
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a backward drag normalizes to the same characters", async () => {
|
|
212
|
+
// Arrange: press at the "s" of "persists", drag left into "daemon"
|
|
213
|
+
const { row, column } = locate("persists");
|
|
214
|
+
|
|
215
|
+
await setup.mockMouse.pressDown(column + 3, row);
|
|
216
|
+
await settle(setup);
|
|
217
|
+
|
|
218
|
+
// Act
|
|
219
|
+
await setup.mockMouse.moveTo(column - 4, row);
|
|
220
|
+
await settle(setup);
|
|
221
|
+
await setup.mockMouse.release(column - 4, row);
|
|
222
|
+
|
|
223
|
+
// Assert: "mon per" - partial words on both ends, ordered start->end
|
|
224
|
+
expect(highlightedText()).toBe("mon per");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("dragging onto the next visual line selects across the wrap", async () => {
|
|
228
|
+
// Arrange: the paragraph wraps at 64 columns; press on line one
|
|
229
|
+
const first = locate("atomically");
|
|
230
|
+
const second = locate("crash");
|
|
231
|
+
|
|
232
|
+
expect(second.row).toBe(first.row + 1);
|
|
233
|
+
await setup.mockMouse.pressDown(first.column, first.row);
|
|
234
|
+
await settle(setup);
|
|
235
|
+
|
|
236
|
+
// Act: drag down onto the second line
|
|
237
|
+
await setup.mockMouse.moveTo(second.column + 5, second.row);
|
|
238
|
+
await settle(setup);
|
|
239
|
+
|
|
240
|
+
// Assert: both rows carry highlight, and together they read as the text
|
|
241
|
+
// between the two positions (minus the wrap's dropped space)
|
|
242
|
+
const rows = highlightedByRow();
|
|
243
|
+
|
|
244
|
+
expect(rows.size).toBe(2);
|
|
245
|
+
const joined = [...rows.values()].join(" ");
|
|
246
|
+
const start = PARAGRAPH.indexOf("atomically");
|
|
247
|
+
const end = PARAGRAPH.indexOf("crash") + 5;
|
|
248
|
+
|
|
249
|
+
expect(joined).toBe(PARAGRAPH.slice(start, end));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("dragging past the right edge selects to the line end", async () => {
|
|
253
|
+
// Arrange
|
|
254
|
+
const { row, column } = locate("torn");
|
|
255
|
+
|
|
256
|
+
await setup.mockMouse.pressDown(column, row);
|
|
257
|
+
await settle(setup);
|
|
258
|
+
|
|
259
|
+
// Act: far past the text
|
|
260
|
+
await setup.mockMouse.moveTo(63, row);
|
|
261
|
+
await settle(setup);
|
|
262
|
+
|
|
263
|
+
// Assert
|
|
264
|
+
expect(highlightedText()).toBe("torn document behind.");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("double-click selects the whole word under the pointer", async () => {
|
|
268
|
+
// Arrange + Act
|
|
269
|
+
const { row, column } = locate("sessions");
|
|
270
|
+
|
|
271
|
+
await setup.mockMouse.doubleClick(column + 3, row);
|
|
272
|
+
await settle(setup);
|
|
273
|
+
|
|
274
|
+
// Assert
|
|
275
|
+
expect(highlightedText()).toBe("sessions");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("backspace on an empty draft undoes it, keeps the selection, and typing re-opens it", async () => {
|
|
279
|
+
// Arrange: mark "emon per" and start a draft
|
|
280
|
+
const { row, column } = locate("daemon");
|
|
281
|
+
|
|
282
|
+
await setup.mockMouse.drag(column + 2, row, column + 10, row);
|
|
283
|
+
await settle(setup);
|
|
284
|
+
await typeText(setup, "x");
|
|
285
|
+
expect(setup.captureCharFrame()).toContain("● x");
|
|
286
|
+
|
|
287
|
+
// Act: delete the only character - the card must stay, now empty
|
|
288
|
+
setup.mockInput.pressKey("BACKSPACE");
|
|
289
|
+
await settle(setup);
|
|
290
|
+
await settle(setup);
|
|
291
|
+
|
|
292
|
+
// Assert
|
|
293
|
+
expect(setup.captureCharFrame()).toContain("●");
|
|
294
|
+
expect(setup.captureCharFrame()).not.toContain("● x");
|
|
295
|
+
|
|
296
|
+
// Act: backspace again on the empty draft
|
|
297
|
+
setup.mockInput.pressKey("BACKSPACE");
|
|
298
|
+
await settle(setup);
|
|
299
|
+
await settle(setup);
|
|
300
|
+
|
|
301
|
+
// Assert: the card is gone, the selection is still painted
|
|
302
|
+
expect(setup.captureCharFrame()).not.toContain("●");
|
|
303
|
+
expect(highlightedText()).toBe("emon per");
|
|
304
|
+
|
|
305
|
+
// Act + Assert: typing again re-opens a draft on the same selection
|
|
306
|
+
await typeText(setup, "y");
|
|
307
|
+
expect(setup.captureCharFrame()).toContain("● y");
|
|
308
|
+
expect(highlightedText()).toBe("emon per");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("typing after a partial-word drag anchors the comment to exactly those characters", async () => {
|
|
312
|
+
// Arrange: mark "emon per"
|
|
313
|
+
const { row, column } = locate("daemon");
|
|
314
|
+
|
|
315
|
+
await setup.mockMouse.drag(column + 2, row, column + 10, row);
|
|
316
|
+
await settle(setup);
|
|
317
|
+
expect(highlightedText()).toBe("emon per");
|
|
318
|
+
|
|
319
|
+
// Act: type and send
|
|
320
|
+
await typeText(setup, "partial");
|
|
321
|
+
setup.mockInput.pressKey("RETURN", { meta: true });
|
|
322
|
+
await settle(setup);
|
|
323
|
+
|
|
324
|
+
// Assert: the annotation span is the exact partial selection
|
|
325
|
+
expect(annotate).toHaveBeenCalledTimes(1);
|
|
326
|
+
const [span, body] = annotate.mock.calls[0]!;
|
|
327
|
+
|
|
328
|
+
expect(span).toEqual({
|
|
329
|
+
start: { blockIndex: 1, char: 6 },
|
|
330
|
+
end: { blockIndex: 1, char: 14 },
|
|
331
|
+
});
|
|
332
|
+
expect(PARAGRAPH.slice(span.start.char, span.end.char)).toBe("emon per");
|
|
333
|
+
expect(body).toBe("partial");
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe("marking across rows", () => {
|
|
338
|
+
/** Row and left column of the stanza on screen. */
|
|
339
|
+
function stanzaOrigin() {
|
|
340
|
+
const { row, column } = locate(STANZA_ROWS[0]!);
|
|
341
|
+
|
|
342
|
+
for (const [index, text] of STANZA_ROWS.entries()) {
|
|
343
|
+
expect(setup.captureCharFrame().split("\n")[row + index]).toContain(text);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { row, column };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
test("a drag onto the row below marks exactly two rows, to the character under the pointer", async () => {
|
|
350
|
+
// Arrange: press inside "written" on row one (source line one)
|
|
351
|
+
const origin = stanzaOrigin();
|
|
352
|
+
const pressColumn = STANZA_ROWS[0]!.indexOf("written") + 3;
|
|
353
|
+
const headColumn = STANZA_ROWS[1]!.indexOf("temp") + 2;
|
|
354
|
+
|
|
355
|
+
await setup.mockMouse.pressDown(origin.column + pressColumn, origin.row);
|
|
356
|
+
await settle(setup);
|
|
357
|
+
|
|
358
|
+
// Act: one row down, inside "temp"
|
|
359
|
+
await setup.mockMouse.moveTo(origin.column + headColumn, origin.row + 1);
|
|
360
|
+
await settle(setup);
|
|
361
|
+
|
|
362
|
+
// Assert: two rows, the second ending mid-word under the pointer
|
|
363
|
+
const rows = highlightedByRow();
|
|
364
|
+
|
|
365
|
+
expect([...rows.keys()]).toEqual([origin.row, origin.row + 1]);
|
|
366
|
+
expect(highlightedAcrossRows()).toBe(
|
|
367
|
+
STANZA.slice(stanzaOffset(0, pressColumn), stanzaOffset(1, headColumn)).replace("\n", " "),
|
|
368
|
+
);
|
|
369
|
+
expect(rows.get(origin.row + 1)).toBe("session, through a te");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("moving down a row then back up shrinks the mark again", async () => {
|
|
373
|
+
// Arrange
|
|
374
|
+
const origin = stanzaOrigin();
|
|
375
|
+
const pressColumn = 4;
|
|
376
|
+
|
|
377
|
+
await setup.mockMouse.pressDown(origin.column + pressColumn, origin.row);
|
|
378
|
+
await settle(setup);
|
|
379
|
+
|
|
380
|
+
// Act: two rows down, then back to one
|
|
381
|
+
await setup.mockMouse.moveTo(origin.column + 10, origin.row + 2);
|
|
382
|
+
await settle(setup);
|
|
383
|
+
expect(highlightedByRow().size).toBe(3);
|
|
384
|
+
await setup.mockMouse.moveTo(origin.column + 10, origin.row + 1);
|
|
385
|
+
await settle(setup);
|
|
386
|
+
|
|
387
|
+
// Assert
|
|
388
|
+
const rows = highlightedByRow();
|
|
389
|
+
|
|
390
|
+
expect(rows.size).toBe(2);
|
|
391
|
+
expect(rows.get(origin.row + 1)).toBe("session, t");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("shift+down extends a mark to the same column on the next visual row", async () => {
|
|
395
|
+
const origin = stanzaOrigin();
|
|
396
|
+
const column = STANZA_ROWS[0]!.indexOf("written");
|
|
397
|
+
|
|
398
|
+
await setup.mockMouse.click(origin.column + column, origin.row);
|
|
399
|
+
await settle(setup);
|
|
400
|
+
setup.mockInput.pressKey("ARROW_DOWN", { shift: true });
|
|
401
|
+
await settle(setup);
|
|
402
|
+
|
|
403
|
+
expect(highlightedAcrossRows()).toBe(
|
|
404
|
+
STANZA.slice(stanzaOffset(0, column), stanzaOffset(1, column)).replace("\n", " "),
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("a backward drag up two rows normalizes to the same characters", async () => {
|
|
409
|
+
// Arrange: press inside "crash" on row three
|
|
410
|
+
const origin = stanzaOrigin();
|
|
411
|
+
const pressColumn = STANZA_ROWS[2]!.indexOf("crash") + 2;
|
|
412
|
+
const headColumn = STANZA_ROWS[0]!.indexOf("JSON");
|
|
413
|
+
|
|
414
|
+
await setup.mockMouse.pressDown(origin.column + pressColumn, origin.row + 2);
|
|
415
|
+
await settle(setup);
|
|
416
|
+
|
|
417
|
+
// Act: drag up to the "J" of "JSON"
|
|
418
|
+
await setup.mockMouse.moveTo(origin.column + headColumn, origin.row);
|
|
419
|
+
await settle(setup);
|
|
420
|
+
|
|
421
|
+
// Assert
|
|
422
|
+
expect(highlightedAcrossRows()).toBe(
|
|
423
|
+
STANZA.slice(stanzaOffset(0, headColumn), stanzaOffset(2, pressColumn)).replaceAll("\n", " "),
|
|
424
|
+
);
|
|
425
|
+
expect(highlightedByRow().get(origin.row + 2)).toBe("so a cr");
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("dragging below all text marks through to the document's last character", async () => {
|
|
429
|
+
// Arrange: press on the stanza's last row
|
|
430
|
+
const origin = stanzaOrigin();
|
|
431
|
+
const pressColumn = STANZA_ROWS[2]!.indexOf("torn");
|
|
432
|
+
|
|
433
|
+
await setup.mockMouse.pressDown(origin.column + pressColumn, origin.row + 2);
|
|
434
|
+
await settle(setup);
|
|
435
|
+
|
|
436
|
+
// Act: a row below everything, past the right edge
|
|
437
|
+
await setup.mockMouse.moveTo(63, origin.row + 8);
|
|
438
|
+
await settle(setup);
|
|
439
|
+
|
|
440
|
+
// Assert: the stanza tail and both bullets (list items sit on adjacent rows)
|
|
441
|
+
const rows = highlightedByRow();
|
|
442
|
+
|
|
443
|
+
expect(rows.get(origin.row + 2)).toBe("torn file on disk.");
|
|
444
|
+
expect(rows.get(origin.row + 4)).toBe(BULLETS[0]);
|
|
445
|
+
expect(rows.get(origin.row + 5)).toBe(BULLETS[1]);
|
|
446
|
+
expect(rows.size).toBe(3);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("dragging above all text marks back from the document's first character", async () => {
|
|
450
|
+
// Arrange: press on the paragraph's first row
|
|
451
|
+
const { row, column } = locate("daemon");
|
|
452
|
+
|
|
453
|
+
await setup.mockMouse.pressDown(column, row);
|
|
454
|
+
await settle(setup);
|
|
455
|
+
|
|
456
|
+
// Act: the padding row above the title
|
|
457
|
+
await setup.mockMouse.moveTo(30, 0);
|
|
458
|
+
await settle(setup);
|
|
459
|
+
|
|
460
|
+
// Assert: the title and the paragraph head
|
|
461
|
+
const rows = highlightedByRow();
|
|
462
|
+
|
|
463
|
+
expect(rows.get(locate("Plan").row)).toBe("Plan");
|
|
464
|
+
expect(rows.get(row)).toBe("The ");
|
|
465
|
+
expect(rows.size).toBe(2);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("a press and release on the same cell marks nothing", async () => {
|
|
469
|
+
// Arrange + Act
|
|
470
|
+
const origin = stanzaOrigin();
|
|
471
|
+
|
|
472
|
+
await setup.mockMouse.pressDown(origin.column + 12, origin.row + 1);
|
|
473
|
+
await settle(setup);
|
|
474
|
+
await setup.mockMouse.release(origin.column + 12, origin.row + 1);
|
|
475
|
+
await settle(setup);
|
|
476
|
+
|
|
477
|
+
// Assert
|
|
478
|
+
expect(highlightedByRow().size).toBe(0);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("holding a mark at the viewport edge scrolls and extends it", async () => {
|
|
482
|
+
setup.renderer.destroy();
|
|
483
|
+
await mount([], 10);
|
|
484
|
+
const origin = locate("daemon");
|
|
485
|
+
|
|
486
|
+
await setup.mockMouse.pressDown(origin.column, origin.row);
|
|
487
|
+
await setup.mockMouse.moveTo(63, 9);
|
|
488
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
489
|
+
await settle(setup);
|
|
490
|
+
await setup.mockMouse.release(63, 9);
|
|
491
|
+
|
|
492
|
+
expect(setup.captureCharFrame()).not.toContain("# Plan");
|
|
493
|
+
expect(highlightedText().length).toBeGreaterThan("daemon".length);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("opening a comment on the document's last line reveals its composer", async () => {
|
|
497
|
+
setup.renderer.destroy();
|
|
498
|
+
await mount([], 10);
|
|
499
|
+
|
|
500
|
+
for (let step = 0; step < 20; step++) {
|
|
501
|
+
setup.mockInput.pressKey("ARROW_DOWN");
|
|
502
|
+
// eslint-disable-next-line no-await-in-loop
|
|
503
|
+
await settle(setup);
|
|
504
|
+
}
|
|
505
|
+
const lastLine = locate(BULLETS[1]!);
|
|
506
|
+
|
|
507
|
+
await setup.mockMouse.drag(
|
|
508
|
+
lastLine.column,
|
|
509
|
+
lastLine.row,
|
|
510
|
+
lastLine.column + "schema.ts".length,
|
|
511
|
+
lastLine.row,
|
|
512
|
+
);
|
|
513
|
+
await settle(setup);
|
|
514
|
+
await typeText(setup, "bottom comment");
|
|
515
|
+
|
|
516
|
+
expect(setup.captureCharFrame()).toContain("● bottom comment");
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe("marking across blocks", () => {
|
|
521
|
+
/** Screen row and left column of a text on screen, asserting it is there. */
|
|
522
|
+
function rowOf(text: string) {
|
|
523
|
+
return locate(text);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
test("a drag from a paragraph into the bullet below marks the tail and the head, not the gap", async () => {
|
|
527
|
+
// Arrange: press inside "torn" on the stanza's last row
|
|
528
|
+
const torn = rowOf("torn file");
|
|
529
|
+
const bullet = rowOf(BULLETS[0]!);
|
|
530
|
+
|
|
531
|
+
expect(bullet.row).toBe(torn.row + 2);
|
|
532
|
+
await setup.mockMouse.pressDown(torn.column, torn.row);
|
|
533
|
+
await settle(setup);
|
|
534
|
+
|
|
535
|
+
// Act: onto "Thread" in the first bullet
|
|
536
|
+
await setup.mockMouse.moveTo(bullet.column + BULLETS[0]!.indexOf("Thread") + 6, bullet.row);
|
|
537
|
+
await settle(setup);
|
|
538
|
+
|
|
539
|
+
// Assert: two rows, the blank row between them untouched
|
|
540
|
+
const rows = highlightedByRow();
|
|
541
|
+
|
|
542
|
+
expect([...rows.keys()]).toEqual([torn.row, bullet.row]);
|
|
543
|
+
expect(rows.get(torn.row)).toBe("torn file on disk.");
|
|
544
|
+
expect(rows.get(bullet.row)).toBe("store.ts - the Thread");
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("a drag across two bullets marks both", async () => {
|
|
548
|
+
// Arrange
|
|
549
|
+
const first = rowOf(BULLETS[0]!);
|
|
550
|
+
const second = rowOf(BULLETS[1]!);
|
|
551
|
+
|
|
552
|
+
await setup.mockMouse.pressDown(first.column + "store.ts - ".length, first.row);
|
|
553
|
+
await settle(setup);
|
|
554
|
+
|
|
555
|
+
// Act
|
|
556
|
+
await setup.mockMouse.moveTo(second.column + "schema.ts".length, second.row);
|
|
557
|
+
await settle(setup);
|
|
558
|
+
|
|
559
|
+
// Assert
|
|
560
|
+
const rows = highlightedByRow();
|
|
561
|
+
|
|
562
|
+
expect(rows.get(first.row)).toBe("the ThreadStore class");
|
|
563
|
+
expect(rows.get(second.row)).toBe("schema.ts");
|
|
564
|
+
expect(rows.size).toBe(2);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("the pointer on the blank row below a block marks to that block's end only", async () => {
|
|
568
|
+
// Arrange
|
|
569
|
+
const torn = rowOf("torn file");
|
|
570
|
+
|
|
571
|
+
await setup.mockMouse.pressDown(torn.column, torn.row);
|
|
572
|
+
await settle(setup);
|
|
573
|
+
|
|
574
|
+
// Act
|
|
575
|
+
await setup.mockMouse.moveTo(10, torn.row + 1);
|
|
576
|
+
await settle(setup);
|
|
577
|
+
|
|
578
|
+
// Assert
|
|
579
|
+
expect(highlightedByRow().size).toBe(1);
|
|
580
|
+
expect(highlightedText()).toBe("torn file on disk.");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("a backward drag from a bullet up into the paragraph normalizes across the blocks", async () => {
|
|
584
|
+
// Arrange: press after "store.ts" and drag up into "disk"
|
|
585
|
+
const bullet = rowOf(BULLETS[0]!);
|
|
586
|
+
const disk = rowOf("disk.");
|
|
587
|
+
|
|
588
|
+
await setup.mockMouse.pressDown(bullet.column + "store.ts".length, bullet.row);
|
|
589
|
+
await settle(setup);
|
|
590
|
+
|
|
591
|
+
// Act
|
|
592
|
+
await setup.mockMouse.moveTo(disk.column, disk.row);
|
|
593
|
+
await settle(setup);
|
|
594
|
+
|
|
595
|
+
// Assert
|
|
596
|
+
const rows = highlightedByRow();
|
|
597
|
+
|
|
598
|
+
expect(rows.get(disk.row)).toBe("disk.");
|
|
599
|
+
expect(rows.get(bullet.row)).toBe("store.ts");
|
|
600
|
+
expect(rows.size).toBe(2);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("typing after a cross-block drag comments on the span and the card sits under its last block", async () => {
|
|
604
|
+
// Arrange
|
|
605
|
+
const torn = rowOf("torn file");
|
|
606
|
+
const bullet = rowOf(BULLETS[0]!);
|
|
607
|
+
|
|
608
|
+
await setup.mockMouse.drag(torn.column, torn.row, bullet.column + 8, bullet.row);
|
|
609
|
+
await settle(setup);
|
|
610
|
+
|
|
611
|
+
// Act
|
|
612
|
+
await typeText(setup, "one comment for both");
|
|
613
|
+
const frame = setup.captureCharFrame().split("\n");
|
|
614
|
+
const cardRow = frame.findIndex((line) => line.includes("● one comment for both"));
|
|
615
|
+
|
|
616
|
+
// Assert: the draft card renders below the bullet, not below the paragraph
|
|
617
|
+
expect(cardRow).toBeGreaterThan(bullet.row);
|
|
618
|
+
expect(frame.slice(torn.row + 1, bullet.row + 1).join("\n")).not.toContain("●");
|
|
619
|
+
|
|
620
|
+
// Act: send
|
|
621
|
+
setup.mockInput.pressKey("RETURN", { meta: true });
|
|
622
|
+
await settle(setup);
|
|
623
|
+
|
|
624
|
+
// Assert
|
|
625
|
+
expect(annotate).toHaveBeenCalledTimes(1);
|
|
626
|
+
const [span, body] = annotate.mock.calls[0]!;
|
|
627
|
+
|
|
628
|
+
expect(span).toEqual({
|
|
629
|
+
start: { blockIndex: STANZA_BLOCK, char: STANZA.indexOf("torn") },
|
|
630
|
+
end: { blockIndex: FIRST_BULLET_BLOCK, char: 8 },
|
|
631
|
+
});
|
|
632
|
+
expect(body).toBe("one comment for both");
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("a saved spanning comment paints every block it covers and renders one card", async () => {
|
|
636
|
+
// Arrange: an anchor from "torn" to "store.ts", as the daemon would hold it
|
|
637
|
+
setup.renderer.destroy();
|
|
638
|
+
const blocks = parseBlocks(PLAN);
|
|
639
|
+
const anchor = makeAnchor(blocks, STANZA_BLOCK, STANZA.indexOf("torn"), 8, FIRST_BULLET_BLOCK);
|
|
640
|
+
|
|
641
|
+
await mount([
|
|
642
|
+
{
|
|
643
|
+
id: "span_1",
|
|
644
|
+
kind: "comment",
|
|
645
|
+
anchor,
|
|
646
|
+
body: "both at once",
|
|
647
|
+
createdAt: "2026-09-01T10:00:00Z",
|
|
648
|
+
},
|
|
649
|
+
]);
|
|
650
|
+
|
|
651
|
+
// Assert
|
|
652
|
+
const rows = highlightedByRow();
|
|
653
|
+
const torn = rowOf("torn file");
|
|
654
|
+
const bullet = rowOf(BULLETS[0]!);
|
|
655
|
+
|
|
656
|
+
expect(rows.get(torn.row)).toBe("torn file on disk.");
|
|
657
|
+
expect(rows.get(bullet.row)).toBe("store.ts");
|
|
658
|
+
expect(rows.size).toBe(2);
|
|
659
|
+
const frame = setup.captureCharFrame();
|
|
660
|
+
|
|
661
|
+
expect(frame.split("● both at once").length - 1).toBe(1);
|
|
662
|
+
expect(frame.split("\n").findIndex((line) => line.includes("● both at once"))).toBeGreaterThan(
|
|
663
|
+
bullet.row,
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
describe("marks stay put around cards", () => {
|
|
669
|
+
const seeded: Annotation[] = [
|
|
670
|
+
{
|
|
671
|
+
id: "own_1",
|
|
672
|
+
kind: "comment",
|
|
673
|
+
anchor: makeAnchor(
|
|
674
|
+
parseBlocks(PLAN),
|
|
675
|
+
1,
|
|
676
|
+
PARAGRAPH.indexOf("persists"),
|
|
677
|
+
PARAGRAPH.indexOf("persists") + "persists".length,
|
|
678
|
+
),
|
|
679
|
+
body: "which daemon?",
|
|
680
|
+
createdAt: "2026-09-01T10:00:00Z",
|
|
681
|
+
},
|
|
682
|
+
];
|
|
683
|
+
|
|
684
|
+
beforeEach(async () => {
|
|
685
|
+
setup.renderer.destroy();
|
|
686
|
+
await mount(seeded);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("clicking a card after placing the caret in another block marks nothing new", async () => {
|
|
690
|
+
// Arrange: a bare caret inside the first bullet
|
|
691
|
+
const bullet = locate(BULLETS[0]!);
|
|
692
|
+
const card = locate("● which daemon?");
|
|
693
|
+
|
|
694
|
+
await setup.mockMouse.click(bullet.column + 20, bullet.row);
|
|
695
|
+
await settle(setup);
|
|
696
|
+
expect(highlightedText()).toBe("persists");
|
|
697
|
+
|
|
698
|
+
// Act: focus the card, which lives under the paragraph
|
|
699
|
+
await setup.mockMouse.click(card.column + 4, card.row);
|
|
700
|
+
await settle(setup);
|
|
701
|
+
await settle(setup);
|
|
702
|
+
|
|
703
|
+
// Assert: only the thread's own mark is painted
|
|
704
|
+
expect(highlightedByRow().size).toBe(1);
|
|
705
|
+
expect(highlightedText()).toBe("persists");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("dragging inside a card's text marks nothing in the document", async () => {
|
|
709
|
+
// Arrange
|
|
710
|
+
const bullet = locate(BULLETS[0]!);
|
|
711
|
+
const card = locate("● which daemon?");
|
|
712
|
+
|
|
713
|
+
await setup.mockMouse.click(bullet.column + 20, bullet.row);
|
|
714
|
+
await settle(setup);
|
|
715
|
+
|
|
716
|
+
// Act: a drag across the comment text
|
|
717
|
+
await setup.mockMouse.drag(card.column + 2, card.row, card.column + 12, card.row);
|
|
718
|
+
await settle(setup);
|
|
719
|
+
await settle(setup);
|
|
720
|
+
|
|
721
|
+
// Assert
|
|
722
|
+
expect(highlightedText()).toBe("persists");
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("jumping to a discussion with cmd+] after placing the caret elsewhere marks nothing new", async () => {
|
|
726
|
+
// Arrange
|
|
727
|
+
const bullet = locate(BULLETS[1]!);
|
|
728
|
+
|
|
729
|
+
await setup.mockMouse.click(bullet.column + 5, bullet.row);
|
|
730
|
+
await settle(setup);
|
|
731
|
+
|
|
732
|
+
// Act
|
|
733
|
+
setup.mockInput.pressKey("]", { meta: true });
|
|
734
|
+
await settle(setup);
|
|
735
|
+
await settle(setup);
|
|
736
|
+
|
|
737
|
+
// Assert
|
|
738
|
+
expect(highlightedText()).toBe("persists");
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
describe("the idle caret cell", () => {
|
|
743
|
+
test("a click paints one steady caret cell under the pointer", async () => {
|
|
744
|
+
// Arrange + Act
|
|
745
|
+
const { row, column } = locate("sessions");
|
|
746
|
+
|
|
747
|
+
await setup.mockMouse.click(column + 2, row);
|
|
748
|
+
await settle(setup);
|
|
749
|
+
|
|
750
|
+
// Assert
|
|
751
|
+
expect(caretCells()).toEqual([{ row, column: column + 2, text: "s" }]);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("after a forward drag the caret cell sits right after the mark", async () => {
|
|
755
|
+
// Arrange + Act: mark "emon per"
|
|
756
|
+
const { row, column } = locate("daemon");
|
|
757
|
+
|
|
758
|
+
await setup.mockMouse.drag(column + 2, row, column + 10, row);
|
|
759
|
+
await settle(setup);
|
|
760
|
+
|
|
761
|
+
// Assert: the mark is intact and the cell after it is the caret
|
|
762
|
+
expect(highlightedText()).toBe("emon per");
|
|
763
|
+
expect(caretCells()).toEqual([{ row, column: column + 10, text: "s" }]);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("after a backward drag the caret cell shows through the mark's first character", async () => {
|
|
767
|
+
// Arrange + Act
|
|
768
|
+
const { row, column } = locate("daemon");
|
|
769
|
+
|
|
770
|
+
await setup.mockMouse.drag(column + 10, row, column + 2, row);
|
|
771
|
+
await settle(setup);
|
|
772
|
+
|
|
773
|
+
// Assert
|
|
774
|
+
expect(highlightedText()).toBe("emon per");
|
|
775
|
+
expect(caretCells()).toEqual([{ row, column: column + 2, text: "e" }]);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("an open card takes the cursor: no caret cell and no placeholder in the text", async () => {
|
|
779
|
+
// Arrange
|
|
780
|
+
const { row, column } = locate("daemon");
|
|
781
|
+
|
|
782
|
+
await setup.mockMouse.click(column + 2, row);
|
|
783
|
+
await settle(setup);
|
|
784
|
+
expect(caretCells()).toHaveLength(1);
|
|
785
|
+
|
|
786
|
+
// Act
|
|
787
|
+
await typeText(setup, "x");
|
|
788
|
+
|
|
789
|
+
// Assert
|
|
790
|
+
expect(caretCells()).toHaveLength(0);
|
|
791
|
+
expect(setup.captureCharFrame()).not.toContain("comment...");
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
describe("discussion identity", () => {
|
|
796
|
+
const blocks = parseBlocks(PLAN);
|
|
797
|
+
const rootAnchor = makeAnchor(
|
|
798
|
+
blocks,
|
|
799
|
+
1,
|
|
800
|
+
PARAGRAPH.indexOf("persists"),
|
|
801
|
+
PARAGRAPH.indexOf("persists") + "persists".length,
|
|
802
|
+
);
|
|
803
|
+
const root: Annotation = {
|
|
804
|
+
id: "root",
|
|
805
|
+
kind: "comment",
|
|
806
|
+
anchor: rootAnchor,
|
|
807
|
+
body: "which daemon?",
|
|
808
|
+
createdAt: "2026-09-01T10:00:00Z",
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
test("a reply joins its root comment's card by replyTo, and a same-span legacy note still groups", async () => {
|
|
812
|
+
// Arrange
|
|
813
|
+
setup.renderer.destroy();
|
|
814
|
+
await mount([
|
|
815
|
+
root,
|
|
816
|
+
{
|
|
817
|
+
id: "reply",
|
|
818
|
+
kind: "comment",
|
|
819
|
+
anchor: rootAnchor,
|
|
820
|
+
body: "the local one",
|
|
821
|
+
author: "SHA256:ana",
|
|
822
|
+
replyTo: "root",
|
|
823
|
+
createdAt: "2026-09-01T10:01:00Z",
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
id: "legacy",
|
|
827
|
+
kind: "comment",
|
|
828
|
+
anchor: rootAnchor,
|
|
829
|
+
body: "same span, no reply link",
|
|
830
|
+
author: "SHA256:bob",
|
|
831
|
+
createdAt: "2026-09-01T09:59:00Z",
|
|
832
|
+
},
|
|
833
|
+
]);
|
|
834
|
+
|
|
835
|
+
// Assert: one card, voices in time order
|
|
836
|
+
const rows = setup.captureCharFrame().split("\n");
|
|
837
|
+
const voices = rows
|
|
838
|
+
.map((row) => row.match(/│ ([●○] .*?)\s+│/)?.[1])
|
|
839
|
+
.filter((voice): voice is string => voice !== undefined);
|
|
840
|
+
|
|
841
|
+
expect(voices).toEqual(["○ same span, no reply link", "● which daemon?", "○ the local one"]);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test("enter on a discussion replies to its root comment", async () => {
|
|
845
|
+
// Arrange
|
|
846
|
+
setup.renderer.destroy();
|
|
847
|
+
await mount([root]);
|
|
848
|
+
const card = locate("● which daemon?");
|
|
849
|
+
|
|
850
|
+
await setup.mockMouse.click(card.column + 4, card.row);
|
|
851
|
+
await settle(setup);
|
|
852
|
+
|
|
853
|
+
// Act
|
|
854
|
+
setup.mockInput.pressKey("RETURN");
|
|
855
|
+
await settle(setup);
|
|
856
|
+
await typeText(setup, "agreed");
|
|
857
|
+
setup.mockInput.pressKey("RETURN", { meta: true });
|
|
858
|
+
await settle(setup);
|
|
859
|
+
|
|
860
|
+
// Assert
|
|
861
|
+
expect(reply).toHaveBeenCalledTimes(1);
|
|
862
|
+
expect(reply.mock.calls[0]).toEqual(["root", "agreed"]);
|
|
863
|
+
expect(annotate).not.toHaveBeenCalled();
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
describe("a new comment sorts into place while typing", () => {
|
|
868
|
+
const blocks = parseBlocks(PLAN);
|
|
869
|
+
const anchorOn = (word: string): Annotation["anchor"] =>
|
|
870
|
+
makeAnchor(blocks, 1, PARAGRAPH.indexOf(word), PARAGRAPH.indexOf(word) + word.length);
|
|
871
|
+
const early: Annotation = {
|
|
872
|
+
id: "early",
|
|
873
|
+
kind: "comment",
|
|
874
|
+
anchor: anchorOn("persists"),
|
|
875
|
+
body: "alpha-note",
|
|
876
|
+
createdAt: "2026-09-01T10:00:00Z",
|
|
877
|
+
};
|
|
878
|
+
const late: Annotation = {
|
|
879
|
+
id: "late",
|
|
880
|
+
kind: "comment",
|
|
881
|
+
anchor: anchorOn("atomically"),
|
|
882
|
+
body: "omega-note",
|
|
883
|
+
createdAt: "2026-09-01T10:01:00Z",
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
test("a draft anchored between two comments renders between them, not appended to the stack", async () => {
|
|
887
|
+
// Arrange - two comments on the same line, one earlier and one later than the draft's word
|
|
888
|
+
setup.renderer.destroy();
|
|
889
|
+
await mount([early, late]);
|
|
890
|
+
|
|
891
|
+
// Act - mark a word between them and start typing a new comment
|
|
892
|
+
const sessions = locate("sessions");
|
|
893
|
+
|
|
894
|
+
await setup.mockMouse.doubleClick(sessions.column + 2, sessions.row);
|
|
895
|
+
await settle(setup);
|
|
896
|
+
await typeText(setup, "midway-note");
|
|
897
|
+
await settle(setup);
|
|
898
|
+
|
|
899
|
+
// Assert - the draft sits where it will land on save (by span start), not at the end
|
|
900
|
+
const rows = setup.captureCharFrame().split("\n");
|
|
901
|
+
const rowOf = (needle: string): number => rows.findIndex((row) => row.includes(needle));
|
|
902
|
+
|
|
903
|
+
expect(rowOf("alpha-note")).toBeGreaterThanOrEqual(0);
|
|
904
|
+
expect(rowOf("midway-note")).toBeGreaterThan(rowOf("alpha-note"));
|
|
905
|
+
expect(rowOf("omega-note")).toBeGreaterThan(rowOf("midway-note"));
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
describe("virtualization guard", () => {
|
|
910
|
+
// The performance guard: the thread view must word-wrap only the blocks in the viewport, not the
|
|
911
|
+
// whole document. Deterministic (a call count, not a wall-clock), so it fails on any machine if a
|
|
912
|
+
// future change un-virtualizes the view - the regression the nav-latency benchmark would show.
|
|
913
|
+
test("a large plan word-wraps only the on-screen blocks, not every block", async () => {
|
|
914
|
+
// Arrange - a plan far taller than the viewport
|
|
915
|
+
const paragraphs = Array.from({ length: 800 }, (_, index) => `Paragraph ${index} with words.`);
|
|
916
|
+
const bigPlan = `# Big Plan\n\n${paragraphs.join("\n\n")}\n`;
|
|
917
|
+
const display = buildDisplay(bigPlan, undefined);
|
|
918
|
+
const bigSession = fixturePlanSession({
|
|
919
|
+
artifact: { type: "plan", content: bigPlan, meta: { title: "Big", planPath: "big.md" } },
|
|
920
|
+
annotations: [],
|
|
921
|
+
});
|
|
922
|
+
const spy = spyOn(markRuns, "wrapLines");
|
|
923
|
+
|
|
924
|
+
try {
|
|
925
|
+
// Act
|
|
926
|
+
const bigSetup = await testRender(
|
|
927
|
+
<ThreadView
|
|
928
|
+
session={bigSession}
|
|
929
|
+
display={display}
|
|
930
|
+
marks={new Map()}
|
|
931
|
+
quickActions={DEFAULT_QUICK_ACTIONS}
|
|
932
|
+
observer={false}
|
|
933
|
+
onAnnotate={() => {}}
|
|
934
|
+
onReply={() => {}}
|
|
935
|
+
onUpdateAnnotation={() => {}}
|
|
936
|
+
onExit={() => {}}
|
|
937
|
+
/>,
|
|
938
|
+
{ width: 64, height: 24 },
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
await settle(bigSetup);
|
|
942
|
+
|
|
943
|
+
// Assert - across the mount's renders the view never wraps a block for each of the 800; a
|
|
944
|
+
// viewport-worth per render stays far under one full pass over the document
|
|
945
|
+
expect(spy.mock.calls.length).toBeLessThan(display.length);
|
|
946
|
+
bigSetup.renderer.destroy();
|
|
947
|
+
} finally {
|
|
948
|
+
spy.mockRestore();
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
});
|