@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.100

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.
Files changed (355) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +42 -34
  8. package/src/annotations/annotation-palette.ts +62 -0
  9. package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
  10. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  11. package/src/annotations/components/AnnotationCards.tsx +364 -0
  12. package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
  13. package/src/annotations/components/DiscussionMarkerRail.tsx +186 -0
  14. package/src/annotations/discussions.ts +77 -0
  15. package/src/annotations/thread-selection.test.ts +210 -0
  16. package/src/annotations/thread-selection.ts +242 -0
  17. package/src/annotations/use-annotation-surface.tsx +1238 -0
  18. package/src/app/App.appearance.test.tsx +53 -0
  19. package/src/{App.autoclose.test.tsx → app/App.autoclose.test.tsx} +77 -51
  20. package/src/app/App.deleted-scroll.test.tsx +187 -0
  21. package/src/app/App.diff.test.tsx +313 -0
  22. package/src/app/App.export.test.tsx +105 -0
  23. package/src/app/App.focus.test.ts +89 -0
  24. package/src/app/App.identity.test.tsx +84 -0
  25. package/src/app/App.plan-render.test.tsx +209 -0
  26. package/src/app/App.plan-review.test.tsx +428 -0
  27. package/src/app/App.prototype-markdown.test.tsx +128 -0
  28. package/src/app/App.prototype.test.tsx +176 -0
  29. package/src/app/App.readonly.test.tsx +210 -0
  30. package/src/app/App.regression.test.tsx +99 -0
  31. package/src/app/App.rendering.test.tsx +134 -0
  32. package/src/app/App.share-dialog.test.tsx +177 -0
  33. package/src/app/App.submit-confirm.test.tsx +267 -0
  34. package/src/app/App.test.tsx +496 -0
  35. package/src/app/App.thread-agent.test.ts +12 -0
  36. package/src/app/App.toast.test.tsx +124 -0
  37. package/src/app/App.tsx +1650 -0
  38. package/src/app/App.visual.test.tsx +59 -0
  39. package/src/app/App.walk.test.tsx +299 -0
  40. package/src/app/App.workbench.test.tsx +1149 -0
  41. package/src/app/app-key-state.ts +61 -0
  42. package/src/app/app-screens.tsx +606 -0
  43. package/src/app/app-view-model.ts +168 -0
  44. package/src/app/components/AppHeader.stories.tsx +50 -0
  45. package/src/app/components/AppHeader.tsx +81 -0
  46. package/src/app/components/AppShell.brand.test.tsx +58 -0
  47. package/src/app/components/AppShell.extensions.test.tsx +191 -0
  48. package/src/app/components/AppShell.stories.tsx +89 -0
  49. package/src/app/components/AppShell.tsx +358 -0
  50. package/src/app/components/AppShell.zoom.test.tsx +149 -0
  51. package/src/app/components/Breadcrumb.stories.tsx +19 -0
  52. package/src/app/components/Breadcrumb.tsx +45 -0
  53. package/src/app/components/MenuBar.stories.tsx +28 -0
  54. package/src/app/components/MenuBar.tsx +44 -0
  55. package/src/app/components/PaneGrid.stories.tsx +84 -0
  56. package/src/app/components/PaneGrid.tsx +128 -0
  57. package/src/app/components/PanelColumn.stories.tsx +25 -0
  58. package/src/app/components/PanelColumn.tsx +93 -0
  59. package/src/app/components/ShellHeader.stories.tsx +50 -0
  60. package/src/app/components/ShellHeader.tsx +137 -0
  61. package/src/app/components/WorkspacePanels.stories.tsx +36 -0
  62. package/src/app/components/WorkspacePanels.tsx +120 -0
  63. package/src/app/components/pages/Workspace.stories.tsx +181 -0
  64. package/src/app/intent-dispatch.test.ts +728 -0
  65. package/src/app/intent-dispatch.ts +687 -0
  66. package/src/app/launch-layout.test.ts +78 -0
  67. package/src/app/launch-layout.ts +57 -0
  68. package/src/app/ready-signal.test.ts +85 -0
  69. package/src/app/ready-signal.ts +52 -0
  70. package/src/app/run.ts +109 -0
  71. package/src/{serve.test.ts → app/serve.test.ts} +24 -12
  72. package/src/app/serve.ts +111 -0
  73. package/src/app/version.ts +5 -0
  74. package/src/appearance/components/theme-context.stories.tsx +46 -0
  75. package/src/appearance/components/theme-context.tsx +37 -0
  76. package/src/appearance/theme-presets.test.ts +51 -0
  77. package/src/appearance/theme-presets.ts +219 -0
  78. package/src/appearance/theme.ts +112 -0
  79. package/src/appearance/visual/contrast.test.ts +35 -0
  80. package/src/appearance/visual/contrast.ts +33 -0
  81. package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
  82. package/src/diff/components/AnnotatableFileView.test.tsx +157 -0
  83. package/src/diff/components/AnnotatableFileView.tsx +141 -0
  84. package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
  85. package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
  86. package/src/diff/components/DiffContentView.scroll.test.tsx +434 -0
  87. package/src/diff/components/DiffContentView.split.test.tsx +140 -0
  88. package/src/diff/components/DiffContentView.stories.tsx +80 -0
  89. package/src/diff/components/DiffContentView.syntax.test.tsx +82 -0
  90. package/src/diff/components/DiffContentView.tsx +1055 -0
  91. package/src/diff/components/WalkWizard.stories.tsx +100 -0
  92. package/src/diff/components/WalkWizard.tsx +146 -0
  93. package/src/diff/components/diff-content-view-layout.test.ts +198 -0
  94. package/src/diff/components/diff-content-view-layout.ts +84 -0
  95. package/src/diff/diff-fold.test.ts +78 -0
  96. package/src/diff/diff-fold.ts +126 -0
  97. package/src/diff/diff-hunk-curate.test.ts +315 -0
  98. package/src/diff/diff-hunk-curate.ts +152 -0
  99. package/src/diff/diff-intraline.test.ts +239 -0
  100. package/src/diff/diff-intraline.ts +238 -0
  101. package/src/diff/diff-syntax.test.ts +125 -0
  102. package/src/diff/diff-syntax.ts +186 -0
  103. package/src/diff/split-diff.test.ts +110 -0
  104. package/src/diff/split-diff.ts +144 -0
  105. package/src/diff/view-diff.test.ts +325 -0
  106. package/src/diff/view-diff.ts +522 -0
  107. package/src/diff/walk.test.ts +121 -0
  108. package/src/diff/walk.ts +73 -0
  109. package/src/index.ts +31 -2
  110. package/src/integrations/attribution.test.ts +31 -0
  111. package/src/integrations/attribution.ts +42 -0
  112. package/src/integrations/client-extension-registry.test.tsx +61 -0
  113. package/src/integrations/client-extension-registry.ts +159 -0
  114. package/src/integrations/clipboard.test.ts +23 -0
  115. package/src/integrations/clipboard.ts +33 -0
  116. package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
  117. package/src/integrations/components/ExtensionZone.tsx +145 -0
  118. package/src/integrations/components/ShareDialog.stories.tsx +121 -0
  119. package/src/integrations/components/ShareDialog.tsx +550 -0
  120. package/src/integrations/components/share-dialog-store.ts +55 -0
  121. package/src/integrations/editor.test.ts +149 -0
  122. package/src/integrations/editor.ts +160 -0
  123. package/src/integrations/github-identity.test.ts +58 -0
  124. package/src/integrations/github-identity.ts +41 -0
  125. package/src/integrations/herdr.test.ts +43 -0
  126. package/src/integrations/herdr.ts +38 -0
  127. package/src/integrations/integrations.test.ts +41 -0
  128. package/src/integrations/integrations.ts +32 -0
  129. package/src/integrations/pasted-image.test.ts +67 -0
  130. package/src/integrations/pasted-image.ts +60 -0
  131. package/src/integrations/share.ts +238 -0
  132. package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
  133. package/src/keyboard/components/CompletionOverlay.tsx +75 -0
  134. package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
  135. package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
  136. package/src/keyboard/components/HandleChipsInput.tsx +143 -0
  137. package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
  138. package/src/keyboard/components/KeybindsDialog.tsx +103 -0
  139. package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
  140. package/src/keyboard/components/NavModeHint.tsx +31 -0
  141. package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
  142. package/src/keyboard/components/SlashComposer.tsx +87 -0
  143. package/src/keyboard/key-bindings.test.ts +156 -0
  144. package/src/keyboard/key-bindings.ts +459 -0
  145. package/src/keyboard/keymap.test.ts +611 -0
  146. package/src/keyboard/keymap.ts +395 -0
  147. package/src/keyboard/skills.test.ts +74 -0
  148. package/src/keyboard/skills.ts +75 -0
  149. package/src/keyboard/slash-palette.test.ts +103 -0
  150. package/src/keyboard/slash-palette.ts +129 -0
  151. package/src/keyboard/thread-chords.test.ts +79 -0
  152. package/src/keyboard/thread-chords.ts +163 -0
  153. package/src/keyboard/use-shared-keyboard.ts +55 -0
  154. package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  155. package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
  156. package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
  157. package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
  158. package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
  159. package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
  160. package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
  161. package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
  162. package/src/markdown/components/ThreadView.stories.tsx +26 -0
  163. package/src/markdown/components/ThreadView.tsx +560 -0
  164. package/src/markdown/mark-runs.ts +121 -0
  165. package/src/markdown/markdown-editor-syntax-style.ts +50 -0
  166. package/src/markdown/markdown-grid.test.ts +101 -0
  167. package/src/markdown/markdown-grid.ts +203 -0
  168. package/src/markdown/markdown-highlight.test.ts +61 -0
  169. package/src/markdown/markdown-highlight.ts +124 -0
  170. package/src/markdown/thread-code-syntax.ts +45 -0
  171. package/src/markdown/use-thread-body-editing.tsx +54 -0
  172. package/src/markdown/view-plan.regression.test.ts +60 -0
  173. package/src/markdown/view-plan.test.ts +545 -0
  174. package/src/markdown/view-plan.ts +1009 -0
  175. package/src/performance/otlp-trace.test.ts +119 -0
  176. package/src/performance/otlp-trace.ts +134 -0
  177. package/src/performance/perf-report.ts +18 -0
  178. package/src/performance/perf-timings.test.ts +18 -0
  179. package/src/performance/perf-timings.ts +39 -0
  180. package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
  181. package/src/prototype/components/PrototypeContentView.tsx +442 -0
  182. package/src/prototype/prototype-browser.test.ts +81 -0
  183. package/src/prototype/prototype-browser.ts +321 -0
  184. package/src/prototype/prototype-pixels.tsx +35 -0
  185. package/src/settings/components/SettingsDialog.stories.tsx +117 -0
  186. package/src/settings/components/SettingsDialog.tsx +183 -0
  187. package/src/settings/components/SettingsRows.stories.tsx +18 -0
  188. package/src/settings/components/SettingsRows.tsx +112 -0
  189. package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
  190. package/src/settings/components/quick-actions-editor.test.tsx +120 -0
  191. package/src/settings/components/quick-actions-editor.tsx +222 -0
  192. package/src/settings/config-identity.test.ts +78 -0
  193. package/src/settings/config.test.ts +676 -0
  194. package/src/settings/config.ts +628 -0
  195. package/src/settings/use-settings-dialog.tsx +351 -0
  196. package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
  197. package/src/stories/stories-app.tsx +196 -0
  198. package/src/stories/stories.test.tsx +74 -0
  199. package/src/stories/story-fixtures.ts +102 -0
  200. package/src/stories/story-tree.test.ts +108 -0
  201. package/src/stories/story.ts +154 -0
  202. package/src/terminal/ansi-screen.test.ts +28 -0
  203. package/src/terminal/ansi-screen.ts +72 -0
  204. package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
  205. package/src/terminal/components/AnsiScreen.tsx +28 -0
  206. package/src/terminal/components/terminal-pane.test.tsx +40 -0
  207. package/src/terminal/components/terminal-pane.ts +196 -0
  208. package/src/terminal/ghostty-terminal.test.ts +85 -0
  209. package/src/terminal/ghostty-terminal.ts +197 -0
  210. package/src/terminal/kitty-image.test.ts +66 -0
  211. package/src/terminal/kitty-image.ts +137 -0
  212. package/src/terminal/pty.test.ts +81 -0
  213. package/src/terminal/pty.ts +246 -0
  214. package/src/testing/test-support.test.ts +264 -0
  215. package/src/testing/test-support.ts +367 -0
  216. package/src/thread/agent-continuation.test.ts +32 -0
  217. package/src/thread/agent-continuation.ts +13 -0
  218. package/src/thread/agent-transcript.test.ts +161 -0
  219. package/src/thread/agent-transcript.ts +336 -0
  220. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  221. package/src/thread/components/AgentConfigControls.test.tsx +112 -0
  222. package/src/thread/components/AgentConfigControls.tsx +114 -0
  223. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  224. package/src/thread/components/AgentThreadPane.test.tsx +829 -0
  225. package/src/thread/components/AgentThreadPane.tsx +367 -0
  226. package/src/thread/components/ThreadFooter.stories.tsx +30 -0
  227. package/src/thread/components/ThreadFooter.test.tsx +49 -0
  228. package/src/thread/components/ThreadFooter.tsx +77 -0
  229. package/src/thread/components/ThreadTree.stories.tsx +46 -0
  230. package/src/thread/components/ThreadTree.test.tsx +41 -0
  231. package/src/thread/components/ThreadTree.tsx +267 -0
  232. package/src/thread/components/thread-tree.test.ts +137 -0
  233. package/src/thread/components/thread-tree.ts +112 -0
  234. package/src/thread/thread-controller.curate.test.ts +502 -0
  235. package/src/thread/thread-controller.derivation.test.ts +62 -0
  236. package/src/thread/thread-controller.live-diff.test.ts +357 -0
  237. package/src/thread/thread-controller.optimistic.test.ts +69 -0
  238. package/src/thread/thread-controller.share.test.ts +573 -0
  239. package/src/thread/thread-controller.switch-cache.test.ts +174 -0
  240. package/src/thread/thread-controller.tree.test.ts +263 -0
  241. package/src/thread/thread-controller.ts +2018 -0
  242. package/src/thread/tree-view.test.ts +128 -0
  243. package/src/thread/tree-view.ts +146 -0
  244. package/src/thread/use-thread-agent.test.tsx +208 -0
  245. package/src/thread/use-thread-agent.ts +111 -0
  246. package/src/ui/components/CodeBlock.stories.tsx +42 -0
  247. package/src/ui/components/CodeBlock.tsx +80 -0
  248. package/src/ui/components/ConfirmCard.stories.tsx +37 -0
  249. package/src/ui/components/ConfirmCard.tsx +151 -0
  250. package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
  251. package/src/ui/components/ConfirmDialog.tsx +72 -0
  252. package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
  253. package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
  254. package/src/ui/components/OverlayScrollbar.tsx +129 -0
  255. package/src/ui/components/PromptDialog.stories.tsx +36 -0
  256. package/src/ui/components/PromptDialog.test.tsx +65 -0
  257. package/src/ui/components/PromptDialog.tsx +99 -0
  258. package/src/ui/components/RootOverlay.stories.tsx +45 -0
  259. package/src/ui/components/RootOverlay.tsx +49 -0
  260. package/src/ui/components/ScrollArea.stories.tsx +20 -0
  261. package/src/ui/components/ScrollArea.tsx +61 -0
  262. package/src/ui/components/Toast.stories.tsx +19 -0
  263. package/src/ui/components/Toast.tsx +54 -0
  264. package/src/ui/components/Tooltip.stories.tsx +26 -0
  265. package/src/ui/components/Tooltip.tsx +72 -0
  266. package/src/ui/components/menu-control.stories.tsx +35 -0
  267. package/src/ui/components/menu-control.tsx +43 -0
  268. package/src/ui/components/primitives/Button.stories.tsx +32 -0
  269. package/src/ui/components/primitives/Button.tsx +57 -0
  270. package/src/ui/components/primitives/Card.stories.tsx +25 -0
  271. package/src/ui/components/primitives/Card.tsx +62 -0
  272. package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
  273. package/src/ui/components/primitives/Dialog.test.tsx +38 -0
  274. package/src/ui/components/primitives/Dialog.tsx +69 -0
  275. package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
  276. package/src/ui/components/primitives/DialogActions.tsx +39 -0
  277. package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
  278. package/src/ui/components/primitives/IconButton.tsx +58 -0
  279. package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
  280. package/src/ui/components/primitives/StatusBar.tsx +23 -0
  281. package/src/ui/components/primitives/Switch.stories.tsx +17 -0
  282. package/src/ui/components/primitives/Switch.tsx +32 -0
  283. package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
  284. package/src/ui/components/primitives/Tabs.tsx +101 -0
  285. package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
  286. package/src/ui/components/primitives/Toolbar.tsx +14 -0
  287. package/src/ui/components/primitives/Tree.stories.tsx +124 -0
  288. package/src/ui/components/primitives/Tree.tsx +133 -0
  289. package/src/ui/components/primitives/frame.test.ts +12 -0
  290. package/src/ui/components/primitives/frame.ts +17 -0
  291. package/src/ui/components/primitives/icons.ts +61 -0
  292. package/src/ui/components/primitives/tree-model.test.ts +99 -0
  293. package/src/ui/components/primitives/tree-model.ts +137 -0
  294. package/src/ui/components/syntax-highlight.ts +133 -0
  295. package/src/ui/components/truncate-text.ts +7 -0
  296. package/src/ui/components/truncate-title.test.ts +31 -0
  297. package/src/ui/components/truncate-title.ts +15 -0
  298. package/src/ui/use-frame-measure.ts +74 -0
  299. package/src/ui/use-terminal-virtualizer.ts +259 -0
  300. package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
  301. package/src/workbench/components/ChangesColumn.tsx +203 -0
  302. package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
  303. package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
  304. package/src/workbench/components/EditorGrid.stories.tsx +86 -0
  305. package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
  306. package/src/workbench/components/EditorGrid.tsx +544 -0
  307. package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
  308. package/src/workbench/components/FileContentsView.stories.tsx +16 -0
  309. package/src/workbench/components/FileContentsView.test.tsx +29 -0
  310. package/src/workbench/components/FileContentsView.tsx +100 -0
  311. package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
  312. package/src/workbench/components/GridTabContent.stories.tsx +59 -0
  313. package/src/workbench/components/GridTabContent.tsx +193 -0
  314. package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
  315. package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
  316. package/src/workbench/components/ProjectTreeView.tsx +151 -0
  317. package/src/workbench/components/TreePane.stories.tsx +106 -0
  318. package/src/workbench/components/TreePane.tsx +155 -0
  319. package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
  320. package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
  321. package/src/workbench/components/WelcomePlayground.tsx +138 -0
  322. package/src/workbench/components/editor-grid.test.ts +184 -0
  323. package/src/workbench/components/editor-grid.ts +281 -0
  324. package/src/workbench/components/file-tree.test.ts +60 -0
  325. package/src/workbench/components/file-tree.ts +112 -0
  326. package/src/workbench/use-changes-workbench.ts +205 -0
  327. package/src/workbench/use-remember-layout.ts +31 -0
  328. package/src/workbench/use-repo-changes.ts +91 -0
  329. package/src/workbench/workbench-snapshot.test.ts +99 -0
  330. package/src/workbench/workbench-snapshot.ts +59 -0
  331. package/src/App.diff.test.tsx +0 -99
  332. package/src/App.export.test.tsx +0 -88
  333. package/src/App.plan-review.test.tsx +0 -259
  334. package/src/App.readonly.test.tsx +0 -148
  335. package/src/App.rendering.test.tsx +0 -108
  336. package/src/App.submit-confirm.test.tsx +0 -183
  337. package/src/App.test.tsx +0 -218
  338. package/src/App.tsx +0 -1249
  339. package/src/config.test.ts +0 -116
  340. package/src/config.ts +0 -150
  341. package/src/editor.ts +0 -34
  342. package/src/herdr.test.ts +0 -68
  343. package/src/herdr.ts +0 -34
  344. package/src/keymap.test.ts +0 -270
  345. package/src/keymap.ts +0 -173
  346. package/src/run.ts +0 -30
  347. package/src/serve.ts +0 -75
  348. package/src/session-controller.ts +0 -398
  349. package/src/syntax.test.ts +0 -41
  350. package/src/syntax.ts +0 -88
  351. package/src/theme.ts +0 -46
  352. package/src/view-diff.test.ts +0 -55
  353. package/src/view-diff.ts +0 -86
  354. package/src/view.test.ts +0 -157
  355. package/src/view.ts +0 -364
@@ -0,0 +1,1055 @@
1
+ /**
2
+ * The diff review sheet: the code surface where discussions live inline under
3
+ * the lines they mark, exactly as the plan thread view does for prose. The caret,
4
+ * click/drag marks, type-to-comment, the "/" palette, and the discussion cards
5
+ * come from the shared annotation surface; this sheet paints the diff's own rows -
6
+ * file bands, hunk headers, a line-number gutter with the change sign, syntax and
7
+ * intra-line colors, curated-out lines struck through - one text per visual line
8
+ * so a drag can hit-test any row, unified or side by side.
9
+ */
10
+
11
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
12
+ import {
13
+ MacOSScrollAccel,
14
+ createTextAttributes,
15
+ type KeyEvent,
16
+ type ScrollBoxRenderable,
17
+ } from "@opentui/core";
18
+ import type { Annotation, Thread } from "@cueloop/schema";
19
+ import { diffRowText, fileChangeCounts, type DiffRow, type Mark } from "../view-diff";
20
+ import type { TextSpan } from "../../annotations/thread-selection";
21
+ import type { QuickAction } from "../../settings/config";
22
+ import type { Theme } from "../../appearance/theme";
23
+ import { useComponentTheme } from "../../appearance/components/theme-context";
24
+ import { useTooltip } from "../../ui/components/Tooltip";
25
+ import { NERD } from "../../ui/components/primitives/icons";
26
+ import { createIntralineResolver, type IntralineRun } from "../diff-intraline";
27
+ import { highlightDiffRows, type SyntaxSpan } from "../diff-syntax";
28
+ import { splitDiffRows, type SplitLine, type SplitRow } from "../split-diff";
29
+ import { UNDERLINE, type AnnotationPalette } from "../../annotations/annotation-palette";
30
+ import { lineMarkRanges, runsFor, wrapLines, type MarkRange } from "../../markdown/mark-runs";
31
+ import { useFrameMeasure } from "../../ui/use-frame-measure";
32
+ import { scrollBoxDragViewport, useTerminalVirtualizer } from "../../ui/use-terminal-virtualizer";
33
+ import { useAnnotationSurface, type LineSource } from "../../annotations/use-annotation-surface";
34
+ import { NavModeHint } from "../../keyboard/components/NavModeHint";
35
+ import { DiscussionMarkerRail } from "../../annotations/components/DiscussionMarkerRail";
36
+ import { coloredRowSpans } from "./diff-content-view-layout";
37
+
38
+ /** Shared empty map so an unresolved/stale highlight state is a stable value. */
39
+ const EMPTY_SYNTAX: Map<number, SyntaxSpan[]> = new Map();
40
+
41
+ /** Shared empty set so "nothing rejected" is a stable identity for renders. */
42
+ const EMPTY_REJECTED: Set<number> = new Set();
43
+
44
+ /** A rejected (curated-out) change row renders struck through and dimmed. */
45
+ const REJECTED_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
46
+
47
+ /** The split-view gutter before a code line: caret bar, four-digit line number, a space, the sign, a space. */
48
+ const GUTTER_COLUMNS = 8;
49
+
50
+ /** The stacked gutter around its two line-number columns: caret bar, the space between them, a space, the sign, a space. */
51
+ const UNIFIED_GUTTER_CHROME = 5;
52
+
53
+ /** The widest line number in the diff, so the stacked gutter's two columns stay as narrow as the file allows. */
54
+ function lineNumberWidth(rows: DiffRow[]): number {
55
+ let width = 1;
56
+
57
+ for (const row of rows) {
58
+ if (row.oldLine !== undefined) width = Math.max(width, String(row.oldLine).length);
59
+ if (row.newLine !== undefined) width = Math.max(width, String(row.newLine).length);
60
+ }
61
+
62
+ return width;
63
+ }
64
+
65
+ /** The per-file fold controls the file band renders; absent in read-only story renders. */
66
+ export interface DiffFoldControls {
67
+ isCollapsed: (file: string) => boolean;
68
+ isExpanded: (file: string) => boolean;
69
+ canExpand: (file: string) => boolean;
70
+ onToggleCollapse: (file: string) => void;
71
+ onToggleExpand: (file: string) => void;
72
+ onCopyPath: (file: string) => boolean | Promise<boolean>;
73
+ }
74
+
75
+ export interface DiffContentViewProps {
76
+ rows: DiffRow[];
77
+ session: Thread;
78
+ marks: Map<number, Mark[]>;
79
+ quickActions: QuickAction[];
80
+ observer: boolean;
81
+ commentsEnabled?: boolean;
82
+ resolved?: boolean;
83
+ suspended?: boolean;
84
+ onComposingChange?: (composing: boolean) => void;
85
+ onObserverBlocked?: (reason: "observer" | "resolved") => void;
86
+ onCursorChange?: (rowIndex: number) => void;
87
+ focusedAnnotationId?: string;
88
+ onFocusAnnotation?: (annotationId: string | undefined) => void;
89
+ onAnnotate: (span: TextSpan, body: string) => void;
90
+ onReply: (rootAnnotationId: string, body: string) => void;
91
+ onUpdateAnnotation: (id: string, body: string) => void;
92
+ onInvoke?: () => void;
93
+ annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
94
+ isAnnotationReadOnly?: (id: string) => boolean;
95
+ resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
96
+ onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
97
+ onExit: () => void;
98
+ rejectedRows?: Set<number>;
99
+ fold?: DiffFoldControls;
100
+ showFileCollapse?: boolean;
101
+ fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
102
+ split?: boolean;
103
+ fileView?: boolean;
104
+ theme?: Theme;
105
+ }
106
+
107
+ /** A row the caret can rest on and a comment can anchor to. */
108
+ function isCodeRow(row: DiffRow | undefined): boolean {
109
+ return row?.kind === "ctx" || row?.kind === "add" || row?.kind === "del";
110
+ }
111
+
112
+ /** The green +N red -N badge pinned to the file band's right edge. */
113
+ function FileCountsBadge({
114
+ stats,
115
+ tokens,
116
+ marginRight,
117
+ }: {
118
+ stats: { additions: number; deletions: number };
119
+ tokens: Theme;
120
+ marginRight: number;
121
+ }): React.ReactNode {
122
+ if (stats.additions === 0 && stats.deletions === 0) return null;
123
+
124
+ return (
125
+ <text style={{ flexShrink: 0, wrapMode: "none", marginRight }}>
126
+ {stats.additions > 0 ? (
127
+ <span fg={tokens.insertedForeground}>{`+${stats.additions}`}</span>
128
+ ) : null}
129
+ {stats.additions > 0 && stats.deletions > 0 ? <span> </span> : null}
130
+ {stats.deletions > 0 ? (
131
+ <span fg={tokens.deletedForeground}>{`-${stats.deletions}`}</span>
132
+ ) : null}
133
+ </text>
134
+ );
135
+ }
136
+
137
+ /** The file band's expand-all-lines toggle: the word "expand" stays put, its color carries the state
138
+ * (active white when the file is expanded, inactive gray when collapsed to changes) so nothing shifts. */
139
+ function ExpandToggle({
140
+ expanded,
141
+ onToggle,
142
+ tokens,
143
+ }: {
144
+ expanded: boolean;
145
+ onToggle: () => void;
146
+ tokens: Theme;
147
+ }): React.ReactNode {
148
+ return (
149
+ <box onMouseUp={onToggle} style={{ alignSelf: "center" }}>
150
+ <text fg={expanded ? tokens.text : tokens.textDim} style={{ wrapMode: "none" }}>
151
+ expand
152
+ </text>
153
+ </box>
154
+ );
155
+ }
156
+
157
+ /** Copy feedback stays at the control the user clicked. */
158
+ function CopyPathButton({
159
+ file,
160
+ onCopyPath,
161
+ marginRight,
162
+ tokens,
163
+ }: {
164
+ file: string;
165
+ onCopyPath: (file: string) => boolean | Promise<boolean>;
166
+ marginRight: number;
167
+ tokens: Theme;
168
+ }): React.ReactNode {
169
+ const { showTooltip, hideTooltip } = useTooltip();
170
+
171
+ return (
172
+ <box
173
+ onMouseUp={(event) => {
174
+ event.stopPropagation();
175
+ const { x, y } = event;
176
+
177
+ void Promise.resolve(onCopyPath(file)).then((copied) => {
178
+ showTooltip(copied ? "copied" : "copy failed", x, y);
179
+ });
180
+ }}
181
+ onMouseOver={(event) => {
182
+ showTooltip("Copy path", event.x, event.y);
183
+ }}
184
+ onMouseOut={hideTooltip}
185
+ style={{ flexShrink: 0, alignSelf: "center", marginRight }}
186
+ >
187
+ <text fg={tokens.textMuted}>{NERD.copy}</text>
188
+ </box>
189
+ );
190
+ }
191
+
192
+ /** A file band: clickable title and file actions between rules above and below. */
193
+ function FileBand({
194
+ row,
195
+ stats,
196
+ fold,
197
+ showFileCollapse,
198
+ tokens,
199
+ }: {
200
+ row: DiffRow;
201
+ stats: { additions: number; deletions: number } | undefined;
202
+ fold: DiffFoldControls | undefined;
203
+ showFileCollapse: boolean;
204
+ tokens: Theme;
205
+ }): React.ReactNode {
206
+ const { showTooltip, hideTooltip } = useTooltip();
207
+ const file = row.file;
208
+ const collapsed = fold?.isCollapsed(file) ?? false;
209
+ const expanded = fold?.isExpanded(file) ?? false;
210
+ const canExpand = fold?.canExpand(file) ?? false;
211
+
212
+ return (
213
+ <box style={{ borderStyle: "single", border: ["top", "bottom"], borderColor: tokens.border }}>
214
+ <box style={{ flexDirection: "row", justifyContent: "space-between" }}>
215
+ <box style={{ flexDirection: "row", flexShrink: 1, minWidth: 0 }}>
216
+ <box
217
+ id={`file-title-${file}`}
218
+ onMouseUp={
219
+ fold && showFileCollapse
220
+ ? (event) => {
221
+ event.stopPropagation();
222
+ fold.onToggleCollapse(file);
223
+ showTooltip(collapsed ? "collapse" : "uncollapse", event.x, event.y);
224
+ }
225
+ : undefined
226
+ }
227
+ onMouseOver={
228
+ fold && showFileCollapse
229
+ ? (event) => showTooltip(collapsed ? "uncollapse" : "collapse", event.x, event.y)
230
+ : undefined
231
+ }
232
+ onMouseOut={fold && showFileCollapse ? hideTooltip : undefined}
233
+ style={{ flexShrink: 1, minWidth: 0 }}
234
+ >
235
+ <text
236
+ fg={collapsed ? tokens.textDim : tokens.textMuted}
237
+ style={{ flexShrink: 1, minWidth: 0, wrapMode: "none" }}
238
+ >
239
+ {diffRowText(row)}
240
+ </text>
241
+ </box>
242
+ </box>
243
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
244
+ {stats ? (
245
+ <FileCountsBadge stats={stats} tokens={tokens} marginRight={fold ? 1 : 0} />
246
+ ) : null}
247
+ {fold ? (
248
+ <CopyPathButton
249
+ file={file}
250
+ onCopyPath={fold.onCopyPath}
251
+ marginRight={canExpand ? 1 : 0}
252
+ tokens={tokens}
253
+ />
254
+ ) : null}
255
+ {fold && canExpand ? (
256
+ <ExpandToggle
257
+ expanded={expanded}
258
+ onToggle={() => fold.onToggleExpand(file)}
259
+ tokens={tokens}
260
+ />
261
+ ) : null}
262
+ </box>
263
+ </box>
264
+ </box>
265
+ );
266
+ }
267
+
268
+ /** One code row rendered: its visual line rows, and the cards that hang under them. */
269
+ interface CodeRowNodes {
270
+ lines: React.ReactNode[];
271
+ cards: React.ReactNode[];
272
+ }
273
+
274
+ /** One side of a split pair rendered: its column of lines with its cards under them, or filler. */
275
+ interface SplitSideNodes {
276
+ node: React.ReactNode;
277
+ }
278
+
279
+ /**
280
+ * One item of the sheet's vertical layout: a base row (unified) or a split row, with the
281
+ * visual lines it occupies. Cards under a materialized row add to the real height; the
282
+ * model only has to be right enough for spacers and the caret reveal.
283
+ */
284
+ interface LayoutItem {
285
+ index: number;
286
+ height: number;
287
+ }
288
+
289
+ interface SheetLayout {
290
+ items: LayoutItem[];
291
+ offsets: number[];
292
+ total: number;
293
+ itemOfRow: number[];
294
+ }
295
+
296
+ /** Rows this many visual lines beyond the viewport stay materialized, so a scroll step never shows a gap. */
297
+ const OVERSCAN_LINES = 30;
298
+
299
+ /** A file band is three rows (rule, name, rule); a hunk header one. */
300
+ function headerHeight(row: DiffRow): number {
301
+ return row.kind === "file" ? 3 : 1;
302
+ }
303
+
304
+ function codeRowHeight(row: DiffRow, textWidth: number): number {
305
+ return wrapLines(diffRowText(row), textWidth).length;
306
+ }
307
+
308
+ function buildLayout(items: LayoutItem[], itemOfRow: number[]): SheetLayout {
309
+ const offsets: number[] = [];
310
+ let total = 0;
311
+
312
+ for (const item of items) {
313
+ offsets.push(total);
314
+ total += item.height;
315
+ }
316
+
317
+ return { items, offsets, total, itemOfRow };
318
+ }
319
+
320
+ function unifiedLayout(rows: DiffRow[], textWidth: number): SheetLayout {
321
+ const items = rows.map((row, index) => ({
322
+ index,
323
+ height: isCodeRow(row) ? codeRowHeight(row, textWidth) : headerHeight(row),
324
+ }));
325
+
326
+ return buildLayout(
327
+ items,
328
+ rows.map((_row, index) => index),
329
+ );
330
+ }
331
+
332
+ function splitLayout(splitRows: SplitRow[], rows: DiffRow[], textWidth: number): SheetLayout {
333
+ const itemOfRow: number[] = [];
334
+ const items = splitRows.map((pair, index) => {
335
+ if (pair.kind !== "pair") {
336
+ const row = rows[pair.rowIndex ?? -1];
337
+
338
+ if (pair.rowIndex !== undefined) itemOfRow[pair.rowIndex] = index;
339
+
340
+ return { index, height: row ? headerHeight(row) : 1 };
341
+ }
342
+ const sideHeight = (line: SplitLine | undefined): number =>
343
+ line ? codeRowHeight(line.row, textWidth) : 0;
344
+
345
+ if (pair.left) itemOfRow[pair.left.rowIndex] = index;
346
+ if (pair.right) itemOfRow[pair.right.rowIndex] = index;
347
+
348
+ return { index, height: Math.max(1, sideHeight(pair.left), sideHeight(pair.right)) };
349
+ });
350
+
351
+ return buildLayout(items, itemOfRow);
352
+ }
353
+
354
+ /** One painted stretch of a code line: its text with foreground, backdrop, and attributes. */
355
+ interface CodeSpan {
356
+ text: string;
357
+ fg: string;
358
+ bg: string | undefined;
359
+ attributes: number;
360
+ }
361
+
362
+ /**
363
+ * The spans of one visual line: per character, the syntax or intra-line color under
364
+ * the mark treatment (backdrop + underline) or the caret cell, coalesced where equal.
365
+ * A rejected row paints one dim struck-through run, so it reads as excluded regardless
366
+ * of its colors; a mark still shows through it.
367
+ */
368
+ function codeLineSpans(
369
+ lineText: string,
370
+ lineStart: number,
371
+ lineRanges: MarkRange[],
372
+ fgByColumn: string[],
373
+ emphasisBgByColumn: Array<string | undefined>,
374
+ rejected: boolean,
375
+ tokens: Theme,
376
+ palette: AnnotationPalette,
377
+ ): CodeSpan[] {
378
+ const spans: CodeSpan[] = [];
379
+ let column = 0;
380
+
381
+ for (const run of runsFor(lineText, lineRanges)) {
382
+ // a caret or a comment mark owns the whole run's backdrop; otherwise the changed-word tint (if any)
383
+ // shows per column and the row box paints the soft band behind the rest
384
+ const runBackground = run.caretOnly
385
+ ? palette.caretCell
386
+ : run.marked
387
+ ? palette.markBackdrop
388
+ : undefined;
389
+ const attributes = (run.marked ? UNDERLINE : 0) | (rejected ? REJECTED_ATTRIBUTES : 0);
390
+
391
+ for (const character of run.text) {
392
+ const fg = rejected ? tokens.textDim : (fgByColumn[lineStart + column] ?? tokens.textMuted);
393
+ const bg = runBackground ?? emphasisBgByColumn[lineStart + column];
394
+ const previous = spans[spans.length - 1];
395
+
396
+ if (
397
+ previous &&
398
+ previous.fg === fg &&
399
+ previous.bg === bg &&
400
+ previous.attributes === attributes
401
+ ) {
402
+ previous.text += character;
403
+ } else spans.push({ text: character, fg, bg, attributes });
404
+ column++;
405
+ }
406
+ }
407
+
408
+ return spans;
409
+ }
410
+
411
+ /** Per-column foreground of a row's text: intra-line change color over syntax over the base. */
412
+ function foregroundColumns(
413
+ text: string,
414
+ baseColor: string,
415
+ intraline: IntralineRun[] | undefined,
416
+ syntax: SyntaxSpan[] | undefined,
417
+ tokens: Theme,
418
+ ): string[] {
419
+ const columns: string[] = [];
420
+
421
+ for (const span of coloredRowSpans(text, intraline, syntax, baseColor, tokens)) {
422
+ // one color per code unit, matching the column model that indexes text by .length
423
+ columns.push(...Array.from({ length: span.text.length }, () => span.foreground));
424
+ }
425
+
426
+ return columns;
427
+ }
428
+
429
+ function rowSign(row: DiffRow): string {
430
+ return row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
431
+ }
432
+
433
+ function rowBaseColor(row: DiffRow, tokens: Theme): string {
434
+ return row.kind === "add"
435
+ ? tokens.insertedForeground
436
+ : row.kind === "del"
437
+ ? tokens.deletedForeground
438
+ : tokens.textMuted;
439
+ }
440
+
441
+ /** The soft band behind a whole added/removed row; context rows carry no band. */
442
+ function rowBackground(row: DiffRow, tokens: Theme): string | undefined {
443
+ return row.kind === "add"
444
+ ? tokens.insertedBackground
445
+ : row.kind === "del"
446
+ ? tokens.deletedBackground
447
+ : undefined;
448
+ }
449
+
450
+ /** The stronger tint behind an add/del row's gutter chip and its intra-line changed words. */
451
+ function rowEmphasisBackground(row: DiffRow, tokens: Theme): string | undefined {
452
+ return row.kind === "add"
453
+ ? tokens.insertedEmphasisBackground
454
+ : row.kind === "del"
455
+ ? tokens.deletedEmphasisBackground
456
+ : undefined;
457
+ }
458
+
459
+ /** Per-column emphasis backdrop: the changed words of a modified line, else none. */
460
+ function emphasisBackgroundColumns(
461
+ text: string,
462
+ intraline: IntralineRun[] | undefined,
463
+ emphasisBackground: string | undefined,
464
+ ): Array<string | undefined> {
465
+ const columns: Array<string | undefined> = Array.from({ length: text.length }, () => undefined);
466
+
467
+ if (!intraline || !emphasisBackground) return columns;
468
+ let offset = 0;
469
+
470
+ for (const run of intraline) {
471
+ if (run.changed)
472
+ for (let index = 0; index < run.text.length && offset + index < text.length; index++)
473
+ columns[offset + index] = emphasisBackground;
474
+ offset += run.text.length;
475
+ }
476
+
477
+ return columns;
478
+ }
479
+
480
+ /** Async tree-sitter highlights, discarded when they belong to superseded rows. */
481
+ function useSyntaxHighlights(rows: DiffRow[]): Map<number, SyntaxSpan[]> {
482
+ const [highlighted, setHighlighted] = useState<{
483
+ rows: DiffRow[];
484
+ byRow: Map<number, SyntaxSpan[]>;
485
+ }>({
486
+ rows,
487
+ byRow: EMPTY_SYNTAX,
488
+ });
489
+
490
+ useEffect(() => {
491
+ let active = true;
492
+
493
+ highlightDiffRows(rows).then(
494
+ (byRow) => {
495
+ if (active) setHighlighted({ rows, byRow });
496
+ },
497
+ (error) => {
498
+ // a highlighter torn down mid-init rejects after unmount (active === false): expected, silent.
499
+ // a rejection while still mounted is a real failure - surface it and leave the rows unhighlighted
500
+ if (active) console.error("diff syntax highlighting failed", error);
501
+ },
502
+ );
503
+
504
+ return () => {
505
+ active = false;
506
+ };
507
+ }, [rows]);
508
+
509
+ return highlighted.rows === rows ? highlighted.byRow : EMPTY_SYNTAX;
510
+ }
511
+
512
+ export function DiffContentView({
513
+ rows,
514
+ session,
515
+ marks,
516
+ quickActions,
517
+ observer,
518
+ commentsEnabled = true,
519
+ resolved = false,
520
+ suspended = false,
521
+ onComposingChange,
522
+ onObserverBlocked,
523
+ onCursorChange,
524
+ focusedAnnotationId,
525
+ onFocusAnnotation,
526
+ onAnnotate,
527
+ onReply,
528
+ onUpdateAnnotation,
529
+ onInvoke,
530
+ isAnnotationReadOnly,
531
+ annotationAction,
532
+ resolveAuthorLabel,
533
+ onNavCommand,
534
+ onExit,
535
+ rejectedRows = EMPTY_REJECTED,
536
+ fold,
537
+ showFileCollapse = true,
538
+ fileStats: fileStatsProp,
539
+ split = false,
540
+ fileView = false,
541
+ theme,
542
+ }: DiffContentViewProps): React.ReactNode {
543
+ const tokens = useComponentTheme(theme);
544
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
545
+ const wheelAcceleration = useMemo(
546
+ () => new MacOSScrollAccel({ A: 0.4, tau: 4, maxMultiplier: 3 }),
547
+ [],
548
+ );
549
+ const verticalStepRef = useRef<
550
+ (from: number, to: number, direction: -1 | 1, targetY?: number) => boolean
551
+ >(() => false);
552
+ const source: LineSource = {
553
+ count: rows.length,
554
+ textAt: (rowIndex) => diffRowText(rows[rowIndex]!),
555
+ annotatable: (rowIndex) => isCodeRow(rows[rowIndex]),
556
+ };
557
+ const outdatedIds = useMemo(
558
+ () =>
559
+ new Set(
560
+ [...marks.values()]
561
+ .flat()
562
+ .flatMap((mark) =>
563
+ mark.outdated && mark.annotationId !== undefined ? [mark.annotationId] : [],
564
+ ),
565
+ ),
566
+ [marks],
567
+ );
568
+ const displaySession = useMemo(
569
+ () =>
570
+ outdatedIds.size === 0
571
+ ? session
572
+ : {
573
+ ...session,
574
+ annotations: session.annotations.map((annotation) =>
575
+ outdatedIds.has(annotation.id) ? { ...annotation, orphan: true } : annotation,
576
+ ),
577
+ },
578
+ [outdatedIds, session],
579
+ );
580
+ const surface = useAnnotationSurface({
581
+ source,
582
+ session: displaySession,
583
+ marks,
584
+ quickActions,
585
+ tokens,
586
+ observer,
587
+ commentsEnabled,
588
+ resolved,
589
+ suspended,
590
+ onComposingChange,
591
+ onObserverBlocked,
592
+ onCursorChange,
593
+ onVerticalStep: (from, to, direction, targetY) =>
594
+ verticalStepRef.current(from, to, direction, targetY),
595
+ focusedAnnotationId,
596
+ onFocusAnnotation,
597
+ onAnnotate,
598
+ onReply,
599
+ onUpdateAnnotation,
600
+ onInvoke,
601
+ isAnnotationReadOnly,
602
+ annotationAction,
603
+ dragViewport: () => scrollBoxDragViewport(scrollRef.current),
604
+ resolveAuthorLabel: (annotation) => {
605
+ const label = resolveAuthorLabel?.(annotation);
606
+
607
+ return annotation.orphan ? `outdated${label ? ` - ${label}` : ""}` : label;
608
+ },
609
+ onNavCommand,
610
+ onExit,
611
+ });
612
+ const { palette } = surface;
613
+ const numberWidth = useMemo(() => lineNumberWidth(rows), [rows]);
614
+ const unifiedGutterColumns = numberWidth * 2 + UNIFIED_GUTTER_CHROME;
615
+ const intralineResolver = useMemo(() => createIntralineResolver(rows), [rows]);
616
+ const syntaxByRow = useSyntaxHighlights(rows);
617
+ const splitRows = useMemo(() => (split ? splitDiffRows(rows) : []), [split, rows]);
618
+ const localStats = useMemo(() => fileChangeCounts(rows), [rows]);
619
+ // base-row counts survive a collapse (folded rows drop the file's add/del rows); fall back locally
620
+ const fileStats = fileStatsProp ?? localStats;
621
+ const viewWidth = useFrameMeasure(
622
+ () => scrollRef.current?.content?.width ?? 0,
623
+ (left, right) => left === right,
624
+ 0,
625
+ );
626
+ // a file view shows one number; split gives each column its own gutter; stacked shows old and new
627
+ const textWidth = fileView
628
+ ? Math.max(0, viewWidth - (numberWidth + 2))
629
+ : split
630
+ ? Math.max(0, Math.floor((viewWidth - 1) / 2) - GUTTER_COLUMNS)
631
+ : Math.max(0, viewWidth - unifiedGutterColumns);
632
+
633
+ // Every visual line is its own text renderable (so a drag can hit-test it), and a large
634
+ // diff has tens of thousands of them - more native text buffers than the renderer can
635
+ // hold. So the sheet is virtual: only the viewport plus an overscan is mounted, each row
636
+ // estimated at its wrapped line count until its box (cards included) is measured.
637
+ const layout = useMemo(
638
+ () => (split ? splitLayout(splitRows, rows, textWidth) : unifiedLayout(rows, textWidth)),
639
+ [split, splitRows, rows, textWidth],
640
+ );
641
+ const virtual = useTerminalVirtualizer({
642
+ scrollbox: scrollRef,
643
+ count: layout.items.length,
644
+ estimateSize: (index) => layout.items[index]?.height ?? 1,
645
+ overscan: OVERSCAN_LINES,
646
+ });
647
+
648
+ useEffect(() => {
649
+ const scrollbox = scrollRef.current;
650
+
651
+ if (scrollbox) scrollbox.verticalScrollBar.scrollStep = 1;
652
+ }, []);
653
+
654
+ verticalStepRef.current = (from, to, direction, targetY) => {
655
+ const scrollbox = scrollRef.current;
656
+
657
+ if (!scrollbox) return false;
658
+ let distance: number;
659
+
660
+ if (targetY !== undefined) {
661
+ const top = scrollbox.viewport.y;
662
+ const bottom = top + scrollbox.viewport.height - 1;
663
+
664
+ distance = direction === 1 ? targetY - bottom : top - targetY;
665
+ } else {
666
+ if (from === to) return false;
667
+ const item = layout.itemOfRow[to];
668
+ const target = item === undefined ? undefined : virtual.startOfIndex(item);
669
+
670
+ if (target === undefined) return false;
671
+ const top = scrollbox.scrollTop;
672
+ const bottom = top + scrollbox.viewport.height - 1;
673
+
674
+ distance = direction === 1 ? target - bottom : top - target;
675
+ }
676
+
677
+ if (distance <= 0) return false;
678
+ // Let the caret commit before scrolling the next visual row into view.
679
+ if (distance === 1) return false;
680
+ scrollbox.scrollBy(direction);
681
+
682
+ return distance > 1;
683
+ };
684
+
685
+ // An opening card or focused discussion reveals its owning item. Ordinary caret
686
+ // movement reveals the exact visual line, since an item's first wrapped line may
687
+ // sit above the viewport while the caret is already on screen.
688
+ const revealItem = layout.itemOfRow[surface.revealBlockIndex];
689
+
690
+ useEffect(() => {
691
+ if (revealItem === undefined || (!surface.compose && !surface.focusedDiscussion)) return;
692
+ virtual.scrollToIndex(revealItem, surface.compose ? "end" : "auto");
693
+ // eslint-disable-next-line react-hooks/exhaustive-deps
694
+ }, [surface.revealBlockIndex, surface.compose, surface.focusedDiscussion]);
695
+
696
+ useLayoutEffect(() => {
697
+ if (revealItem === undefined || surface.compose || surface.focusedDiscussion) return;
698
+ const scrollbox = scrollRef.current;
699
+
700
+ if (!scrollbox) return;
701
+ const headY = surface.headVisualY();
702
+
703
+ if (headY !== undefined) {
704
+ const top = scrollbox.viewport.y;
705
+ const bottom = top + scrollbox.viewport.height - 1;
706
+
707
+ if (headY < top) scrollbox.scrollBy(-1);
708
+ else if (headY > bottom) scrollbox.scrollBy(1);
709
+
710
+ return;
711
+ }
712
+ const start = virtual.startOfIndex(revealItem);
713
+
714
+ if (start === undefined) return;
715
+ const top = scrollbox.scrollTop;
716
+ const bottom = top + scrollbox.viewport.height - 1;
717
+
718
+ if (start < top) virtual.scrollToOffset(start);
719
+ else if (start > bottom) virtual.scrollToOffset(start - scrollbox.viewport.height + 1);
720
+ // eslint-disable-next-line react-hooks/exhaustive-deps
721
+ }, [surface.revealBlockIndex, surface.head.char, surface.compose, surface.focusedDiscussion]);
722
+
723
+ /**
724
+ * The visual lines of one code row painted with gutter, colors, and marks; cards collected after.
725
+ * `paintMarks` false renders the line plain (no caret, no mark backdrop) - the split view uses it so
726
+ * a context row, shared by both columns, only carries the caret and marks on the change side.
727
+ */
728
+ const codeRowLines = (
729
+ row: DiffRow,
730
+ rowIndex: number,
731
+ textWidth: number,
732
+ keyPrefix: string,
733
+ paintMarks = true,
734
+ ): CodeRowNodes => {
735
+ const text = diffRowText(row);
736
+ const lines = wrapLines(text, textWidth);
737
+ const ranges = paintMarks ? surface.rangesFor(rowIndex) : [];
738
+ const fgByColumn = foregroundColumns(
739
+ text,
740
+ rowBaseColor(row, tokens),
741
+ intralineResolver.runsForRow(rowIndex),
742
+ syntaxByRow.get(rowIndex),
743
+ tokens,
744
+ );
745
+ const emphasisBgByColumn = emphasisBackgroundColumns(
746
+ text,
747
+ intralineResolver.runsForRow(rowIndex),
748
+ rowEmphasisBackground(row, tokens),
749
+ );
750
+ const rowBg = rowBackground(row, tokens);
751
+ const rejected = rejectedRows.has(rowIndex);
752
+ const isCaretRow = paintMarks && surface.head.blockIndex === rowIndex;
753
+ // split view carries one number per side; the stacked view shows the old and new numbers together
754
+ const splitLineNumber = row.kind === "del" ? row.oldLine : row.newLine;
755
+ const splitGutter = `${String(splitLineNumber ?? "").padStart(4, " ")} ${rowSign(row)} `;
756
+ const oldNumber =
757
+ row.kind === "add"
758
+ ? " ".repeat(numberWidth)
759
+ : String(row.oldLine ?? "").padStart(numberWidth);
760
+ const newNumber =
761
+ row.kind === "del"
762
+ ? " ".repeat(numberWidth)
763
+ : String(row.newLine ?? "").padStart(numberWidth);
764
+ const gutterFor = (lineIndex: number, isCaretLine: boolean): React.ReactNode => {
765
+ const bar = (
766
+ <span fg={isCaretLine ? tokens.accent : tokens.textDim}>{isCaretLine ? "▎" : " "}</span>
767
+ );
768
+
769
+ if (fileView) {
770
+ // a plain file has no old/new sides, so one line number reads like an ordinary file viewer
771
+ return (
772
+ <>
773
+ {bar}
774
+ {lineIndex === 0 ? (
775
+ <span fg={tokens.textDim}>{String(row.newLine ?? "").padStart(numberWidth)} </span>
776
+ ) : (
777
+ " ".repeat(numberWidth + 1)
778
+ )}
779
+ </>
780
+ );
781
+ }
782
+ if (split) {
783
+ return (
784
+ <>
785
+ {bar}
786
+ {lineIndex === 0 ? (
787
+ <>
788
+ <span fg={tokens.textDim}>{splitGutter.slice(0, 5)}</span>
789
+ <span fg={rowBaseColor(row, tokens)}>{splitGutter.slice(5)}</span>
790
+ </>
791
+ ) : (
792
+ " ".repeat(GUTTER_COLUMNS - 1)
793
+ )}
794
+ </>
795
+ );
796
+ }
797
+
798
+ // stacked: caret bar, old new (each tinted red/green on its side), then the change sign;
799
+ // the numbers sit on the row's soft band, only the changed code carries the brighter backdrop
800
+ return lineIndex === 0 ? (
801
+ <>
802
+ {bar}
803
+ <span fg={row.kind === "del" ? tokens.deletedForeground : tokens.textDim}>
804
+ {oldNumber}
805
+ </span>
806
+ <span fg={tokens.textDim}> </span>
807
+ <span fg={row.kind === "add" ? tokens.insertedForeground : tokens.textDim}>
808
+ {newNumber}
809
+ </span>
810
+ <span> </span>
811
+ <span fg={rowBaseColor(row, tokens)}>{`${rowSign(row)} `}</span>
812
+ </>
813
+ ) : (
814
+ <>
815
+ {bar}
816
+ {" ".repeat(unifiedGutterColumns - 1)}
817
+ </>
818
+ );
819
+ };
820
+ const lineNodes: React.ReactNode[] = [];
821
+ const cards: React.ReactNode[] = [];
822
+
823
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
824
+ const line = lines[lineIndex]!;
825
+ const isLastLine = lineIndex === lines.length - 1;
826
+ const lineRanges = lineMarkRanges(ranges, line);
827
+ const isCaretLine =
828
+ isCaretRow && (text.length === 0 || lineRanges.some((range) => range.caretOnly));
829
+
830
+ lineNodes.push(
831
+ <box
832
+ key={`${keyPrefix}-line-${lineIndex}`}
833
+ style={{ flexDirection: "row", backgroundColor: rowBg }}
834
+ >
835
+ <text
836
+ selectable={false}
837
+ fg={isCaretLine ? tokens.accent : tokens.textDim}
838
+ style={{ flexShrink: 0, height: 1, alignSelf: "flex-start", wrapMode: "none" }}
839
+ >
840
+ {gutterFor(lineIndex, isCaretLine)}
841
+ </text>
842
+ <text
843
+ selectable={false}
844
+ style={{ flexGrow: 1, flexShrink: 1, wrapMode: "none" }}
845
+ ref={surface.registerLine(rowIndex, lineIndex, line)}
846
+ onMouseDown={surface.onLineMouseDown}
847
+ >
848
+ {codeLineSpans(
849
+ text.slice(line.start, line.end),
850
+ line.start,
851
+ lineRanges,
852
+ fgByColumn,
853
+ emphasisBgByColumn,
854
+ rejected,
855
+ tokens,
856
+ palette,
857
+ ).map((span, spanIndex) => (
858
+ <span key={spanIndex} fg={span.fg} bg={span.bg} attributes={span.attributes}>
859
+ {span.text}
860
+ </span>
861
+ ))}
862
+ </text>
863
+ </box>,
864
+ );
865
+ // cards render on the side that owns the row, so a shared context row's card is not duplicated
866
+ if (paintMarks) cards.push(...surface.cardsAfterLine(rowIndex, line, isLastLine));
867
+ }
868
+
869
+ return { lines: lineNodes, cards };
870
+ };
871
+
872
+ const headerNode = (row: DiffRow, rowIndex: number): React.ReactNode =>
873
+ row.kind === "file" ? (
874
+ <box key={rowIndex} id={`diff-row-${rowIndex}`}>
875
+ <FileBand
876
+ row={row}
877
+ stats={fileStats.get(row.file)}
878
+ fold={fold}
879
+ showFileCollapse={showFileCollapse}
880
+ tokens={tokens}
881
+ />
882
+ </box>
883
+ ) : (
884
+ <text
885
+ key={rowIndex}
886
+ id={`diff-row-${rowIndex}`}
887
+ fg={tokens.blue}
888
+ style={{ wrapMode: "none" }}
889
+ >
890
+ {` ${diffRowText(row)}`}
891
+ </text>
892
+ );
893
+
894
+ const unifiedItem = (rowIndex: number): React.ReactNode => {
895
+ const row = rows[rowIndex]!;
896
+
897
+ if (!isCodeRow(row)) return headerNode(row, rowIndex);
898
+ const { lines, cards } = codeRowLines(row, rowIndex, textWidth, `row-${rowIndex}`);
899
+
900
+ return (
901
+ <box key={rowIndex} id={`diff-row-${rowIndex}`} style={{ flexDirection: "column" }}>
902
+ {lines}
903
+ {cards}
904
+ </box>
905
+ );
906
+ };
907
+
908
+ /** One side of a split pair: the row's visual lines, or blank filler that reads as absent. */
909
+ const splitSide = (
910
+ line: SplitLine | undefined,
911
+ side: "left" | "right",
912
+ textWidth: number,
913
+ keyPrefix: string,
914
+ ): SplitSideNodes => {
915
+ if (!line) {
916
+ const filler = (
917
+ <box style={{ flexGrow: 1, flexBasis: 0, minWidth: 0, backgroundColor: tokens.panel }} />
918
+ );
919
+
920
+ return { node: filler };
921
+ }
922
+ // the change side (right: additions and context) owns the caret and marks; the left carries them
923
+ // only on its deletions, so a context row shared by both columns is not marked twice
924
+ const paintMarks = side === "right" || line.kind === "del";
925
+ const { lines, cards } = codeRowLines(
926
+ line.row,
927
+ line.rowIndex,
928
+ textWidth,
929
+ keyPrefix,
930
+ paintMarks,
931
+ );
932
+ // a discussion stays in the pane of the side it annotates, under that side's lines
933
+ const column = (
934
+ <box style={{ flexDirection: "column", flexGrow: 1, flexBasis: 0, minWidth: 0 }}>
935
+ {lines}
936
+ {cards}
937
+ </box>
938
+ );
939
+
940
+ return { node: column };
941
+ };
942
+
943
+ const splitItem = (pairIndex: number): React.ReactNode => {
944
+ const pair: SplitRow = splitRows[pairIndex]!;
945
+
946
+ if (pair.kind !== "pair") {
947
+ const rowIndex = pair.rowIndex ?? -1;
948
+
949
+ return headerNode(
950
+ rows[rowIndex] ?? { kind: pair.kind, text: pair.file, file: pair.file },
951
+ rowIndex,
952
+ );
953
+ }
954
+ const left = splitSide(pair.left, "left", textWidth, `pair-${pairIndex}-left`);
955
+ const right = splitSide(pair.right, "right", textWidth, `pair-${pairIndex}-right`);
956
+ // both sides answer to the pair's id, so a reveal of either base row lands here
957
+ const anchorRow = pair.right?.rowIndex ?? pair.left?.rowIndex ?? -1;
958
+ const otherRow = pair.left?.rowIndex;
959
+
960
+ return (
961
+ <box
962
+ key={`pair-${pairIndex}`}
963
+ id={`diff-row-${anchorRow}`}
964
+ style={{ flexDirection: "column" }}
965
+ >
966
+ {/* stretch so the divider box grows to the taller side's wrapped height, leaving no gap */}
967
+ <box
968
+ id={otherRow !== undefined && otherRow !== anchorRow ? `diff-row-${otherRow}` : undefined}
969
+ style={{ flexDirection: "row", alignItems: "stretch" }}
970
+ >
971
+ {left.node}
972
+ <box
973
+ style={{
974
+ flexShrink: 0,
975
+ borderStyle: "single",
976
+ border: ["left"],
977
+ borderColor: tokens.border,
978
+ }}
979
+ />
980
+ {right.node}
981
+ </box>
982
+ </box>
983
+ );
984
+ };
985
+
986
+ const materialized: React.ReactNode[] = [];
987
+ const firstItem = virtual.items[0];
988
+ const lastItem = virtual.items[virtual.items.length - 1];
989
+
990
+ if (firstItem && lastItem) {
991
+ const below = virtual.totalSize - lastItem.end;
992
+
993
+ if (firstItem.start > 0) {
994
+ materialized.push(<box key="spacer-above" style={{ height: firstItem.start }} />);
995
+ }
996
+ for (const item of virtual.items) {
997
+ // the wrapper is what gets measured, so a row's cards count toward its height
998
+ materialized.push(
999
+ <box key={`item-${item.index}`} ref={virtual.measureRef(item.index)}>
1000
+ {split ? splitItem(item.index) : unifiedItem(item.index)}
1001
+ </box>,
1002
+ );
1003
+ }
1004
+ if (below > 0) materialized.push(<box key="spacer-below" style={{ height: below }} />);
1005
+ }
1006
+
1007
+ return (
1008
+ <box style={{ flexGrow: 1, flexDirection: "row" }} {...surface.rootMouseProps}>
1009
+ <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 0 }}>
1010
+ {/* the rail draws this box's scrollbar past the dots, so the built-in one stays hidden */}
1011
+ <scrollbox
1012
+ id="diff-scroll"
1013
+ ref={scrollRef}
1014
+ style={{ flexGrow: 1 }}
1015
+ focusable={false}
1016
+ focused={false}
1017
+ scrollAcceleration={wheelAcceleration}
1018
+ verticalScrollbarOptions={{ visible: false }}
1019
+ onMouseScroll={(event) => {
1020
+ const scrollbox = scrollRef.current;
1021
+
1022
+ if (!scrollbox || !event.scroll || event.modifiers.shift) return;
1023
+ if (event.scroll.direction !== "up" && event.scroll.direction !== "down") return;
1024
+ const before = scrollbox.scrollTop;
1025
+ const followCaret = surface.prepareViewportScroll();
1026
+
1027
+ queueMicrotask(() => {
1028
+ const delta = scrollbox.scrollTop - before;
1029
+ const top = scrollbox.viewport.screenY;
1030
+
1031
+ followCaret(delta, top, top + scrollbox.viewport.height - 1);
1032
+ });
1033
+ }}
1034
+ >
1035
+ {materialized}
1036
+ </scrollbox>
1037
+ {suspended ? null : (
1038
+ <NavModeHint
1039
+ navMode={surface.navMode}
1040
+ surface={onNavCommand ? "diff" : "bare"}
1041
+ theme={tokens}
1042
+ />
1043
+ )}
1044
+ </box>
1045
+ <DiscussionMarkerRail
1046
+ discussions={surface.discussions}
1047
+ spanQuote={surface.spanQuote}
1048
+ focusedKey={surface.focusedDiscussion}
1049
+ onJump={surface.jumpToDiscussion}
1050
+ scrollbox={scrollRef}
1051
+ theme={theme}
1052
+ />
1053
+ </box>
1054
+ );
1055
+ }