@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,975 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The annotation surface: everything an inline-commenting view shares, over a
|
|
3
|
+
* list of text lines it does not know how to paint. A character-precise caret
|
|
4
|
+
* sits in the text; click/drag marks (word mode on double-click); typing opens a
|
|
5
|
+
* composer anchored at the caret word or held selection; "/" opens the quick-action
|
|
6
|
+
* palette; enter replies, tab folds, esc dismisses; cmd+enter sends. The plan
|
|
7
|
+
* thread view and the diff sheet both drive this hook and only paint their own
|
|
8
|
+
* rows, so marking and commenting behave identically on prose and on code.
|
|
9
|
+
*
|
|
10
|
+
* The view registers one renderable per visual line (so a drag can hit-test any
|
|
11
|
+
* row on screen), asks for the mark ranges to paint on a block, and slots the
|
|
12
|
+
* cards this hook builds under the visual line a span ends on.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React, { useContext, useEffect, useRef, useState } from "react";
|
|
16
|
+
import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
|
|
17
|
+
import { useKeyboard } from "@opentui/react";
|
|
18
|
+
import type { Annotation, Thread } from "@cueloop/schema";
|
|
19
|
+
import type { Mark } from "./view-plan";
|
|
20
|
+
import type { QuickAction } from "./config";
|
|
21
|
+
import type { Theme } from "./theme";
|
|
22
|
+
import {
|
|
23
|
+
comparePositions,
|
|
24
|
+
isDoubleClick,
|
|
25
|
+
nextWordStart,
|
|
26
|
+
orderedSpan,
|
|
27
|
+
positionAt,
|
|
28
|
+
previousWordStart,
|
|
29
|
+
snapSpanToWords,
|
|
30
|
+
spanRangeInBlock,
|
|
31
|
+
tightenSpan,
|
|
32
|
+
wordRangeAt,
|
|
33
|
+
type ClickStamp,
|
|
34
|
+
type LineGeometry,
|
|
35
|
+
type TextPosition,
|
|
36
|
+
type TextSpan,
|
|
37
|
+
} from "./thread-selection";
|
|
38
|
+
import { annotationPaletteFor, type AnnotationPalette } from "./annotation-palette";
|
|
39
|
+
import { printableSequence, type MarkRange, type VisualLine } from "./mark-runs";
|
|
40
|
+
import {
|
|
41
|
+
activeSlashToken,
|
|
42
|
+
insertSlashItem,
|
|
43
|
+
isStandaloneSlashQuery,
|
|
44
|
+
mergeSlashItems,
|
|
45
|
+
slashFilter,
|
|
46
|
+
slashItemsFrom,
|
|
47
|
+
} from "./slash-palette";
|
|
48
|
+
import { SlashSkillsContext } from "./skills";
|
|
49
|
+
import { discussionsFrom, type Discussion } from "./discussions";
|
|
50
|
+
import {
|
|
51
|
+
CommentRow,
|
|
52
|
+
Composer,
|
|
53
|
+
ComposerPalette,
|
|
54
|
+
DiscussionCard,
|
|
55
|
+
type EdgeSegment,
|
|
56
|
+
} from "./components/AnnotationCards";
|
|
57
|
+
|
|
58
|
+
/** The lines a surface annotates: the text per block and which blocks take a comment. */
|
|
59
|
+
export interface LineSource {
|
|
60
|
+
count: number;
|
|
61
|
+
textAt(blockIndex: number): string;
|
|
62
|
+
/** A block the caret can rest on and a comment can anchor to (a diff's header rows cannot). */
|
|
63
|
+
annotatable(blockIndex: number): boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ComposeState {
|
|
67
|
+
/** The block the card renders under: where the span ends. */
|
|
68
|
+
blockIndex: number;
|
|
69
|
+
/** Reply target; null composes a new discussion. */
|
|
70
|
+
discussionKey: string | null;
|
|
71
|
+
span: TextSpan | null;
|
|
72
|
+
seed: string;
|
|
73
|
+
/** When set, the composer rewrites this annotation instead of appending. */
|
|
74
|
+
editAnnotationId: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface AnnotationSurfaceOptions {
|
|
78
|
+
source: LineSource;
|
|
79
|
+
session: Thread;
|
|
80
|
+
marks: Map<number, Mark[]>;
|
|
81
|
+
quickActions: QuickAction[];
|
|
82
|
+
tokens: Theme;
|
|
83
|
+
observer: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Whether this surface accepts comments. False for a non-diff thread's live
|
|
86
|
+
* working-tree diff, which is view-only: its rows are diff rows, but the
|
|
87
|
+
* thread anchors comments in plan coordinates, so a draft here would misanchor.
|
|
88
|
+
*/
|
|
89
|
+
commentsEnabled?: boolean;
|
|
90
|
+
/** A message is in: no draft may open; the app answers with its read-only status. */
|
|
91
|
+
resolved: boolean;
|
|
92
|
+
/** True while a menu, dialog, or overlay owns the keyboard. */
|
|
93
|
+
suspended: boolean;
|
|
94
|
+
/** Reports whether a composer is open, so session chords can yield to typing. */
|
|
95
|
+
onComposingChange?: (composing: boolean) => void;
|
|
96
|
+
/** An observer or a resolved review refused a draft; the app shows why. */
|
|
97
|
+
onObserverBlocked?: (reason: "observer" | "resolved") => void;
|
|
98
|
+
/** Reports the caret's block, so block-level primitives (cut, restore) act where the caret is. */
|
|
99
|
+
onCursorChange?: (blockIndex: number) => void;
|
|
100
|
+
/** The rail's focused card; the discussion holding it takes focus here. */
|
|
101
|
+
focusedAnnotationId?: string;
|
|
102
|
+
/** Reports the focused discussion's root comment, so the rail follows. */
|
|
103
|
+
onFocusAnnotation?: (annotationId: string | undefined) => void;
|
|
104
|
+
onAnnotate: (span: TextSpan, body: string) => void;
|
|
105
|
+
onReply: (rootAnnotationId: string, body: string) => void;
|
|
106
|
+
onUpdateAnnotation: (id: string, body: string) => void;
|
|
107
|
+
/** The visible scroll viewport used to keep a held mouse mark moving at its edges. */
|
|
108
|
+
dragViewport?: () => {
|
|
109
|
+
top: number;
|
|
110
|
+
bottom: number;
|
|
111
|
+
scrollBy: (rows: -1 | 1) => boolean;
|
|
112
|
+
} | null;
|
|
113
|
+
/** The author's display name for a comment's hover tooltip; the rail resolves it against the participant registry. */
|
|
114
|
+
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
115
|
+
/** Resolve a nav-mode key to a session/curation/tree/diff command; true when it acted. */
|
|
116
|
+
onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
|
|
117
|
+
onExit: () => void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface AnnotationSurface {
|
|
121
|
+
palette: AnnotationPalette;
|
|
122
|
+
discussions: Discussion[];
|
|
123
|
+
/** The caret's block. */
|
|
124
|
+
cursor: number;
|
|
125
|
+
head: TextPosition;
|
|
126
|
+
compose: ComposeState | null;
|
|
127
|
+
/** True while the surface is in nav mode: bare keys act as commands, typing does not compose. */
|
|
128
|
+
navMode: boolean;
|
|
129
|
+
focusedDiscussion: string | null;
|
|
130
|
+
/** The block to keep in view: an opening card, a focused discussion, else the caret. */
|
|
131
|
+
revealBlockIndex: number;
|
|
132
|
+
/** The text a span covers, blocks joined by a space, for previews. */
|
|
133
|
+
spanQuote: (span: TextSpan) => string;
|
|
134
|
+
/** Ref callback for one visual line, so a drag can hit-test it. */
|
|
135
|
+
registerLine: (
|
|
136
|
+
blockIndex: number,
|
|
137
|
+
lineIndex: number,
|
|
138
|
+
line: VisualLine,
|
|
139
|
+
) => (renderable: TextRenderable | null) => void;
|
|
140
|
+
/** Press on a visual line: place the caret (word mode on double-click) and start a drag. */
|
|
141
|
+
onLineMouseDown: (event: TerminalMouseEvent) => void;
|
|
142
|
+
/** Drag routing for the view root, so a fast flick off a row never strands the gesture. */
|
|
143
|
+
rootMouseProps: {
|
|
144
|
+
onMouseDrag: (event: TerminalMouseEvent) => void;
|
|
145
|
+
onMouseDragEnd: () => void;
|
|
146
|
+
onMouseUp: () => void;
|
|
147
|
+
};
|
|
148
|
+
/** Ranges to paint on a block: discussion marks, the compose mark, the caret cell. */
|
|
149
|
+
rangesFor: (blockIndex: number) => MarkRange[];
|
|
150
|
+
/** Cards anchored to a visual line: discussion cards, the new-discussion composer, the palette. */
|
|
151
|
+
cardsAfterLine: (blockIndex: number, line: VisualLine, isLastLine: boolean) => React.ReactNode[];
|
|
152
|
+
jumpToDiscussion: (key: string) => void;
|
|
153
|
+
/** Clicking away from an open composer commits the draft. */
|
|
154
|
+
blurSaveCompose: () => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The nearest block the caret can rest on, stepping from `from` in `direction`; `from` when none. */
|
|
158
|
+
function nearestAnnotatable(source: LineSource, from: number, direction: 1 | -1): number {
|
|
159
|
+
for (let index = from + direction; index >= 0 && index < source.count; index += direction) {
|
|
160
|
+
if (source.annotatable(index)) return index;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return from;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The first block the caret can rest on, so a surface never opens on a header. */
|
|
167
|
+
function firstAnnotatable(source: LineSource): number {
|
|
168
|
+
for (let index = 0; index < source.count; index++) if (source.annotatable(index)) return index;
|
|
169
|
+
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The visual-row direction requested by an arrow or readline-style caret key. */
|
|
174
|
+
function verticalCaretDelta(key: KeyEvent): -1 | 0 | 1 {
|
|
175
|
+
if (key.name === "up" || (key.ctrl && key.name === "p")) return -1;
|
|
176
|
+
if (key.name === "down" || (key.ctrl && key.name === "n")) return 1;
|
|
177
|
+
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function useAnnotationSurface(options: AnnotationSurfaceOptions): AnnotationSurface {
|
|
182
|
+
const {
|
|
183
|
+
source,
|
|
184
|
+
session,
|
|
185
|
+
marks,
|
|
186
|
+
quickActions,
|
|
187
|
+
tokens,
|
|
188
|
+
observer,
|
|
189
|
+
commentsEnabled = true,
|
|
190
|
+
resolved,
|
|
191
|
+
suspended,
|
|
192
|
+
onComposingChange,
|
|
193
|
+
onObserverBlocked,
|
|
194
|
+
onCursorChange,
|
|
195
|
+
focusedAnnotationId,
|
|
196
|
+
onFocusAnnotation,
|
|
197
|
+
onAnnotate,
|
|
198
|
+
onReply,
|
|
199
|
+
onUpdateAnnotation,
|
|
200
|
+
dragViewport,
|
|
201
|
+
resolveAuthorLabel,
|
|
202
|
+
onNavCommand,
|
|
203
|
+
onExit,
|
|
204
|
+
} = options;
|
|
205
|
+
const palette = annotationPaletteFor(tokens);
|
|
206
|
+
const discussions = discussionsFrom(session, marks);
|
|
207
|
+
const [cursor, setCursor] = useState(() => firstAnnotatable(source));
|
|
208
|
+
// character-precise: `head` is the caret, `anchor` the other end of a held
|
|
209
|
+
// selection (the same position when collapsed); both are full positions, so
|
|
210
|
+
// the cursor block (cards, folding) can move without conjuring a selection
|
|
211
|
+
const [caret, setCaret] = useState<{ head: TextPosition; anchor: TextPosition }>(() => {
|
|
212
|
+
const start = { blockIndex: firstAnnotatable(source), char: 0 };
|
|
213
|
+
|
|
214
|
+
return { head: start, anchor: start };
|
|
215
|
+
});
|
|
216
|
+
const [compose, setCompose] = useState<ComposeState | null>(null);
|
|
217
|
+
const dragPointer = useRef<{ x: number; y: number } | null>(null);
|
|
218
|
+
const edgeScrollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
219
|
+
const dragViewportRef = useRef(dragViewport);
|
|
220
|
+
|
|
221
|
+
dragViewportRef.current = dragViewport;
|
|
222
|
+
const [navMode, setNavModeState] = useState(false);
|
|
223
|
+
const navModeRef = useRef(false);
|
|
224
|
+
const setNavMode = (value: boolean): void => {
|
|
225
|
+
navModeRef.current = value;
|
|
226
|
+
setNavModeState(value);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
onComposingChange?.(compose !== null);
|
|
231
|
+
}, [compose, onComposingChange]);
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
onCursorChange?.(caret.head.blockIndex);
|
|
234
|
+
}, [caret.head.blockIndex, onCursorChange]);
|
|
235
|
+
// the lines can change under the caret (a diff loads, a file folds): land it on
|
|
236
|
+
// the nearest block it may rest on rather than a header or past the end
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
const { blockIndex } = caret.head;
|
|
239
|
+
|
|
240
|
+
if (source.count === 0) return;
|
|
241
|
+
if (blockIndex < source.count && source.annotatable(blockIndex)) return;
|
|
242
|
+
const clamped = Math.min(blockIndex, source.count - 1);
|
|
243
|
+
const next = source.annotatable(clamped)
|
|
244
|
+
? clamped
|
|
245
|
+
: nearestAnnotatable(source, clamped, 1) !== clamped
|
|
246
|
+
? nearestAnnotatable(source, clamped, 1)
|
|
247
|
+
: nearestAnnotatable(source, clamped, -1);
|
|
248
|
+
const position = { blockIndex: next, char: 0 };
|
|
249
|
+
|
|
250
|
+
setCursor(next);
|
|
251
|
+
setCaret({ head: position, anchor: position });
|
|
252
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
253
|
+
}, [source.count]);
|
|
254
|
+
|
|
255
|
+
// discussion focus is the rail's card focus seen from the document: when the
|
|
256
|
+
// app owns it (onFocusAnnotation), the focused discussion is the one holding
|
|
257
|
+
// the focused card and focusing here names the root comment; standalone, it
|
|
258
|
+
// is local state
|
|
259
|
+
const [localFocus, setLocalFocus] = useState<string | null>(null);
|
|
260
|
+
const focusedDiscussion =
|
|
261
|
+
onFocusAnnotation === undefined
|
|
262
|
+
? localFocus
|
|
263
|
+
: (discussions.find((discussion) =>
|
|
264
|
+
discussion.annotations.some((annotation) => annotation.id === focusedAnnotationId),
|
|
265
|
+
)?.key ?? null);
|
|
266
|
+
const setFocusedDiscussion = (key: string | null): void => {
|
|
267
|
+
if (onFocusAnnotation === undefined) return setLocalFocus(key);
|
|
268
|
+
onFocusAnnotation(discussions.find((discussion) => discussion.key === key)?.rootId);
|
|
269
|
+
};
|
|
270
|
+
const [folded, setFolded] = useState<Set<string>>(new Set());
|
|
271
|
+
const [composeText, setComposeText] = useState("");
|
|
272
|
+
const composeTextRef = useRef("");
|
|
273
|
+
const setDraft = (text: string): void => {
|
|
274
|
+
composeTextRef.current = text;
|
|
275
|
+
setComposeText(text);
|
|
276
|
+
};
|
|
277
|
+
const [caretOffset, setCaretOffset] = useState(0);
|
|
278
|
+
const [slashIndex, setSlashIndex] = useState(0);
|
|
279
|
+
const composerReady = useRef(false);
|
|
280
|
+
const composeRef = useRef<ComposeState | null>(null);
|
|
281
|
+
// every visual line registers its renderable so a drag can hit-test any
|
|
282
|
+
// row on screen, across blocks (rows without text resolve to the block above)
|
|
283
|
+
const lineRenderables = useRef(
|
|
284
|
+
new Map<
|
|
285
|
+
string,
|
|
286
|
+
{ blockIndex: number; start: number; end: number; renderable: TextRenderable }
|
|
287
|
+
>(),
|
|
288
|
+
);
|
|
289
|
+
// the press anchors the gesture; the first drag samples can land before
|
|
290
|
+
// React has re-rendered, so the anchor travels with the gesture, not the closure
|
|
291
|
+
const dragging = useRef<{ wordMode: boolean; anchor: TextPosition; head: TextPosition } | null>(
|
|
292
|
+
null,
|
|
293
|
+
);
|
|
294
|
+
const lastClick = useRef<ClickStamp | null>(null);
|
|
295
|
+
|
|
296
|
+
const blockText = (blockIndex: number): string =>
|
|
297
|
+
blockIndex < source.count ? source.textAt(blockIndex) : "";
|
|
298
|
+
const textLengthOf = (blockIndex: number): number => blockText(blockIndex).length;
|
|
299
|
+
const { head } = caret;
|
|
300
|
+
/** The held selection in document order, off block edges; null when collapsed. */
|
|
301
|
+
const heldSpan = ((): TextSpan | null => {
|
|
302
|
+
const span = orderedSpan({ anchor: caret.anchor, head });
|
|
303
|
+
|
|
304
|
+
return span ? tightenSpan(span, textLengthOf) : null;
|
|
305
|
+
})();
|
|
306
|
+
const caretIsSelection = heldSpan !== null;
|
|
307
|
+
/** The typing anchor: the held selection (char-precise), else the marked word. */
|
|
308
|
+
const caretSpan = (): TextSpan | null => {
|
|
309
|
+
if (heldSpan) return heldSpan;
|
|
310
|
+
const word = wordRangeAt(blockText(head.blockIndex), head.char);
|
|
311
|
+
|
|
312
|
+
return word
|
|
313
|
+
? {
|
|
314
|
+
start: { blockIndex: cursor, char: word.start },
|
|
315
|
+
end: { blockIndex: cursor, char: word.end },
|
|
316
|
+
}
|
|
317
|
+
: null;
|
|
318
|
+
};
|
|
319
|
+
const collapseCaret = (): void => setCaret({ head, anchor: head });
|
|
320
|
+
const spanQuote = (span: TextSpan): string => {
|
|
321
|
+
const parts: string[] = [];
|
|
322
|
+
|
|
323
|
+
for (let blockIndex = span.start.blockIndex; blockIndex <= span.end.blockIndex; blockIndex++) {
|
|
324
|
+
const range = spanRangeInBlock(span, blockIndex, textLengthOf(blockIndex));
|
|
325
|
+
|
|
326
|
+
if (range) parts.push(blockText(blockIndex).slice(range.start, range.end));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return parts.join(" ");
|
|
330
|
+
};
|
|
331
|
+
const allGeometry = (): LineGeometry[] =>
|
|
332
|
+
[...lineRenderables.current.values()].map((entry) => ({
|
|
333
|
+
blockIndex: entry.blockIndex,
|
|
334
|
+
start: entry.start,
|
|
335
|
+
end: entry.end,
|
|
336
|
+
x: entry.renderable.x,
|
|
337
|
+
y: entry.renderable.y,
|
|
338
|
+
}));
|
|
339
|
+
const verticalTextPosition = (position: TextPosition, direction: -1 | 1): TextPosition | null => {
|
|
340
|
+
const lines = allGeometry()
|
|
341
|
+
.filter((line) => source.annotatable(line.blockIndex))
|
|
342
|
+
.toSorted((left, right) => left.y - right.y || left.x - right.x);
|
|
343
|
+
const currentIndex = lines.findIndex(
|
|
344
|
+
(line) =>
|
|
345
|
+
line.blockIndex === position.blockIndex &&
|
|
346
|
+
line.start <= position.char &&
|
|
347
|
+
position.char <= line.end,
|
|
348
|
+
);
|
|
349
|
+
const target = currentIndex === -1 ? undefined : lines[currentIndex + direction];
|
|
350
|
+
|
|
351
|
+
if (!target) return null;
|
|
352
|
+
const current = lines[currentIndex]!;
|
|
353
|
+
const column = Math.max(0, position.char - current.start);
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
blockIndex: target.blockIndex,
|
|
357
|
+
char: Math.min(target.end, target.start + column),
|
|
358
|
+
};
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const openCompose = (state: ComposeState): void => {
|
|
362
|
+
// a view-only surface (a non-diff thread's live diff) never opens a draft
|
|
363
|
+
if (!commentsEnabled) return;
|
|
364
|
+
if (observer) return onObserverBlocked?.("observer");
|
|
365
|
+
if (resolved) return onObserverBlocked?.("resolved");
|
|
366
|
+
composerReady.current = false;
|
|
367
|
+
composeRef.current = state;
|
|
368
|
+
setCompose(state);
|
|
369
|
+
setDraft(state.seed);
|
|
370
|
+
setCaretOffset(state.seed.length);
|
|
371
|
+
setSlashIndex(0);
|
|
372
|
+
};
|
|
373
|
+
const closeCompose = (): void => {
|
|
374
|
+
composeRef.current = null;
|
|
375
|
+
setCompose(null);
|
|
376
|
+
setDraft("");
|
|
377
|
+
setCaretOffset(0);
|
|
378
|
+
};
|
|
379
|
+
const skills = useContext(SlashSkillsContext);
|
|
380
|
+
const paletteItems = mergeSlashItems(slashItemsFrom(quickActions), skills);
|
|
381
|
+
// the "/word" under the caret, anywhere in the draft, so each new "/" reopens the palette
|
|
382
|
+
const slashToken = compose !== null ? activeSlashToken(composeText, caretOffset) : null;
|
|
383
|
+
const slashActive = slashToken !== null;
|
|
384
|
+
const slashItems = slashToken !== null ? slashFilter(paletteItems, slashToken.slice(1)) : [];
|
|
385
|
+
|
|
386
|
+
// a fresh token starts its selection at the top, so an earlier Down never leaks into it
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
if (slashActive) setSlashIndex(0);
|
|
389
|
+
}, [slashActive]);
|
|
390
|
+
|
|
391
|
+
const saveComment = (body: string): void => {
|
|
392
|
+
// the body saves verbatim - typed newlines are the author's choice;
|
|
393
|
+
// trimming only decides whether the draft is empty enough to discard
|
|
394
|
+
const target = composeRef.current;
|
|
395
|
+
|
|
396
|
+
closeCompose();
|
|
397
|
+
collapseCaret();
|
|
398
|
+
if (!target || body.trim().length === 0) return;
|
|
399
|
+
if (target.editAnnotationId !== null) {
|
|
400
|
+
onUpdateAnnotation(target.editAnnotationId, body);
|
|
401
|
+
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (target.discussionKey !== null) {
|
|
405
|
+
const discussion = discussions.find((candidate) => candidate.key === target.discussionKey);
|
|
406
|
+
|
|
407
|
+
if (discussion) {
|
|
408
|
+
onReply(discussion.rootId, body);
|
|
409
|
+
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const span = target.span ?? caretSpan();
|
|
414
|
+
|
|
415
|
+
if (span) onAnnotate(span, body);
|
|
416
|
+
};
|
|
417
|
+
/** A new discussion on the typing anchor; the card renders under the span's last block. */
|
|
418
|
+
const openNewCompose = (seed: string): void => {
|
|
419
|
+
const span = caretSpan();
|
|
420
|
+
|
|
421
|
+
openCompose({
|
|
422
|
+
blockIndex: span?.end.blockIndex ?? head.blockIndex,
|
|
423
|
+
discussionKey: null,
|
|
424
|
+
span,
|
|
425
|
+
seed,
|
|
426
|
+
editAnnotationId: null,
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// clicking away commits the draft (blur-save); only a standalone "/query" is a palette
|
|
431
|
+
// artifact, so prose that merely ends in a "/name" still saves
|
|
432
|
+
const blurSaveCompose = (): void => {
|
|
433
|
+
if (!composeRef.current) return;
|
|
434
|
+
if (isStandaloneSlashQuery(composeText) || composeText.trim().length === 0) {
|
|
435
|
+
return closeCompose();
|
|
436
|
+
}
|
|
437
|
+
saveComment(composeText);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
/** Extend the held selection to the pointer's position (word mode snaps both ends). */
|
|
441
|
+
const extendSelectionTo = (pointer: TextPosition): void => {
|
|
442
|
+
const drag = dragging.current;
|
|
443
|
+
|
|
444
|
+
if (!drag) return;
|
|
445
|
+
let next = { head: pointer, anchor: drag.anchor };
|
|
446
|
+
|
|
447
|
+
if (drag.wordMode) {
|
|
448
|
+
const span = orderedSpan({ anchor: drag.anchor, head: pointer });
|
|
449
|
+
|
|
450
|
+
if (span) {
|
|
451
|
+
const snapped = snapSpanToWords(span, blockText);
|
|
452
|
+
const forward = comparePositions(pointer, drag.anchor) >= 0;
|
|
453
|
+
|
|
454
|
+
next = forward
|
|
455
|
+
? { anchor: snapped.start, head: snapped.end }
|
|
456
|
+
: { anchor: snapped.end, head: snapped.start };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (comparePositions(next.head, drag.head) === 0) return;
|
|
460
|
+
drag.head = next.head;
|
|
461
|
+
setCursor(next.head.blockIndex);
|
|
462
|
+
setCaret({ head: next.head, anchor: next.anchor });
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
// Drag routing lives on the view root: the renderer captures the drag on
|
|
466
|
+
// its FIRST drag sample, to whatever is under the pointer then, so a fast
|
|
467
|
+
// flick off the text row would otherwise strand the gesture. Events
|
|
468
|
+
// bubble, so the root sees every drag and resolves it against every line
|
|
469
|
+
// on screen - the mark follows the pointer across blocks.
|
|
470
|
+
const handleRootDrag = (event: TerminalMouseEvent): void => {
|
|
471
|
+
if (!dragging.current) return;
|
|
472
|
+
dragPointer.current = { x: event.x, y: event.y };
|
|
473
|
+
const position = positionAt(allGeometry(), event.x, event.y);
|
|
474
|
+
|
|
475
|
+
if (position) extendSelectionTo(position);
|
|
476
|
+
if (edgeScrollTimer.current === null) {
|
|
477
|
+
edgeScrollTimer.current = setInterval(() => {
|
|
478
|
+
const pointer = dragPointer.current;
|
|
479
|
+
const viewport = dragViewportRef.current?.();
|
|
480
|
+
|
|
481
|
+
if (!dragging.current || !pointer || !viewport) return;
|
|
482
|
+
const direction = pointer.y <= viewport.top ? -1 : pointer.y >= viewport.bottom ? 1 : 0;
|
|
483
|
+
|
|
484
|
+
if (direction === 0 || !viewport.scrollBy(direction)) return;
|
|
485
|
+
const next = positionAt(allGeometry(), pointer.x, pointer.y);
|
|
486
|
+
|
|
487
|
+
if (next) extendSelectionTo(next);
|
|
488
|
+
}, 100);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
const endDrag = (): void => {
|
|
492
|
+
dragging.current = null;
|
|
493
|
+
dragPointer.current = null;
|
|
494
|
+
if (edgeScrollTimer.current !== null) clearInterval(edgeScrollTimer.current);
|
|
495
|
+
edgeScrollTimer.current = null;
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
useEffect(() => endDrag, []);
|
|
499
|
+
|
|
500
|
+
const jumpToDiscussion = (key: string): void => {
|
|
501
|
+
const target = discussions.find((discussion) => discussion.key === key);
|
|
502
|
+
|
|
503
|
+
if (!target) return;
|
|
504
|
+
blurSaveCompose();
|
|
505
|
+
setCursor(target.blockIndex);
|
|
506
|
+
setFocusedDiscussion(key);
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// ── keyboard, split per concern to stay within the complexity budget ──
|
|
510
|
+
|
|
511
|
+
const handleSlashKey = (key: KeyEvent, activeCompose: ComposeState): boolean => {
|
|
512
|
+
if (!slashActive || slashItems.length === 0) return false;
|
|
513
|
+
const selected = Math.min(slashIndex, slashItems.length - 1);
|
|
514
|
+
|
|
515
|
+
if (key.name === "up") {
|
|
516
|
+
setSlashIndex(Math.max(0, selected - 1));
|
|
517
|
+
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
if (key.name === "down") {
|
|
521
|
+
setSlashIndex(Math.min(slashItems.length - 1, selected + 1));
|
|
522
|
+
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
if (key.name === "return" || key.name === "tab") {
|
|
526
|
+
const inserted = insertSlashItem(composeText, caretOffset, slashItems[selected]!.name);
|
|
527
|
+
|
|
528
|
+
openCompose({ ...activeCompose, seed: inserted.text });
|
|
529
|
+
setCaretOffset(inserted.caret);
|
|
530
|
+
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return false;
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/** Pre-mount window: buffer printables, honor a fast cmd+enter or newline. */
|
|
538
|
+
const handlePremountKey = (key: KeyEvent, activeCompose: ComposeState): void => {
|
|
539
|
+
if (key.name === "return") {
|
|
540
|
+
if (key.super || key.meta || key.ctrl) return saveComment(activeCompose.seed);
|
|
541
|
+
const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
|
|
542
|
+
|
|
543
|
+
composeRef.current = grown;
|
|
544
|
+
setDraft(grown.seed);
|
|
545
|
+
setCaretOffset(grown.seed.length);
|
|
546
|
+
|
|
547
|
+
return setCompose(grown);
|
|
548
|
+
}
|
|
549
|
+
const sequence = printableSequence(key);
|
|
550
|
+
|
|
551
|
+
if (sequence) {
|
|
552
|
+
const grown = { ...activeCompose, seed: activeCompose.seed + sequence };
|
|
553
|
+
|
|
554
|
+
composeRef.current = grown;
|
|
555
|
+
setDraft(grown.seed);
|
|
556
|
+
setCaretOffset(grown.seed.length);
|
|
557
|
+
setCompose(grown);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const handleComposeKey = (key: KeyEvent, activeCompose: ComposeState): void => {
|
|
562
|
+
// the textarea owns every key while open; the view takes dismiss (which
|
|
563
|
+
// also releases the discussion focus), the slash palette, and pre-mount input
|
|
564
|
+
if (key.name === "escape") {
|
|
565
|
+
setFocusedDiscussion(null);
|
|
566
|
+
|
|
567
|
+
return closeCompose();
|
|
568
|
+
}
|
|
569
|
+
// backspace on an already empty draft undoes it: the card dissolves
|
|
570
|
+
// and the caret sits back on the still-held selection, ready to re-type
|
|
571
|
+
// (an edit of an existing comment is never deleted this way)
|
|
572
|
+
if (
|
|
573
|
+
key.name === "backspace" &&
|
|
574
|
+
composeTextRef.current.length === 0 &&
|
|
575
|
+
activeCompose.editAnnotationId === null
|
|
576
|
+
) {
|
|
577
|
+
return closeCompose();
|
|
578
|
+
}
|
|
579
|
+
if (handleSlashKey(key, activeCompose)) return;
|
|
580
|
+
if (!composerReady.current) handlePremountKey(key, activeCompose);
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/** Fold or unfold every discussion sitting on the caret's block (nav-mode z). */
|
|
584
|
+
const toggleFoldAtCursor = (): void => {
|
|
585
|
+
setFolded((current) => {
|
|
586
|
+
const next = new Set(current);
|
|
587
|
+
|
|
588
|
+
for (const discussion of discussions) {
|
|
589
|
+
if (discussion.blockIndex !== cursor) continue;
|
|
590
|
+
if (next.has(discussion.key)) next.delete(discussion.key);
|
|
591
|
+
else next.add(discussion.key);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return next;
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
/** m / cmd+[ / cmd+] / return - true when the key was a discussion primitive. */
|
|
599
|
+
const handleDiscussionVerb = (key: KeyEvent): boolean => {
|
|
600
|
+
// comment on selection: cmd+option+m (alt+m where cmd arrives ESC-prefixed)
|
|
601
|
+
if (key.name === "m" && (key.super || key.meta || key.option)) {
|
|
602
|
+
openNewCompose("");
|
|
603
|
+
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
if ((key.super || key.meta || key.ctrl) && (key.name === "[" || key.name === "]")) {
|
|
607
|
+
if (discussions.length === 0) return true;
|
|
608
|
+
const currentIndex = discussions.findIndex(
|
|
609
|
+
(discussion) => discussion.key === focusedDiscussion,
|
|
610
|
+
);
|
|
611
|
+
const nextIndex =
|
|
612
|
+
key.name === "]"
|
|
613
|
+
? (currentIndex + 1) % discussions.length
|
|
614
|
+
: currentIndex <= 0
|
|
615
|
+
? discussions.length - 1
|
|
616
|
+
: currentIndex - 1;
|
|
617
|
+
|
|
618
|
+
jumpToDiscussion(discussions[nextIndex]!.key);
|
|
619
|
+
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
// enter replies into the focused discussion, else the cursor block's discussion
|
|
623
|
+
if (key.name === "return") {
|
|
624
|
+
const replyTarget =
|
|
625
|
+
discussions.find((discussion) => discussion.key === focusedDiscussion) ??
|
|
626
|
+
discussions.findLast((discussion) => discussion.blockIndex === cursor);
|
|
627
|
+
|
|
628
|
+
if (replyTarget) {
|
|
629
|
+
openCompose({
|
|
630
|
+
blockIndex: replyTarget.blockIndex,
|
|
631
|
+
discussionKey: replyTarget.key,
|
|
632
|
+
span: null,
|
|
633
|
+
seed: "",
|
|
634
|
+
editAnnotationId: null,
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return false;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
/** Arrows step by word start; shift holds the anchor; the caret flows across blocks. */
|
|
645
|
+
const moveCaretHorizontal = (delta: 1 | -1, extend: boolean): void => {
|
|
646
|
+
const text = blockText(head.blockIndex);
|
|
647
|
+
const target =
|
|
648
|
+
delta === 1 ? nextWordStart(text, head.char) : previousWordStart(text, head.char);
|
|
649
|
+
const moveTo = (blockIndex: number, char: number): void => {
|
|
650
|
+
const next = { blockIndex, char };
|
|
651
|
+
|
|
652
|
+
setCursor(blockIndex);
|
|
653
|
+
setCaret({ head: next, anchor: extend ? caret.anchor : next });
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
if (target !== null) return moveTo(head.blockIndex, target);
|
|
657
|
+
if (extend && head.char !== (delta === 1 ? text.length : 0)) {
|
|
658
|
+
// the block edge first, then the next step crosses into the neighbour
|
|
659
|
+
return moveTo(head.blockIndex, delta === 1 ? text.length : 0);
|
|
660
|
+
}
|
|
661
|
+
// the neighbour is the next block the caret may rest on: headers are skipped
|
|
662
|
+
const neighbour = nearestAnnotatable(source, head.blockIndex, delta);
|
|
663
|
+
|
|
664
|
+
if (neighbour === head.blockIndex) return;
|
|
665
|
+
if (delta === 1) return moveTo(neighbour, 0);
|
|
666
|
+
const previousText = blockText(neighbour);
|
|
667
|
+
|
|
668
|
+
moveTo(
|
|
669
|
+
neighbour,
|
|
670
|
+
extend ? previousText.length : (previousWordStart(previousText, previousText.length) ?? 0),
|
|
671
|
+
);
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
const handleCaretKey = (key: KeyEvent): boolean => {
|
|
675
|
+
const vertical = verticalCaretDelta(key);
|
|
676
|
+
|
|
677
|
+
if (vertical !== 0) {
|
|
678
|
+
const nextBlock = nearestAnnotatable(source, cursor, vertical);
|
|
679
|
+
const nextHead = verticalTextPosition(head, vertical) ?? {
|
|
680
|
+
blockIndex: nextBlock,
|
|
681
|
+
char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
setCaret({
|
|
685
|
+
head: nextHead,
|
|
686
|
+
anchor: key.shift ? caret.anchor : nextHead,
|
|
687
|
+
});
|
|
688
|
+
setFocusedDiscussion(null);
|
|
689
|
+
setCursor(nextHead.blockIndex);
|
|
690
|
+
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
if (key.name === "right" || (key.ctrl && key.name === "l")) {
|
|
694
|
+
moveCaretHorizontal(1, Boolean(key.shift || key.meta || key.ctrl));
|
|
695
|
+
|
|
696
|
+
return true;
|
|
697
|
+
}
|
|
698
|
+
if (key.name === "left" || (key.ctrl && key.name === "h")) {
|
|
699
|
+
moveCaretHorizontal(-1, Boolean(key.shift || key.meta || key.ctrl));
|
|
700
|
+
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return false;
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
/** A printable starts a comment: edit my trailing, reply, or a new discussion. */
|
|
708
|
+
const startTyping = (key: KeyEvent): void => {
|
|
709
|
+
const sequence = printableSequence(key);
|
|
710
|
+
|
|
711
|
+
if (!sequence) return;
|
|
712
|
+
const discussion = discussions.find((candidate) => candidate.key === focusedDiscussion);
|
|
713
|
+
|
|
714
|
+
if (discussion) {
|
|
715
|
+
const last = discussion.annotations.at(-1)!;
|
|
716
|
+
const editingOwn = last.author === undefined;
|
|
717
|
+
|
|
718
|
+
return openCompose({
|
|
719
|
+
blockIndex: discussion.blockIndex,
|
|
720
|
+
discussionKey: discussion.key,
|
|
721
|
+
span: null,
|
|
722
|
+
seed: editingOwn ? `${last.body}${sequence}` : sequence,
|
|
723
|
+
editAnnotationId: editingOwn ? last.id : null,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
openNewCompose(sequence);
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
useKeyboard((key) => {
|
|
730
|
+
if (suspended) return;
|
|
731
|
+
if (key.ctrl && key.name === "q") return onExit();
|
|
732
|
+
const activeCompose = composeRef.current;
|
|
733
|
+
|
|
734
|
+
if (activeCompose) return handleComposeKey(key, activeCompose);
|
|
735
|
+
if (key.name === "escape") {
|
|
736
|
+
// from type mode esc only enters nav, so a held mark survives for `c`
|
|
737
|
+
if (!navModeRef.current) return setNavMode(true);
|
|
738
|
+
if (focusedDiscussion !== null) setFocusedDiscussion(null);
|
|
739
|
+
|
|
740
|
+
return collapseCaret();
|
|
741
|
+
}
|
|
742
|
+
if (navModeRef.current) {
|
|
743
|
+
if (handleCaretKey(key)) return;
|
|
744
|
+
if (key.name === "z") return toggleFoldAtCursor();
|
|
745
|
+
if (onNavCommand?.(key, heldSpan)) return;
|
|
746
|
+
if (key.name === "c") {
|
|
747
|
+
setNavMode(false);
|
|
748
|
+
|
|
749
|
+
return openNewCompose("");
|
|
750
|
+
}
|
|
751
|
+
if (printableSequence(key)) {
|
|
752
|
+
setNavMode(false);
|
|
753
|
+
startTyping(key);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (handleDiscussionVerb(key)) return;
|
|
759
|
+
if (handleCaretKey(key)) return;
|
|
760
|
+
startTyping(key);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// ── the cards the view slots under its lines ──
|
|
764
|
+
|
|
765
|
+
// one composer element (keyed by seed so slash acceptance remounts it);
|
|
766
|
+
// the slash palette renders BELOW the whole card, not inside it
|
|
767
|
+
const composerNode = compose ? (
|
|
768
|
+
<Composer
|
|
769
|
+
key={compose.seed}
|
|
770
|
+
seed={compose.seed}
|
|
771
|
+
glyph="●"
|
|
772
|
+
tokens={tokens}
|
|
773
|
+
onSave={saveComment}
|
|
774
|
+
onReady={() => (composerReady.current = true)}
|
|
775
|
+
onInput={(text, caret) => {
|
|
776
|
+
setDraft(text);
|
|
777
|
+
setCaretOffset(caret);
|
|
778
|
+
}}
|
|
779
|
+
/>
|
|
780
|
+
) : null;
|
|
781
|
+
// the palette list below the composer while the caret is on a "/word"; null otherwise, so it pushes
|
|
782
|
+
const paletteNode = (
|
|
783
|
+
<ComposerPalette
|
|
784
|
+
key="composer-palette"
|
|
785
|
+
slashActive={slashActive}
|
|
786
|
+
slashItems={slashItems}
|
|
787
|
+
slashIndex={slashIndex}
|
|
788
|
+
tokens={tokens}
|
|
789
|
+
/>
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
const foldedSummaryFor = (discussion: Discussion): React.ReactNode => (
|
|
793
|
+
<box key={discussion.key} style={{ flexDirection: "row", marginTop: 1, marginLeft: 2 }}>
|
|
794
|
+
<text fg={tokens.textDim}>
|
|
795
|
+
{`○ ${discussion.annotations.length} comment${discussion.annotations.length === 1 ? "" : "s"} ›`}
|
|
796
|
+
</text>
|
|
797
|
+
</box>
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
const discussionCardFor = (discussion: Discussion, composingHere: boolean): React.ReactNode => {
|
|
801
|
+
const segments: EdgeSegment[] = discussion.annotations.map((annotation) => {
|
|
802
|
+
const editingHere =
|
|
803
|
+
composingHere && compose?.editAnnotationId === annotation.id && composerNode !== null;
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
color: annotation.author === undefined ? palette.cardEdge : tokens.text,
|
|
807
|
+
node: editingHere ? (
|
|
808
|
+
composerNode
|
|
809
|
+
) : (
|
|
810
|
+
<CommentRow
|
|
811
|
+
key={annotation.id}
|
|
812
|
+
annotation={annotation}
|
|
813
|
+
tokens={tokens}
|
|
814
|
+
authorLabel={resolveAuthorLabel?.(annotation)}
|
|
815
|
+
/>
|
|
816
|
+
),
|
|
817
|
+
};
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
if (composingHere && compose?.editAnnotationId === null && composerNode) {
|
|
821
|
+
segments.push({ color: palette.cardEdge, node: composerNode });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return (
|
|
825
|
+
<DiscussionCard
|
|
826
|
+
key={discussion.key}
|
|
827
|
+
segments={segments}
|
|
828
|
+
tokens={tokens}
|
|
829
|
+
focused={focusedDiscussion === discussion.key}
|
|
830
|
+
onFocus={() => {
|
|
831
|
+
if (composeRef.current?.discussionKey === discussion.key) return;
|
|
832
|
+
blurSaveCompose();
|
|
833
|
+
setFocusedDiscussion(discussion.key);
|
|
834
|
+
setCursor(discussion.blockIndex);
|
|
835
|
+
}}
|
|
836
|
+
/>
|
|
837
|
+
);
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const rangesFor = (blockIndex: number): MarkRange[] => {
|
|
841
|
+
const textLength = textLengthOf(blockIndex);
|
|
842
|
+
const ranges: MarkRange[] = [];
|
|
843
|
+
const pushSpan = (span: TextSpan | null): void => {
|
|
844
|
+
const range = span ? spanRangeInBlock(span, blockIndex, textLength) : null;
|
|
845
|
+
|
|
846
|
+
if (range) ranges.push(range);
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
for (const discussion of discussions) pushSpan(discussion.span);
|
|
850
|
+
if (compose) pushSpan(compose.span);
|
|
851
|
+
if (caretIsSelection && !compose) pushSpan(heldSpan);
|
|
852
|
+
// the idle caret cell sits at the head, also at the end of a held mark;
|
|
853
|
+
// an open card takes the cursor with it, so no cell is painted then
|
|
854
|
+
if (blockIndex === head.blockIndex && !compose && textLength > 0) {
|
|
855
|
+
const cell = Math.max(0, Math.min(textLength - 1, head.char));
|
|
856
|
+
|
|
857
|
+
ranges.push({ start: cell, end: cell + 1, caretOnly: true });
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return ranges;
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
const cardsAfterLine = (
|
|
864
|
+
blockIndex: number,
|
|
865
|
+
line: VisualLine,
|
|
866
|
+
isLastLine: boolean,
|
|
867
|
+
): React.ReactNode[] => {
|
|
868
|
+
const endsInLine = (end: number): boolean => end - 1 >= line.start && end - 1 < line.end;
|
|
869
|
+
const nodes: React.ReactNode[] = [];
|
|
870
|
+
const composeHere = compose && compose.blockIndex === blockIndex;
|
|
871
|
+
const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
|
|
872
|
+
const composeAnchoredHere =
|
|
873
|
+
newComposeHere && (compose!.span ? endsInLine(compose!.span.end.char) : isLastLine);
|
|
874
|
+
const composeStart = compose?.span?.start;
|
|
875
|
+
|
|
876
|
+
const pushComposeCard = (): void => {
|
|
877
|
+
nodes.push(
|
|
878
|
+
<DiscussionCard
|
|
879
|
+
key="compose-new"
|
|
880
|
+
tokens={tokens}
|
|
881
|
+
segments={[{ color: palette.cardEdge, node: composerNode }]}
|
|
882
|
+
/>,
|
|
883
|
+
);
|
|
884
|
+
nodes.push(paletteNode);
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
// the draft sorts in by its span start, the same order discussionsFrom lands it on save, so a
|
|
888
|
+
// new comment sits in its final position while typing instead of appending to the stack
|
|
889
|
+
let composePending = composeAnchoredHere;
|
|
890
|
+
|
|
891
|
+
for (const discussion of discussions) {
|
|
892
|
+
if (discussion.blockIndex !== blockIndex || !endsInLine(discussion.span.end.char)) continue;
|
|
893
|
+
if (
|
|
894
|
+
composePending &&
|
|
895
|
+
composeStart &&
|
|
896
|
+
comparePositions(composeStart, discussion.span.start) < 0
|
|
897
|
+
) {
|
|
898
|
+
pushComposeCard();
|
|
899
|
+
composePending = false;
|
|
900
|
+
}
|
|
901
|
+
const composingHere = Boolean(composeHere && compose.discussionKey === discussion.key);
|
|
902
|
+
|
|
903
|
+
if (folded.has(discussion.key) && !composingHere) {
|
|
904
|
+
nodes.push(foldedSummaryFor(discussion));
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
nodes.push(discussionCardFor(discussion, composingHere));
|
|
908
|
+
if (composingHere) nodes.push(paletteNode);
|
|
909
|
+
}
|
|
910
|
+
if (composePending) pushComposeCard();
|
|
911
|
+
|
|
912
|
+
return nodes;
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
const registerLine =
|
|
916
|
+
(blockIndex: number, lineIndex: number, line: VisualLine) =>
|
|
917
|
+
(renderable: TextRenderable | null): void => {
|
|
918
|
+
const key = `${blockIndex}:${lineIndex}`;
|
|
919
|
+
|
|
920
|
+
if (renderable) {
|
|
921
|
+
lineRenderables.current.set(key, {
|
|
922
|
+
blockIndex,
|
|
923
|
+
start: line.start,
|
|
924
|
+
end: line.end,
|
|
925
|
+
renderable,
|
|
926
|
+
});
|
|
927
|
+
} else lineRenderables.current.delete(key);
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
const onLineMouseDown = (event: TerminalMouseEvent): void => {
|
|
931
|
+
blurSaveCompose();
|
|
932
|
+
const pressed = positionAt(allGeometry(), event.x, event.y);
|
|
933
|
+
|
|
934
|
+
if (!pressed || !source.annotatable(pressed.blockIndex)) return;
|
|
935
|
+
setNavMode(false);
|
|
936
|
+
const stamp = { time: Date.now(), x: event.x, y: event.y };
|
|
937
|
+
const wordMode = isDoubleClick(lastClick.current, stamp);
|
|
938
|
+
|
|
939
|
+
lastClick.current = stamp;
|
|
940
|
+
const word = wordMode ? wordRangeAt(blockText(pressed.blockIndex), pressed.char) : null;
|
|
941
|
+
const anchor = word ? { blockIndex: pressed.blockIndex, char: word.start } : pressed;
|
|
942
|
+
const pressHead = word ? { blockIndex: pressed.blockIndex, char: word.end } : pressed;
|
|
943
|
+
|
|
944
|
+
dragging.current = { wordMode, anchor, head: pressHead };
|
|
945
|
+
setFocusedDiscussion(null);
|
|
946
|
+
setCursor(pressed.blockIndex);
|
|
947
|
+
setCaret({ head: pressHead, anchor });
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
// an opening card shifts the layout, so the block it belongs to is revealed
|
|
951
|
+
// again; a discussion focused from the rail is scrolled into view the same way
|
|
952
|
+
const revealBlockIndex =
|
|
953
|
+
compose?.blockIndex ??
|
|
954
|
+
discussions.find((discussion) => discussion.key === focusedDiscussion)?.blockIndex ??
|
|
955
|
+
cursor;
|
|
956
|
+
|
|
957
|
+
return {
|
|
958
|
+
palette,
|
|
959
|
+
discussions,
|
|
960
|
+
cursor,
|
|
961
|
+
head,
|
|
962
|
+
compose,
|
|
963
|
+
navMode,
|
|
964
|
+
focusedDiscussion,
|
|
965
|
+
revealBlockIndex,
|
|
966
|
+
spanQuote,
|
|
967
|
+
registerLine,
|
|
968
|
+
onLineMouseDown,
|
|
969
|
+
rootMouseProps: { onMouseDrag: handleRootDrag, onMouseDragEnd: endDrag, onMouseUp: endDrag },
|
|
970
|
+
rangesFor,
|
|
971
|
+
cardsAfterLine,
|
|
972
|
+
jumpToDiscussion,
|
|
973
|
+
blurSaveCompose,
|
|
974
|
+
};
|
|
975
|
+
}
|