@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,210 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isCollapsed,
|
|
4
|
+
isDoubleClick,
|
|
5
|
+
nextWordStart,
|
|
6
|
+
orderedSpan,
|
|
7
|
+
positionAt,
|
|
8
|
+
previousWordStart,
|
|
9
|
+
snapSpanToWords,
|
|
10
|
+
snapToWords,
|
|
11
|
+
spanRangeInBlock,
|
|
12
|
+
tightenSpan,
|
|
13
|
+
wordRangeAt,
|
|
14
|
+
type LineGeometry,
|
|
15
|
+
} from "./thread-selection";
|
|
16
|
+
|
|
17
|
+
const TEXT = "All persistence lives in a new module.";
|
|
18
|
+
const BULLET = "store.ts - the store";
|
|
19
|
+
// block 3 wraps onto two rows, a blank row, then bullet block 4 on one row
|
|
20
|
+
const LINES: LineGeometry[] = [
|
|
21
|
+
{ blockIndex: 3, start: 0, end: 21, x: 4, y: 10 },
|
|
22
|
+
{ blockIndex: 3, start: 22, end: 38, x: 4, y: 11 },
|
|
23
|
+
{ blockIndex: 4, start: 0, end: 20, x: 6, y: 13 },
|
|
24
|
+
];
|
|
25
|
+
const textLengthOf = (blockIndex: number): number =>
|
|
26
|
+
blockIndex === 3 ? TEXT.length : BULLET.length;
|
|
27
|
+
|
|
28
|
+
describe("positionAt", () => {
|
|
29
|
+
test("maps a cell inside a line to the character under it", () => {
|
|
30
|
+
// Act
|
|
31
|
+
const position = positionAt(LINES, 4 + 6, 10);
|
|
32
|
+
|
|
33
|
+
// Assert: column 6 of the first line is the "r" in "persistence"
|
|
34
|
+
expect(position).toEqual({ blockIndex: 3, char: 6 });
|
|
35
|
+
expect(TEXT[6]).toBe("r");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("past the right edge resolves to the position after the line's last char", () => {
|
|
39
|
+
// Assert
|
|
40
|
+
expect(positionAt(LINES, 90, 10)).toEqual({ blockIndex: 3, char: 21 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a row without text resolves to the end of the closest line above", () => {
|
|
44
|
+
// Assert: the blank row between the blocks, and a row below everything
|
|
45
|
+
expect(positionAt(LINES, 30, 12)).toEqual({ blockIndex: 3, char: 38 });
|
|
46
|
+
expect(positionAt(LINES, 30, 40)).toEqual({ blockIndex: 4, char: 20 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rows above all text resolve to the first character", () => {
|
|
50
|
+
// Assert
|
|
51
|
+
expect(positionAt(LINES, 30, 2)).toEqual({ blockIndex: 3, char: 0 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a row of another block maps into that block", () => {
|
|
55
|
+
// Act
|
|
56
|
+
const position = positionAt(LINES, 6 + 3, 13);
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
expect(position).toEqual({ blockIndex: 4, char: 3 });
|
|
60
|
+
expect(positionAt([], 5, 10)).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("orderedSpan and tightenSpan", () => {
|
|
65
|
+
test("a backward drag normalizes to document order", () => {
|
|
66
|
+
// Act
|
|
67
|
+
const span = orderedSpan({
|
|
68
|
+
anchor: { blockIndex: 4, char: 5 },
|
|
69
|
+
head: { blockIndex: 3, char: 6 },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Assert
|
|
73
|
+
expect(span).toEqual({
|
|
74
|
+
start: { blockIndex: 3, char: 6 },
|
|
75
|
+
end: { blockIndex: 4, char: 5 },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a collapsed selection is no span", () => {
|
|
80
|
+
// Arrange
|
|
81
|
+
const selection = {
|
|
82
|
+
anchor: { blockIndex: 3, char: 6 },
|
|
83
|
+
head: { blockIndex: 3, char: 6 },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
expect(isCollapsed(selection)).toBe(true);
|
|
88
|
+
expect(orderedSpan(selection)).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a span opening at a block's end moves onto the next block", () => {
|
|
92
|
+
// Act: the pointer sat on the blank row above the bullet when the press landed
|
|
93
|
+
const tightened = tightenSpan(
|
|
94
|
+
{
|
|
95
|
+
start: { blockIndex: 3, char: TEXT.length },
|
|
96
|
+
end: { blockIndex: 4, char: 5 },
|
|
97
|
+
},
|
|
98
|
+
textLengthOf,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Assert
|
|
102
|
+
expect(tightened).toEqual({
|
|
103
|
+
start: { blockIndex: 4, char: 0 },
|
|
104
|
+
end: { blockIndex: 4, char: 5 },
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a span closing at a block's start moves back onto the previous block", () => {
|
|
109
|
+
// Act
|
|
110
|
+
const tightened = tightenSpan(
|
|
111
|
+
{ start: { blockIndex: 3, char: 10 }, end: { blockIndex: 4, char: 0 } },
|
|
112
|
+
textLengthOf,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Assert
|
|
116
|
+
expect(tightened).toEqual({
|
|
117
|
+
start: { blockIndex: 3, char: 10 },
|
|
118
|
+
end: { blockIndex: 3, char: TEXT.length },
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a span with nothing left in it is null", () => {
|
|
123
|
+
// Assert
|
|
124
|
+
expect(
|
|
125
|
+
tightenSpan(
|
|
126
|
+
{
|
|
127
|
+
start: { blockIndex: 3, char: TEXT.length },
|
|
128
|
+
end: { blockIndex: 4, char: 0 },
|
|
129
|
+
},
|
|
130
|
+
textLengthOf,
|
|
131
|
+
),
|
|
132
|
+
).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("spanRangeInBlock", () => {
|
|
137
|
+
const span = {
|
|
138
|
+
start: { blockIndex: 3, char: 22 },
|
|
139
|
+
end: { blockIndex: 5, char: 4 },
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
test("the first block runs from the start to its end, middles fully, the last to the end char", () => {
|
|
143
|
+
// Assert
|
|
144
|
+
expect(spanRangeInBlock(span, 3, TEXT.length)).toEqual({
|
|
145
|
+
start: 22,
|
|
146
|
+
end: TEXT.length,
|
|
147
|
+
});
|
|
148
|
+
expect(spanRangeInBlock(span, 4, BULLET.length)).toEqual({
|
|
149
|
+
start: 0,
|
|
150
|
+
end: BULLET.length,
|
|
151
|
+
});
|
|
152
|
+
expect(spanRangeInBlock(span, 5, 30)).toEqual({ start: 0, end: 4 });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("blocks outside the span have no range", () => {
|
|
156
|
+
// Assert
|
|
157
|
+
expect(spanRangeInBlock(span, 2, 10)).toBeNull();
|
|
158
|
+
expect(spanRangeInBlock(span, 6, 10)).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("word rules", () => {
|
|
163
|
+
test("the boundary rule attributes trailing whitespace to the preceding word", () => {
|
|
164
|
+
// Assert: char 3 is the space after "All"
|
|
165
|
+
expect(wordRangeAt(TEXT, 3)).toEqual({ start: 0, end: 3 });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("snapToWords grows a partial range to whole words on both sides", () => {
|
|
169
|
+
// Assert: "rsistence liv" -> "persistence lives"
|
|
170
|
+
expect(snapToWords(TEXT, { start: 6, end: 19 })).toEqual({
|
|
171
|
+
start: 4,
|
|
172
|
+
end: 21,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("snapSpanToWords snaps each end of a cross-block span within its own block", () => {
|
|
177
|
+
// Act: "rsistence ... st" -> "persistence ... store.ts"
|
|
178
|
+
const snapped = snapSpanToWords(
|
|
179
|
+
{ start: { blockIndex: 3, char: 6 }, end: { blockIndex: 4, char: 2 } },
|
|
180
|
+
(blockIndex) => (blockIndex === 3 ? TEXT : BULLET),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Assert
|
|
184
|
+
expect(snapped).toEqual({
|
|
185
|
+
start: { blockIndex: 3, char: 4 },
|
|
186
|
+
end: { blockIndex: 4, char: 8 },
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("word starts step in both directions and stop at the ends", () => {
|
|
191
|
+
// Assert
|
|
192
|
+
expect(nextWordStart(TEXT, 0)).toBe(4);
|
|
193
|
+
expect(nextWordStart(TEXT, 31)).toBeNull();
|
|
194
|
+
expect(previousWordStart(TEXT, 22)).toBe(16);
|
|
195
|
+
expect(previousWordStart(TEXT, 0)).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("isDoubleClick", () => {
|
|
200
|
+
test("same cell within the window counts, a moved or late press does not", () => {
|
|
201
|
+
// Arrange
|
|
202
|
+
const first = { time: 1000, x: 5, y: 7 };
|
|
203
|
+
|
|
204
|
+
// Assert
|
|
205
|
+
expect(isDoubleClick(first, { time: 1300, x: 5, y: 7 })).toBe(true);
|
|
206
|
+
expect(isDoubleClick(first, { time: 1300, x: 6, y: 7 })).toBe(false);
|
|
207
|
+
expect(isDoubleClick(first, { time: 1500, x: 5, y: 7 })).toBe(false);
|
|
208
|
+
expect(isDoubleClick(null, { time: 1300, x: 5, y: 7 })).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The thread view's text-selection model: a pure, renderer-free description
|
|
3
|
+
* of where the caret is and what is marked, plus the hit-testing that maps a
|
|
4
|
+
* pointer cell onto a character. Character-precise (partial words), spanning
|
|
5
|
+
* wrapped lines and consecutive blocks, normalized for backward drags. Word snapping
|
|
6
|
+
* happens only where the grammar asks for it: the typing anchor for a bare
|
|
7
|
+
* caret, and double-click word mode.
|
|
8
|
+
*
|
|
9
|
+
* Isolated on purpose: the interaction can be unit-tested and benchmarked
|
|
10
|
+
* without a terminal, and the view layer only wires events to it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface TextPosition {
|
|
14
|
+
blockIndex: number;
|
|
15
|
+
char: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Where one visual line of a block sits on screen, in cells. */
|
|
19
|
+
export interface LineGeometry {
|
|
20
|
+
blockIndex: number;
|
|
21
|
+
/** Char range of the block's text this line shows. */
|
|
22
|
+
start: number;
|
|
23
|
+
end: number;
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The held selection: `anchor` is where the press landed, `head` follows the pointer. */
|
|
29
|
+
export interface Selection {
|
|
30
|
+
anchor: TextPosition;
|
|
31
|
+
head: TextPosition;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface CharRange {
|
|
35
|
+
start: number;
|
|
36
|
+
end: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** An ordered stretch of text, possibly across consecutive blocks. */
|
|
40
|
+
export interface TextSpan {
|
|
41
|
+
start: TextPosition;
|
|
42
|
+
end: TextPosition;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Document order: block first, then character. */
|
|
46
|
+
export function comparePositions(left: TextPosition, right: TextPosition): number {
|
|
47
|
+
return left.blockIndex - right.blockIndex || left.char - right.char;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Word boundaries of a block's text. */
|
|
51
|
+
export function wordRanges(text: string): CharRange[] {
|
|
52
|
+
const ranges: CharRange[] = [];
|
|
53
|
+
const matcher = /\S+/g;
|
|
54
|
+
let match: RegExpExecArray | null;
|
|
55
|
+
|
|
56
|
+
while ((match = matcher.exec(text)) !== null) {
|
|
57
|
+
ranges.push({ start: match.index, end: match.index + match[0].length });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return ranges;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The marked word for a char offset: the word containing it, or - when the
|
|
65
|
+
* caret sits in the whitespace immediately after a word - that preceding
|
|
66
|
+
* word, never the next one.
|
|
67
|
+
*/
|
|
68
|
+
export function wordIndexAt(text: string, char: number): number {
|
|
69
|
+
const words = wordRanges(text);
|
|
70
|
+
|
|
71
|
+
for (let index = 0; index < words.length; index++) {
|
|
72
|
+
if (char < words[index]!.start) return Math.max(0, index - 1);
|
|
73
|
+
if (char < words[index]!.end) return index;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return Math.max(0, words.length - 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The word range the boundary rule assigns to a char offset; null for empty text. */
|
|
80
|
+
export function wordRangeAt(text: string, char: number): CharRange | null {
|
|
81
|
+
const words = wordRanges(text);
|
|
82
|
+
|
|
83
|
+
return words[wordIndexAt(text, char)] ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Map a pointer cell to a text position over every registered line. A row
|
|
88
|
+
* without text (a blank row, a card) resolves to the end of the closest line
|
|
89
|
+
* above it, rows above all text to the first character, and a cell past a
|
|
90
|
+
* line's last cell to the position after that line's last character, so a
|
|
91
|
+
* drag that runs off the right edge still selects to the line end.
|
|
92
|
+
*/
|
|
93
|
+
export function positionAt(lines: LineGeometry[], x: number, y: number): TextPosition | null {
|
|
94
|
+
if (lines.length === 0) return null;
|
|
95
|
+
const ordered = lines.toSorted((left, right) => left.y - right.y || left.x - right.x);
|
|
96
|
+
const onRow = ordered.find((line) => line.y === y);
|
|
97
|
+
|
|
98
|
+
if (onRow) {
|
|
99
|
+
const column = Math.max(0, Math.min(onRow.end - onRow.start, x - onRow.x));
|
|
100
|
+
|
|
101
|
+
return { blockIndex: onRow.blockIndex, char: onRow.start + column };
|
|
102
|
+
}
|
|
103
|
+
const above = ordered.findLast((line) => line.y < y);
|
|
104
|
+
|
|
105
|
+
if (above) return { blockIndex: above.blockIndex, char: above.end };
|
|
106
|
+
const first = ordered[0]!;
|
|
107
|
+
|
|
108
|
+
return { blockIndex: first.blockIndex, char: first.start };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True when nothing is held: the caret is a point, not a range. */
|
|
112
|
+
export function isCollapsed(selection: Selection): boolean {
|
|
113
|
+
return comparePositions(selection.anchor, selection.head) === 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The selection in document order, or null when collapsed. */
|
|
117
|
+
export function orderedSpan(selection: Selection): TextSpan | null {
|
|
118
|
+
if (isCollapsed(selection)) return null;
|
|
119
|
+
|
|
120
|
+
return comparePositions(selection.anchor, selection.head) < 0
|
|
121
|
+
? { start: selection.anchor, end: selection.head }
|
|
122
|
+
: { start: selection.head, end: selection.anchor };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Pull a span's edges off block boundaries: opening at a block's end or
|
|
127
|
+
* closing at a block's start marks nothing there, so the edge moves onto the
|
|
128
|
+
* neighbouring block. Null when nothing remains.
|
|
129
|
+
*/
|
|
130
|
+
export function tightenSpan(
|
|
131
|
+
span: TextSpan,
|
|
132
|
+
textLengthOf: (blockIndex: number) => number,
|
|
133
|
+
): TextSpan | null {
|
|
134
|
+
let { start, end } = span;
|
|
135
|
+
|
|
136
|
+
while (start.blockIndex < end.blockIndex && start.char >= textLengthOf(start.blockIndex)) {
|
|
137
|
+
start = { blockIndex: start.blockIndex + 1, char: 0 };
|
|
138
|
+
}
|
|
139
|
+
while (end.blockIndex > start.blockIndex && end.char === 0) {
|
|
140
|
+
end = {
|
|
141
|
+
blockIndex: end.blockIndex - 1,
|
|
142
|
+
char: textLengthOf(end.blockIndex - 1),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const clampedEnd = {
|
|
146
|
+
blockIndex: end.blockIndex,
|
|
147
|
+
char: Math.min(end.char, textLengthOf(end.blockIndex)),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return comparePositions(start, clampedEnd) < 0 ? { start, end: clampedEnd } : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The part of a span that falls on one block, as a char range; null when none. */
|
|
154
|
+
export function spanRangeInBlock(
|
|
155
|
+
span: TextSpan,
|
|
156
|
+
blockIndex: number,
|
|
157
|
+
textLength: number,
|
|
158
|
+
): CharRange | null {
|
|
159
|
+
if (blockIndex < span.start.blockIndex || blockIndex > span.end.blockIndex) return null;
|
|
160
|
+
const start = blockIndex === span.start.blockIndex ? span.start.char : 0;
|
|
161
|
+
const end = blockIndex === span.end.blockIndex ? Math.min(span.end.char, textLength) : textLength;
|
|
162
|
+
|
|
163
|
+
return end > start ? { start, end } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Grow a char range outward to the word boundaries it touches (double-click word mode). */
|
|
167
|
+
export function snapToWords(text: string, range: CharRange): CharRange {
|
|
168
|
+
const words = wordRanges(text);
|
|
169
|
+
const first = words.find((word) => range.start < word.end) ?? words[words.length - 1];
|
|
170
|
+
const last = words.toReversed().find((word) => range.end > word.start) ?? words[0];
|
|
171
|
+
|
|
172
|
+
if (!first || !last) return range;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
start: Math.min(first.start, range.start),
|
|
176
|
+
end: Math.max(last.end, range.end),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Snap both ends of a span to word boundaries, each within its own block. */
|
|
181
|
+
export function snapSpanToWords(span: TextSpan, textOf: (blockIndex: number) => string): TextSpan {
|
|
182
|
+
if (span.start.blockIndex === span.end.blockIndex) {
|
|
183
|
+
const snapped = snapToWords(textOf(span.start.blockIndex), {
|
|
184
|
+
start: span.start.char,
|
|
185
|
+
end: span.end.char,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
start: { blockIndex: span.start.blockIndex, char: snapped.start },
|
|
190
|
+
end: { blockIndex: span.end.blockIndex, char: snapped.end },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const startText = textOf(span.start.blockIndex);
|
|
194
|
+
const endText = textOf(span.end.blockIndex);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
start: {
|
|
198
|
+
blockIndex: span.start.blockIndex,
|
|
199
|
+
char: snapToWords(startText, {
|
|
200
|
+
start: span.start.char,
|
|
201
|
+
end: startText.length,
|
|
202
|
+
}).start,
|
|
203
|
+
},
|
|
204
|
+
end: {
|
|
205
|
+
blockIndex: span.end.blockIndex,
|
|
206
|
+
char: snapToWords(endText, { start: 0, end: span.end.char }).end,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface ClickStamp {
|
|
212
|
+
time: number;
|
|
213
|
+
x: number;
|
|
214
|
+
y: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Two presses on the same cell inside this window read as a double-click. */
|
|
218
|
+
export const DOUBLE_CLICK_WINDOW_MS = 400;
|
|
219
|
+
|
|
220
|
+
export function isDoubleClick(previous: ClickStamp | null, current: ClickStamp): boolean {
|
|
221
|
+
return (
|
|
222
|
+
previous !== null &&
|
|
223
|
+
previous.x === current.x &&
|
|
224
|
+
previous.y === current.y &&
|
|
225
|
+
current.time - previous.time <= DOUBLE_CLICK_WINDOW_MS
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Next word start after `char`, or null past the last word. */
|
|
230
|
+
export function nextWordStart(text: string, char: number): number | null {
|
|
231
|
+
const next = wordRanges(text).find((word) => word.start > char);
|
|
232
|
+
|
|
233
|
+
return next ? next.start : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Previous word start before `char`, or null before the first word. */
|
|
237
|
+
export function previousWordStart(text: string, char: number): number | null {
|
|
238
|
+
const previous = wordRanges(text)
|
|
239
|
+
.toReversed()
|
|
240
|
+
.find((word) => word.start < char);
|
|
241
|
+
|
|
242
|
+
return previous ? previous.start : null;
|
|
243
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
appendEntry,
|
|
4
|
+
createBranch,
|
|
5
|
+
historyFromLinear,
|
|
6
|
+
labelTip,
|
|
7
|
+
navigateTo,
|
|
8
|
+
switchBranch,
|
|
9
|
+
tipOf,
|
|
10
|
+
type SessionHistory,
|
|
11
|
+
} from "@cueloop/schema";
|
|
12
|
+
import { entryTarget, treeRows } from "./tree-view";
|
|
13
|
+
|
|
14
|
+
const AT = "2026-09-01T10:00:00.000Z";
|
|
15
|
+
const LATER = "2026-09-01T11:00:00.000Z";
|
|
16
|
+
|
|
17
|
+
/** rev1 -> comment a1 on main; a branch "alt" off a1 with its own comment; main continues with rev2. */
|
|
18
|
+
function forked(): SessionHistory {
|
|
19
|
+
let history = historyFromLinear({
|
|
20
|
+
id: "ses_1",
|
|
21
|
+
revisions: [{ revision: 1, content: "v1", submittedAt: AT }],
|
|
22
|
+
annotations: [],
|
|
23
|
+
message: null,
|
|
24
|
+
createdAt: AT,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
history = appendEntry(history, { type: "comment", annotationId: "a1", createdAt: AT }).history;
|
|
28
|
+
history = labelTip(history, "start");
|
|
29
|
+
history = createBranch(history, "alt");
|
|
30
|
+
history = appendEntry(history, { type: "comment", annotationId: "b1", createdAt: AT }).history;
|
|
31
|
+
history = switchBranch(history, "main");
|
|
32
|
+
history = appendEntry(history, {
|
|
33
|
+
type: "revision",
|
|
34
|
+
by: "agent",
|
|
35
|
+
content: "v2",
|
|
36
|
+
createdAt: LATER,
|
|
37
|
+
}).history;
|
|
38
|
+
|
|
39
|
+
return history;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("treeRows", () => {
|
|
43
|
+
test("the current branch is the trunk; other segments indent under the entry they grew from", () => {
|
|
44
|
+
// Act
|
|
45
|
+
const rows = treeRows(forked());
|
|
46
|
+
|
|
47
|
+
// Assert: rev1, a1 (labelled, alt hangs off it), the alt comment indented, then rev2 as main's tip
|
|
48
|
+
expect(rows.map((row) => [row.depth, row.text, row.onPath])).toEqual([
|
|
49
|
+
[0, "revision 1", true],
|
|
50
|
+
[0, "comment", true],
|
|
51
|
+
[1, "comment", false],
|
|
52
|
+
[0, "revision 2", true],
|
|
53
|
+
]);
|
|
54
|
+
expect(rows[1]!.label).toBe("start");
|
|
55
|
+
expect(rows[2]!.tips).toEqual(["alt"]);
|
|
56
|
+
expect(rows[3]!.tips).toEqual(["main"]);
|
|
57
|
+
expect(rows[3]!.isCurrentTip).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("on the other branch the trunk follows it and main's continuation indents", () => {
|
|
61
|
+
// Act
|
|
62
|
+
const rows = treeRows(switchBranch(forked(), "alt"));
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
expect(rows.map((row) => [row.depth, row.text, row.onPath])).toEqual([
|
|
66
|
+
[0, "revision 1", true],
|
|
67
|
+
[0, "comment", true],
|
|
68
|
+
[1, "revision 2", false],
|
|
69
|
+
[0, "comment", true],
|
|
70
|
+
]);
|
|
71
|
+
expect(rows[3]!.isCurrentTip).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a branch summary reads as a return with its text", () => {
|
|
75
|
+
// Arrange
|
|
76
|
+
const history = navigateTo(forked(), forked().entries[0]!.id, { summary: "too early" });
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
const rows = treeRows(history);
|
|
80
|
+
|
|
81
|
+
// Assert: the summary hangs under rev1 as the trunk, the abandoned segment indents
|
|
82
|
+
expect(rows.find((row) => row.glyph === "↩")?.text).toBe('"too early"');
|
|
83
|
+
expect(rows.find((row) => row.glyph === "↩")?.isCurrentTip).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("entryTarget", () => {
|
|
88
|
+
test("the tip is here, another tip is a switch, an ancestor is a navigate on its branch", () => {
|
|
89
|
+
// Arrange
|
|
90
|
+
const history = forked();
|
|
91
|
+
const [rev1, a1, altComment, rev2] = history.entries;
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
94
|
+
expect(entryTarget(history, rev2!.id)).toEqual({ kind: "here" });
|
|
95
|
+
expect(entryTarget(history, altComment!.id)).toEqual({ kind: "switch", branch: "alt" });
|
|
96
|
+
expect(entryTarget(history, a1!.id)).toEqual({
|
|
97
|
+
kind: "navigate",
|
|
98
|
+
entryId: a1!.id,
|
|
99
|
+
branch: "main",
|
|
100
|
+
});
|
|
101
|
+
expect(entryTarget(history, rev1!.id)?.kind).toBe("navigate");
|
|
102
|
+
expect(tipOf(history)).toBe(rev2!.id);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("an entry only another branch reaches is a navigate on that branch", () => {
|
|
106
|
+
// Arrange: alt gets a second comment so its first is no longer a tip
|
|
107
|
+
let history = switchBranch(forked(), "alt");
|
|
108
|
+
|
|
109
|
+
history = appendEntry(history, {
|
|
110
|
+
type: "comment",
|
|
111
|
+
annotationId: "b2",
|
|
112
|
+
createdAt: LATER,
|
|
113
|
+
}).history;
|
|
114
|
+
const altFirst = history.entries.find(
|
|
115
|
+
(entry) => entry.type === "comment" && entry.annotationId === "b1",
|
|
116
|
+
)!;
|
|
117
|
+
|
|
118
|
+
history = switchBranch(history, "main");
|
|
119
|
+
|
|
120
|
+
// Assert
|
|
121
|
+
expect(entryTarget(history, altFirst.id)).toEqual({
|
|
122
|
+
kind: "navigate",
|
|
123
|
+
entryId: altFirst.id,
|
|
124
|
+
branch: "alt",
|
|
125
|
+
});
|
|
126
|
+
expect(entryTarget(history, "e_nope")).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|