@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.91
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 +1406 -1060
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +787 -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 +29 -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 +78 -0
- package/src/launch-layout.ts +62 -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,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "/" quick-action palette behind a composer: the skill list a leading
|
|
3
|
+
* slash opens, its prefix > substring > subsequence filter, and the inline
|
|
4
|
+
* "/word" completion offered mid-sentence. Pure; the surfaces render it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { quickActionBody, type QuickAction } from "./config";
|
|
8
|
+
|
|
9
|
+
export interface SlashItem {
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
body: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function slashItemsFrom(quickActions: QuickAction[]): SlashItem[] {
|
|
16
|
+
return quickActions.map((action) => ({
|
|
17
|
+
name: action.prompt
|
|
18
|
+
.toLowerCase()
|
|
19
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
20
|
+
.replace(/^-|-$/g, ""),
|
|
21
|
+
description: action.metadata ?? action.prompt,
|
|
22
|
+
body: quickActionBody(action),
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const WORD_BOUNDARY = /[\s\-_./:]/;
|
|
27
|
+
|
|
28
|
+
/** Order-preserving subsequence score, or null when a query character is missing; higher is better. */
|
|
29
|
+
export function scoreMatch(name: string, query: string): number | null {
|
|
30
|
+
if (query.length === 0) return 0;
|
|
31
|
+
const lowerName = name.toLowerCase();
|
|
32
|
+
const lowerQuery = query.toLowerCase();
|
|
33
|
+
|
|
34
|
+
if (lowerName === lowerQuery) return 1000;
|
|
35
|
+
if (lowerName.startsWith(lowerQuery)) return 500 - name.length;
|
|
36
|
+
let score = 0;
|
|
37
|
+
let cursor = 0;
|
|
38
|
+
let run = 0;
|
|
39
|
+
|
|
40
|
+
for (const character of lowerQuery) {
|
|
41
|
+
const found = lowerName.indexOf(character, cursor);
|
|
42
|
+
|
|
43
|
+
if (found === -1) return null;
|
|
44
|
+
run = found === cursor ? run + 1 : 0;
|
|
45
|
+
score += found === cursor ? 5 + run : 1;
|
|
46
|
+
if (found === 0 || WORD_BOUNDARY.test(lowerName[found - 1]!)) score += 10;
|
|
47
|
+
score -= found - cursor;
|
|
48
|
+
cursor = found + 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return score;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function slashFilter(items: SlashItem[], query: string): SlashItem[] {
|
|
55
|
+
if (query.length === 0) return items;
|
|
56
|
+
const scored: Array<{ item: SlashItem; score: number }> = [];
|
|
57
|
+
|
|
58
|
+
for (const item of items) {
|
|
59
|
+
const score = scoreMatch(item.name, query);
|
|
60
|
+
|
|
61
|
+
if (score !== null) scored.push({ item, score });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return scored.toSorted((left, right) => right.score - left.score).map((entry) => entry.item);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// a skill or action name: the grammar loadSkills and slashItemsFrom can produce, so a "/name"
|
|
68
|
+
// token closes only on whitespace or another slash, never inside a valid underscore-or-hyphen name
|
|
69
|
+
const SLASH_TOKEN = /(?:^|\s)(\/[a-zA-Z0-9_-]*)$/;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The "/word" token the caret is writing right now: the run from the last
|
|
73
|
+
* whitespace up to the caret, at the start of the draft or after it. Null once a
|
|
74
|
+
* space closes the token, so each new "/" reopens the palette and skills chain.
|
|
75
|
+
* Caret-aware, so editing an earlier "/name" targets it, not a trailing token.
|
|
76
|
+
*/
|
|
77
|
+
export function activeSlashToken(text: string, caret: number): string | null {
|
|
78
|
+
const head = text.slice(0, Math.max(0, Math.min(caret, text.length)));
|
|
79
|
+
const match = SLASH_TOKEN.exec(head);
|
|
80
|
+
|
|
81
|
+
return match ? match[1]! : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** True while the whole draft is just a "/query" - the palette owns it, so a blur discards it. */
|
|
85
|
+
export function isStandaloneSlashQuery(text: string): boolean {
|
|
86
|
+
return /^\/\S*$/.test(text.trim());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function insertSlashItem(text: string, caret: number, itemName: string) {
|
|
90
|
+
const token = activeSlashToken(text, caret) ?? "";
|
|
91
|
+
const cut = caret - token.length;
|
|
92
|
+
const insertion = `/${itemName} `;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
text: text.slice(0, cut) + insertion + text.slice(caret),
|
|
96
|
+
caret: cut + insertion.length,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The character ranges of every completed "/name" in the draft that names a real
|
|
102
|
+
* action or skill, so a surface can paint the references. A half-typed "/na" is
|
|
103
|
+
* not a reference yet, so it stays unpainted until the palette completes it.
|
|
104
|
+
*/
|
|
105
|
+
export function skillReferenceRanges(
|
|
106
|
+
text: string,
|
|
107
|
+
names: ReadonlySet<string>,
|
|
108
|
+
): Array<{ start: number; end: number }> {
|
|
109
|
+
const ranges: Array<{ start: number; end: number }> = [];
|
|
110
|
+
const pattern = /(?:^|\s)(\/[a-zA-Z0-9_-]+)/g;
|
|
111
|
+
|
|
112
|
+
for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
|
|
113
|
+
const token = match[1]!;
|
|
114
|
+
|
|
115
|
+
if (!names.has(token.slice(1))) continue;
|
|
116
|
+
const start = match.index + match[0].length - token.length;
|
|
117
|
+
|
|
118
|
+
ranges.push({ start, end: start + token.length });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return ranges;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Quick actions and user skills in one palette; a quick action wins a name collision (it expands). */
|
|
125
|
+
export function mergeSlashItems(actions: SlashItem[], skills: SlashItem[]): SlashItem[] {
|
|
126
|
+
const taken = new Set(actions.map((action) => action.name));
|
|
127
|
+
|
|
128
|
+
return [...actions, ...skills.filter((skill) => !taken.has(skill.name))];
|
|
129
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { diffRows } from "./view-diff";
|
|
3
|
+
import { splitDiffRows, splitRowOffsets, type SplitRow } from "./split-diff";
|
|
4
|
+
|
|
5
|
+
const PATCH = `diff --git a/greet.ts b/greet.ts
|
|
6
|
+
--- a/greet.ts
|
|
7
|
+
+++ b/greet.ts
|
|
8
|
+
@@ -1,4 +1,4 @@
|
|
9
|
+
const name = "world";
|
|
10
|
+
-console.log("hi " + name);
|
|
11
|
+
-console.log("done");
|
|
12
|
+
+console.log("hello " + name);
|
|
13
|
+
+console.log("finished");
|
|
14
|
+
export {};
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
function pairs(rows: SplitRow[]): SplitRow[] {
|
|
18
|
+
return rows.filter((row) => row.kind === "pair");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("splitDiffRows", () => {
|
|
22
|
+
test("carries the file and hunk headers through unchanged", () => {
|
|
23
|
+
// Act
|
|
24
|
+
const split = splitDiffRows(diffRows(PATCH));
|
|
25
|
+
|
|
26
|
+
// Assert
|
|
27
|
+
expect(split[0]).toMatchObject({ kind: "file", file: "greet.ts", text: "greet.ts" });
|
|
28
|
+
expect(split[1]?.kind).toBe("hunk");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a context line sits on both sides with its own line numbers", () => {
|
|
32
|
+
// Act
|
|
33
|
+
const first = pairs(splitDiffRows(diffRows(PATCH)))[0]!;
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
36
|
+
expect(first.left).toMatchObject({ kind: "ctx", lineNumber: 1 });
|
|
37
|
+
expect(first.right).toMatchObject({ kind: "ctx", lineNumber: 1 });
|
|
38
|
+
expect(first.left?.text).toBe(first.right?.text);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a balanced change block pairs each deletion against an addition", () => {
|
|
42
|
+
// Act - the two deletions zip against the two additions, one per row
|
|
43
|
+
const changed = pairs(splitDiffRows(diffRows(PATCH))).filter(
|
|
44
|
+
(row) => row.left?.kind === "del" || row.right?.kind === "add",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(changed).toHaveLength(2);
|
|
49
|
+
expect(changed[0]).toMatchObject({
|
|
50
|
+
left: { kind: "del", lineNumber: 2 },
|
|
51
|
+
right: { kind: "add", lineNumber: 2 },
|
|
52
|
+
});
|
|
53
|
+
expect(changed[1]).toMatchObject({
|
|
54
|
+
left: { kind: "del", lineNumber: 3 },
|
|
55
|
+
right: { kind: "add", lineNumber: 3 },
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("an unbalanced block blank-fills the shorter side", () => {
|
|
60
|
+
// Arrange - one deletion, three additions
|
|
61
|
+
const patch = `diff --git a/x.ts b/x.ts
|
|
62
|
+
--- a/x.ts
|
|
63
|
+
+++ b/x.ts
|
|
64
|
+
@@ -1,2 +1,4 @@
|
|
65
|
+
keep;
|
|
66
|
+
-old;
|
|
67
|
+
+new1;
|
|
68
|
+
+new2;
|
|
69
|
+
+new3;
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
// Act
|
|
73
|
+
const changed = pairs(splitDiffRows(diffRows(patch))).filter(
|
|
74
|
+
(row) => row.left?.kind === "del" || row.right?.kind === "add",
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Assert - three rows tall, only the first has a left deletion
|
|
78
|
+
expect(changed).toHaveLength(3);
|
|
79
|
+
expect(changed[0]?.left?.kind).toBe("del");
|
|
80
|
+
expect(changed[1]?.left).toBeUndefined();
|
|
81
|
+
expect(changed[2]?.left).toBeUndefined();
|
|
82
|
+
expect(changed.map((row) => row.right?.text.trim())).toEqual(["new1;", "new2;", "new3;"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("each side keeps its originating unified row for anchoring", () => {
|
|
86
|
+
// Act
|
|
87
|
+
const base = diffRows(PATCH);
|
|
88
|
+
const changed = pairs(splitDiffRows(base)).find((row) => row.right?.kind === "add")!;
|
|
89
|
+
|
|
90
|
+
// Assert - the split line points back at a row from the base list
|
|
91
|
+
expect(base[changed.right!.rowIndex]).toBe(changed.right!.row);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("splitRowOffsets", () => {
|
|
96
|
+
test("maps both sides of a pair to the same y and steps a file band by three", () => {
|
|
97
|
+
// Arrange
|
|
98
|
+
const base = diffRows(PATCH);
|
|
99
|
+
const split = splitDiffRows(base);
|
|
100
|
+
|
|
101
|
+
// Act
|
|
102
|
+
const offsets = splitRowOffsets(split);
|
|
103
|
+
|
|
104
|
+
// Assert - the file band is three rows and the hunk one, so the first context pair sits at y 4
|
|
105
|
+
const firstContext = split.find((row) => row.kind === "pair" && row.left?.kind === "ctx")!;
|
|
106
|
+
|
|
107
|
+
expect(offsets.get(firstContext.left!.rowIndex)).toBe(4);
|
|
108
|
+
expect(offsets.get(firstContext.right!.rowIndex)).toBe(4);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side-by-side diff projection: fold the unified DiffRow list into paired rows,
|
|
3
|
+
* old on the left and new on the right. A context line sits on both sides; a
|
|
4
|
+
* change block zips its deletions against its additions to the taller side, with
|
|
5
|
+
* a blank filler where one side runs out. Each side keeps its originating unified
|
|
6
|
+
* row, so the cursor, anchors, and curation keep working through the base rows.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DiffRow } from "./view-diff";
|
|
10
|
+
|
|
11
|
+
/** One code line on one side of the split, carrying its originating unified row. */
|
|
12
|
+
export interface SplitLine {
|
|
13
|
+
/** The unified diff row this side renders; the anchor and curation target. */
|
|
14
|
+
row: DiffRow;
|
|
15
|
+
/** Index of the originating row in the base list; the cursor and curation index. */
|
|
16
|
+
rowIndex: number;
|
|
17
|
+
kind: "ctx" | "del" | "add";
|
|
18
|
+
text: string;
|
|
19
|
+
/** Old line number for the left column, new line number for the right column. */
|
|
20
|
+
lineNumber?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A rendered split row: a file band, a hunk header, or an old|new line pair. */
|
|
24
|
+
export interface SplitRow {
|
|
25
|
+
kind: "file" | "hunk" | "pair";
|
|
26
|
+
file: string;
|
|
27
|
+
/** Header text for file and hunk rows. */
|
|
28
|
+
text?: string;
|
|
29
|
+
/** Index of the header in the base list, for file and hunk rows. */
|
|
30
|
+
rowIndex?: number;
|
|
31
|
+
/** Old side: a context or deletion line, or absent (blank filler). */
|
|
32
|
+
left?: SplitLine;
|
|
33
|
+
/** New side: a context or addition line, or absent (blank filler). */
|
|
34
|
+
right?: SplitLine;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A base row paired with its index in the base list. */
|
|
38
|
+
interface IndexedRow {
|
|
39
|
+
row: DiffRow;
|
|
40
|
+
index: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function contextLine({ row, index }: IndexedRow, side: "left" | "right"): SplitLine {
|
|
44
|
+
return {
|
|
45
|
+
row,
|
|
46
|
+
rowIndex: index,
|
|
47
|
+
kind: "ctx",
|
|
48
|
+
text: row.text,
|
|
49
|
+
lineNumber: side === "left" ? row.oldLine : row.newLine,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function deletionLine({ row, index }: IndexedRow): SplitLine {
|
|
54
|
+
return { row, rowIndex: index, kind: "del", text: row.text, lineNumber: row.oldLine };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function additionLine({ row, index }: IndexedRow): SplitLine {
|
|
58
|
+
return { row, rowIndex: index, kind: "add", text: row.text, lineNumber: row.newLine };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Zip a change block's deletions against its additions, blank-filling the shorter side. */
|
|
62
|
+
function zipChangeBlock(
|
|
63
|
+
deletions: IndexedRow[],
|
|
64
|
+
additions: IndexedRow[],
|
|
65
|
+
file: string,
|
|
66
|
+
): SplitRow[] {
|
|
67
|
+
const pairs: SplitRow[] = [];
|
|
68
|
+
const height = Math.max(deletions.length, additions.length);
|
|
69
|
+
|
|
70
|
+
for (let index = 0; index < height; index++) {
|
|
71
|
+
const del = deletions[index];
|
|
72
|
+
const add = additions[index];
|
|
73
|
+
|
|
74
|
+
pairs.push({
|
|
75
|
+
kind: "pair",
|
|
76
|
+
file,
|
|
77
|
+
left: del ? deletionLine(del) : undefined,
|
|
78
|
+
right: add ? additionLine(add) : undefined,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return pairs;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Project unified diff rows into side-by-side split rows. */
|
|
86
|
+
export function splitDiffRows(rows: DiffRow[]): SplitRow[] {
|
|
87
|
+
const split: SplitRow[] = [];
|
|
88
|
+
let pendingDeletions: IndexedRow[] = [];
|
|
89
|
+
let pendingAdditions: IndexedRow[] = [];
|
|
90
|
+
let blockFile = "";
|
|
91
|
+
|
|
92
|
+
const flushChangeBlock = (): void => {
|
|
93
|
+
if (pendingDeletions.length === 0 && pendingAdditions.length === 0) return;
|
|
94
|
+
split.push(...zipChangeBlock(pendingDeletions, pendingAdditions, blockFile));
|
|
95
|
+
pendingDeletions = [];
|
|
96
|
+
pendingAdditions = [];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
rows.forEach((row, index) => {
|
|
100
|
+
if (row.kind === "add") {
|
|
101
|
+
blockFile = row.file;
|
|
102
|
+
pendingAdditions.push({ row, index });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (row.kind === "del") {
|
|
106
|
+
blockFile = row.file;
|
|
107
|
+
pendingDeletions.push({ row, index });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// any non-change row closes the current change block before it renders
|
|
111
|
+
flushChangeBlock();
|
|
112
|
+
if (row.kind === "file" || row.kind === "hunk") {
|
|
113
|
+
split.push({ kind: row.kind, file: row.file, text: row.text, rowIndex: index });
|
|
114
|
+
} else
|
|
115
|
+
split.push({
|
|
116
|
+
kind: "pair",
|
|
117
|
+
file: row.file,
|
|
118
|
+
left: contextLine({ row, index }, "left"),
|
|
119
|
+
right: contextLine({ row, index }, "right"),
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
flushChangeBlock();
|
|
123
|
+
|
|
124
|
+
return split;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Visual y of each base row index in the split layout, so the cursor scrolls into
|
|
129
|
+
* view. A file band spans three rows (rule, name, rule), a hunk one, a pair one; a
|
|
130
|
+
* pair's two sides share the row, so both base indices map to the same y.
|
|
131
|
+
*/
|
|
132
|
+
export function splitRowOffsets(splitRows: SplitRow[]): Map<number, number> {
|
|
133
|
+
const offsets = new Map<number, number>();
|
|
134
|
+
let contentY = 0;
|
|
135
|
+
|
|
136
|
+
for (const row of splitRows) {
|
|
137
|
+
if (row.kind === "file") {
|
|
138
|
+
contentY += 3;
|
|
139
|
+
} else if (row.kind === "hunk") {
|
|
140
|
+
contentY += 1;
|
|
141
|
+
} else {
|
|
142
|
+
if (row.left) offsets.set(row.left.rowIndex, contentY);
|
|
143
|
+
if (row.right) offsets.set(row.right.rowIndex, contentY);
|
|
144
|
+
contentY += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return offsets;
|
|
149
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createElement, type ReactElement } from "react";
|
|
6
|
+
import type { KeyEvent, MouseEvent } from "@opentui/core";
|
|
7
|
+
import type { TestRendererSetup } from "@opentui/core/testing";
|
|
8
|
+
import { useKeyboard } from "@opentui/react";
|
|
9
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
10
|
+
import { loadConfig } from "./config";
|
|
11
|
+
import {
|
|
12
|
+
clickText,
|
|
13
|
+
dragText,
|
|
14
|
+
frameRow,
|
|
15
|
+
isolateUserConfig,
|
|
16
|
+
locateText,
|
|
17
|
+
locateTextInFrame,
|
|
18
|
+
pressKey,
|
|
19
|
+
waitForText,
|
|
20
|
+
type FrameLocation,
|
|
21
|
+
} from "./test-support";
|
|
22
|
+
|
|
23
|
+
describe("isolateUserConfig", () => {
|
|
24
|
+
test("loadConfig sees defaults while isolated and the env is restored after", () => {
|
|
25
|
+
// Arrange
|
|
26
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-isolate-"));
|
|
27
|
+
const userConfigPath = join(home, "config.toml");
|
|
28
|
+
|
|
29
|
+
writeFileSync(userConfigPath, "[ui]\nauto_close = 5\n");
|
|
30
|
+
const priorEnv = process.env.CUELOOP_CONFIG;
|
|
31
|
+
|
|
32
|
+
process.env.CUELOOP_CONFIG = userConfigPath;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
// Act
|
|
36
|
+
const restoreUserConfig = isolateUserConfig(home);
|
|
37
|
+
const isolated = loadConfig({ repoRoot: home });
|
|
38
|
+
|
|
39
|
+
restoreUserConfig();
|
|
40
|
+
const restored = loadConfig({ repoRoot: home });
|
|
41
|
+
|
|
42
|
+
// Assert
|
|
43
|
+
expect(isolated.ui.autoClose).toBe("off");
|
|
44
|
+
expect(process.env.CUELOOP_CONFIG).toBe(userConfigPath);
|
|
45
|
+
expect(restored.ui.autoClose).toBe(5);
|
|
46
|
+
} finally {
|
|
47
|
+
if (priorEnv === undefined) delete process.env.CUELOOP_CONFIG;
|
|
48
|
+
else process.env.CUELOOP_CONFIG = priorEnv;
|
|
49
|
+
rmSync(home, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("restore removes the variable when nothing was set before", () => {
|
|
54
|
+
// Arrange
|
|
55
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-isolate-"));
|
|
56
|
+
const priorEnv = process.env.CUELOOP_CONFIG;
|
|
57
|
+
|
|
58
|
+
delete process.env.CUELOOP_CONFIG;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Act
|
|
62
|
+
const restoreUserConfig = isolateUserConfig(home);
|
|
63
|
+
const pointedAt = process.env.CUELOOP_CONFIG ?? "";
|
|
64
|
+
|
|
65
|
+
restoreUserConfig();
|
|
66
|
+
|
|
67
|
+
// Assert
|
|
68
|
+
expect(pointedAt).toBe(join(home, "no-config.toml"));
|
|
69
|
+
expect(process.env.CUELOOP_CONFIG).toBeUndefined();
|
|
70
|
+
} finally {
|
|
71
|
+
if (priorEnv !== undefined) process.env.CUELOOP_CONFIG = priorEnv;
|
|
72
|
+
rmSync(home, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
interface RecordedKey {
|
|
78
|
+
name: string;
|
|
79
|
+
ctrl: boolean;
|
|
80
|
+
shift: boolean;
|
|
81
|
+
meta: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface RecordedMouseEvent {
|
|
85
|
+
type: "down" | "up";
|
|
86
|
+
x: number;
|
|
87
|
+
y: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface FixtureLog {
|
|
91
|
+
keys: RecordedKey[];
|
|
92
|
+
mouseEvents: RecordedMouseEvent[];
|
|
93
|
+
pressZoneClicks: number[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A minimal interaction surface: one keyboard recorder, a root box recording
|
|
98
|
+
* down/up coordinates, and stacked text rows at known positions.
|
|
99
|
+
*/
|
|
100
|
+
function InteractionFixture(properties: { log: FixtureLog }): ReactElement {
|
|
101
|
+
const { log } = properties;
|
|
102
|
+
|
|
103
|
+
useKeyboard((key: KeyEvent) => {
|
|
104
|
+
log.keys.push({ name: key.name, ctrl: key.ctrl, shift: key.shift, meta: key.meta });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return createElement(
|
|
108
|
+
"box",
|
|
109
|
+
{
|
|
110
|
+
width: "100%",
|
|
111
|
+
height: "100%",
|
|
112
|
+
flexDirection: "column",
|
|
113
|
+
onMouseDown: (event: MouseEvent) => {
|
|
114
|
+
log.mouseEvents.push({ type: "down", x: event.x, y: event.y });
|
|
115
|
+
},
|
|
116
|
+
onMouseUp: (event: MouseEvent) => {
|
|
117
|
+
log.mouseEvents.push({ type: "up", x: event.x, y: event.y });
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
createElement("text", { content: "header line" }),
|
|
121
|
+
createElement("text", { content: " shifted needle" }),
|
|
122
|
+
createElement("text", { content: " drag from here" }),
|
|
123
|
+
createElement("text", { content: " drop to here" }),
|
|
124
|
+
createElement("text", {
|
|
125
|
+
content: "press zone",
|
|
126
|
+
onMouseDown: (event: MouseEvent) => {
|
|
127
|
+
log.pressZoneClicks.push(event.x);
|
|
128
|
+
},
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function renderFixture(): Promise<{ setup: TestRendererSetup; log: FixtureLog }> {
|
|
134
|
+
const log: FixtureLog = { keys: [], mouseEvents: [], pressZoneClicks: [] };
|
|
135
|
+
const setup = await testRender(createElement(InteractionFixture, { log }), {
|
|
136
|
+
width: 40,
|
|
137
|
+
height: 8,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await waitForText(setup, "press zone");
|
|
141
|
+
|
|
142
|
+
return { setup, log };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe("pressKey", () => {
|
|
146
|
+
test("a ctrl+letter chord reaches the keyboard handler with ctrl set", async () => {
|
|
147
|
+
// Arrange
|
|
148
|
+
const { setup, log } = await renderFixture();
|
|
149
|
+
|
|
150
|
+
// Act
|
|
151
|
+
await pressKey(setup, "o", { ctrl: true });
|
|
152
|
+
|
|
153
|
+
// Assert
|
|
154
|
+
expect(log.keys).toEqual([{ name: "o", ctrl: true, shift: false, meta: false }]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a named key with a modifier arrives as the modified key", async () => {
|
|
158
|
+
// Arrange
|
|
159
|
+
const { setup, log } = await renderFixture();
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
await pressKey(setup, "ARROW_RIGHT", { shift: true });
|
|
163
|
+
|
|
164
|
+
// Assert
|
|
165
|
+
expect(log.keys).toEqual([{ name: "right", ctrl: false, shift: true, meta: false }]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("locateText", () => {
|
|
170
|
+
test("returns the 0-based row and start column of the first occurrence", async () => {
|
|
171
|
+
// Arrange
|
|
172
|
+
const { setup } = await renderFixture();
|
|
173
|
+
|
|
174
|
+
// Act
|
|
175
|
+
const located = locateText(setup, "shifted needle");
|
|
176
|
+
|
|
177
|
+
// Assert
|
|
178
|
+
expect(located).toEqual({ row: 1, column: 3 });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a miss throws with the needle and the full frame in the message", async () => {
|
|
182
|
+
// Arrange
|
|
183
|
+
const { setup } = await renderFixture();
|
|
184
|
+
|
|
185
|
+
// Act
|
|
186
|
+
const locateMissing = (): FrameLocation => locateText(setup, "absent needle");
|
|
187
|
+
|
|
188
|
+
// Assert
|
|
189
|
+
expect(locateMissing).toThrow('"absent needle"');
|
|
190
|
+
expect(locateMissing).toThrow("header line");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("frameRow", () => {
|
|
195
|
+
test("returns rows that reflect the vertical layout", async () => {
|
|
196
|
+
// Arrange
|
|
197
|
+
const { setup } = await renderFixture();
|
|
198
|
+
|
|
199
|
+
// Act / Assert
|
|
200
|
+
expect(frameRow(setup, "header line")).toBe(0);
|
|
201
|
+
expect(frameRow(setup, "drop to here")).toBe(3);
|
|
202
|
+
expect(frameRow(setup, "press zone")).toBe(4);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("clickText", () => {
|
|
207
|
+
test("lands the click on the element rendering the needle", async () => {
|
|
208
|
+
// Arrange
|
|
209
|
+
const { setup, log } = await renderFixture();
|
|
210
|
+
|
|
211
|
+
// Act
|
|
212
|
+
await clickText(setup, "press zone");
|
|
213
|
+
|
|
214
|
+
// Assert
|
|
215
|
+
expect(log.pressZoneClicks).toEqual([0]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("charOffset shifts the click within the needle", async () => {
|
|
219
|
+
// Arrange
|
|
220
|
+
const { setup, log } = await renderFixture();
|
|
221
|
+
|
|
222
|
+
// Act
|
|
223
|
+
await clickText(setup, "press zone", 6);
|
|
224
|
+
|
|
225
|
+
// Assert
|
|
226
|
+
expect(log.pressZoneClicks).toEqual([6]);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("dragText", () => {
|
|
231
|
+
test("delivers the down at the from-text and the up at the to-text", async () => {
|
|
232
|
+
// Arrange
|
|
233
|
+
const { setup, log } = await renderFixture();
|
|
234
|
+
|
|
235
|
+
// Act
|
|
236
|
+
await dragText(setup, "drag from here", "drop to here");
|
|
237
|
+
|
|
238
|
+
// Assert
|
|
239
|
+
expect(log.mouseEvents).toContainEqual({ type: "down", x: 2, y: 2 });
|
|
240
|
+
expect(log.mouseEvents).toContainEqual({ type: "up", x: 5, y: 3 });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("an end offset lands the up that many characters past the to-text", async () => {
|
|
244
|
+
// Arrange
|
|
245
|
+
const { setup, log } = await renderFixture();
|
|
246
|
+
|
|
247
|
+
// Act
|
|
248
|
+
await dragText(setup, "drag from here", "drop to here", 4);
|
|
249
|
+
|
|
250
|
+
// Assert
|
|
251
|
+
expect(log.mouseEvents).toContainEqual({ type: "up", x: 9, y: 3 });
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("locateTextInFrame", () => {
|
|
256
|
+
test("finds the 0-based row and column of the first match, null when absent", () => {
|
|
257
|
+
// Arrange
|
|
258
|
+
const frame = "header\n ● draft here\nfooter";
|
|
259
|
+
|
|
260
|
+
// Act + Assert
|
|
261
|
+
expect(locateTextInFrame(frame, "draft")).toEqual({ row: 1, column: 4 });
|
|
262
|
+
expect(locateTextInFrame(frame, "missing")).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
});
|