@fairyhunter13/opentui-core 0.1.113 → 0.1.114

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 (591) hide show
  1. package/dev/keypress-debug-renderer.ts +148 -0
  2. package/dev/keypress-debug.ts +43 -0
  3. package/dev/print-env-vars.ts +32 -0
  4. package/dev/test-tmux-graphics-334.sh +68 -0
  5. package/dev/thai-debug-test.ts +68 -0
  6. package/docs/development.md +144 -0
  7. package/package.json +62 -53
  8. package/scripts/build.ts +400 -0
  9. package/scripts/publish.ts +60 -0
  10. package/src/3d/SpriteResourceManager.ts +286 -0
  11. package/src/3d/SpriteUtils.ts +70 -0
  12. package/src/3d/TextureUtils.ts +196 -0
  13. package/src/3d/ThreeRenderable.ts +197 -0
  14. package/src/3d/WGPURenderer.ts +294 -0
  15. package/src/3d/animation/ExplodingSpriteEffect.ts +513 -0
  16. package/src/3d/animation/PhysicsExplodingSpriteEffect.ts +429 -0
  17. package/src/3d/animation/SpriteAnimator.ts +633 -0
  18. package/src/3d/animation/SpriteParticleGenerator.ts +435 -0
  19. package/src/3d/canvas.ts +464 -0
  20. package/src/3d/index.ts +12 -0
  21. package/src/3d/physics/PlanckPhysicsAdapter.ts +72 -0
  22. package/src/3d/physics/RapierPhysicsAdapter.ts +66 -0
  23. package/src/3d/physics/physics-interface.ts +31 -0
  24. package/src/3d/shaders/supersampling.wgsl +201 -0
  25. package/src/3d.ts +3 -0
  26. package/src/NativeSpanFeed.ts +300 -0
  27. package/src/Renderable.ts +1704 -0
  28. package/src/__snapshots__/buffer.test.ts.snap +28 -0
  29. package/src/animation/Timeline.test.ts +2709 -0
  30. package/src/animation/Timeline.ts +598 -0
  31. package/src/ansi.ts +18 -0
  32. package/src/benchmark/attenuation-benchmark.ts +81 -0
  33. package/src/benchmark/colormatrix-benchmark.ts +128 -0
  34. package/src/benchmark/gain-benchmark.ts +80 -0
  35. package/src/benchmark/latest-all-bench-run.json +707 -0
  36. package/src/benchmark/latest-async-bench-run.json +336 -0
  37. package/src/benchmark/latest-default-bench-run.json +657 -0
  38. package/src/benchmark/latest-large-bench-run.json +707 -0
  39. package/src/benchmark/latest-quick-bench-run.json +207 -0
  40. package/src/benchmark/markdown-benchmark.ts +1796 -0
  41. package/src/benchmark/native-span-feed-async-benchmark.ts +355 -0
  42. package/src/benchmark/native-span-feed-benchmark.md +56 -0
  43. package/src/benchmark/native-span-feed-benchmark.ts +596 -0
  44. package/src/benchmark/native-span-feed-compare.ts +280 -0
  45. package/src/benchmark/renderer-benchmark.ts +754 -0
  46. package/src/benchmark/text-table-benchmark.ts +948 -0
  47. package/src/buffer.test.ts +291 -0
  48. package/src/buffer.ts +554 -0
  49. package/src/console.test.ts +612 -0
  50. package/src/console.ts +1254 -0
  51. package/src/edit-buffer.test.ts +1769 -0
  52. package/src/edit-buffer.ts +411 -0
  53. package/src/editor-view.test.ts +1032 -0
  54. package/src/editor-view.ts +284 -0
  55. package/src/examples/ascii-font-selection-demo.ts +245 -0
  56. package/src/examples/assets/Water_2_M_Normal.jpg +0 -0
  57. package/src/examples/assets/concrete.png +0 -0
  58. package/src/examples/assets/crate.png +0 -0
  59. package/src/examples/assets/crate_emissive.png +0 -0
  60. package/src/examples/assets/forrest_background.png +0 -0
  61. package/src/examples/assets/hast-example.json +1018 -0
  62. package/src/examples/assets/heart.png +0 -0
  63. package/src/examples/assets/main_char_heavy_attack.png +0 -0
  64. package/src/examples/assets/main_char_idle.png +0 -0
  65. package/src/examples/assets/main_char_jump_end.png +0 -0
  66. package/src/examples/assets/main_char_jump_landing.png +0 -0
  67. package/src/examples/assets/main_char_jump_start.png +0 -0
  68. package/src/examples/assets/main_char_run_loop.png +0 -0
  69. package/src/examples/assets/roughness_map.jpg +0 -0
  70. package/src/examples/build.ts +115 -0
  71. package/src/examples/code-demo.ts +924 -0
  72. package/src/examples/console-demo.ts +358 -0
  73. package/src/examples/core-plugin-slots-demo.ts +759 -0
  74. package/src/examples/diff-demo.ts +701 -0
  75. package/src/examples/draggable-three-demo.ts +259 -0
  76. package/src/examples/editor-demo.ts +322 -0
  77. package/src/examples/extmarks-demo.ts +196 -0
  78. package/src/examples/focus-restore-demo.ts +310 -0
  79. package/src/examples/fonts.ts +245 -0
  80. package/src/examples/fractal-shader-demo.ts +268 -0
  81. package/src/examples/framebuffer-demo.ts +674 -0
  82. package/src/examples/full-unicode-demo.ts +241 -0
  83. package/src/examples/golden-star-demo.ts +933 -0
  84. package/src/examples/grayscale-buffer-demo.ts +249 -0
  85. package/src/examples/hast-syntax-highlighting-demo.ts +129 -0
  86. package/src/examples/index.ts +926 -0
  87. package/src/examples/input-demo.ts +377 -0
  88. package/src/examples/input-select-layout-demo.ts +425 -0
  89. package/src/examples/install.sh +143 -0
  90. package/src/examples/keypress-debug-demo.ts +452 -0
  91. package/src/examples/lib/HexList.ts +122 -0
  92. package/src/examples/lib/PaletteGrid.ts +125 -0
  93. package/src/examples/lib/standalone-keys.ts +25 -0
  94. package/src/examples/lib/tab-controller.ts +243 -0
  95. package/src/examples/lights-phong-demo.ts +290 -0
  96. package/src/examples/link-demo.ts +220 -0
  97. package/src/examples/live-state-demo.ts +480 -0
  98. package/src/examples/markdown-demo.ts +725 -0
  99. package/src/examples/mouse-interaction-demo.ts +428 -0
  100. package/src/examples/nested-zindex-demo.ts +357 -0
  101. package/src/examples/opacity-example.ts +235 -0
  102. package/src/examples/opentui-demo.ts +1057 -0
  103. package/src/examples/physx-planck-2d-demo.ts +623 -0
  104. package/src/examples/physx-rapier-2d-demo.ts +655 -0
  105. package/src/examples/relative-positioning-demo.ts +323 -0
  106. package/src/examples/scroll-example.ts +214 -0
  107. package/src/examples/scrollbox-mouse-test.ts +112 -0
  108. package/src/examples/scrollbox-overlay-hit-test.ts +206 -0
  109. package/src/examples/select-demo.ts +237 -0
  110. package/src/examples/shader-cube-demo.ts +1015 -0
  111. package/src/examples/simple-layout-example.ts +591 -0
  112. package/src/examples/slider-demo.ts +617 -0
  113. package/src/examples/split-mode-demo.ts +453 -0
  114. package/src/examples/sprite-animation-demo.ts +443 -0
  115. package/src/examples/sprite-particle-generator-demo.ts +486 -0
  116. package/src/examples/static-sprite-demo.ts +193 -0
  117. package/src/examples/sticky-scroll-example.ts +308 -0
  118. package/src/examples/styled-text-demo.ts +282 -0
  119. package/src/examples/tab-select-demo.ts +219 -0
  120. package/src/examples/terminal-title.ts +29 -0
  121. package/src/examples/terminal.ts +305 -0
  122. package/src/examples/text-node-demo.ts +416 -0
  123. package/src/examples/text-selection-demo.ts +377 -0
  124. package/src/examples/text-table-demo.ts +503 -0
  125. package/src/examples/text-truncation-demo.ts +481 -0
  126. package/src/examples/text-wrap.ts +757 -0
  127. package/src/examples/texture-loading-demo.ts +259 -0
  128. package/src/examples/timeline-example.ts +670 -0
  129. package/src/examples/transparency-demo.ts +400 -0
  130. package/src/examples/vnode-composition-demo.ts +404 -0
  131. package/src/examples/wide-grapheme-overlay-demo.ts +280 -0
  132. package/src/index.ts +24 -0
  133. package/src/lib/KeyHandler.integration.test.ts +292 -0
  134. package/src/lib/KeyHandler.stopPropagation.test.ts +289 -0
  135. package/src/lib/KeyHandler.test.ts +662 -0
  136. package/src/lib/KeyHandler.ts +222 -0
  137. package/src/lib/RGBA.test.ts +984 -0
  138. package/src/lib/RGBA.ts +204 -0
  139. package/src/lib/ascii.font.ts +330 -0
  140. package/src/lib/border.test.ts +83 -0
  141. package/src/lib/border.ts +170 -0
  142. package/src/lib/bunfs.test.ts +27 -0
  143. package/src/lib/bunfs.ts +18 -0
  144. package/src/lib/clipboard.test.ts +41 -0
  145. package/src/lib/clipboard.ts +47 -0
  146. package/src/lib/clock.ts +35 -0
  147. package/src/lib/data-paths.test.ts +133 -0
  148. package/src/lib/data-paths.ts +109 -0
  149. package/src/lib/debounce.ts +106 -0
  150. package/src/lib/detect-links.test.ts +98 -0
  151. package/src/lib/detect-links.ts +56 -0
  152. package/src/lib/env.test.ts +228 -0
  153. package/src/lib/env.ts +209 -0
  154. package/src/lib/extmarks-history.ts +51 -0
  155. package/src/lib/extmarks-multiwidth.test.ts +322 -0
  156. package/src/lib/extmarks.test.ts +3457 -0
  157. package/src/lib/extmarks.ts +843 -0
  158. package/src/lib/fonts/block.json +405 -0
  159. package/src/lib/fonts/grid.json +265 -0
  160. package/src/lib/fonts/huge.json +741 -0
  161. package/src/lib/fonts/pallet.json +314 -0
  162. package/src/lib/fonts/shade.json +591 -0
  163. package/src/lib/fonts/slick.json +321 -0
  164. package/src/lib/fonts/tiny.json +69 -0
  165. package/src/lib/hast-styled-text.ts +59 -0
  166. package/src/lib/index.ts +21 -0
  167. package/src/lib/keymapping.test.ts +317 -0
  168. package/src/lib/keymapping.ts +115 -0
  169. package/src/lib/objects-in-viewport.test.ts +787 -0
  170. package/src/lib/objects-in-viewport.ts +153 -0
  171. package/src/lib/output.capture.ts +58 -0
  172. package/src/lib/parse.keypress-kitty.protocol.test.ts +340 -0
  173. package/src/lib/parse.keypress-kitty.test.ts +663 -0
  174. package/src/lib/parse.keypress-kitty.ts +439 -0
  175. package/src/lib/parse.keypress.test.ts +1849 -0
  176. package/src/lib/parse.keypress.ts +397 -0
  177. package/src/lib/parse.mouse.test.ts +552 -0
  178. package/src/lib/parse.mouse.ts +232 -0
  179. package/src/lib/paste.ts +16 -0
  180. package/src/lib/queue.ts +65 -0
  181. package/src/lib/renderable.validations.test.ts +87 -0
  182. package/src/lib/renderable.validations.ts +83 -0
  183. package/src/lib/scroll-acceleration.ts +98 -0
  184. package/src/lib/selection.ts +240 -0
  185. package/src/lib/singleton.ts +28 -0
  186. package/src/lib/stdin-parser.test.ts +2290 -0
  187. package/src/lib/stdin-parser.ts +1810 -0
  188. package/src/lib/styled-text.ts +178 -0
  189. package/src/lib/terminal-capability-detection.test.ts +202 -0
  190. package/src/lib/terminal-capability-detection.ts +79 -0
  191. package/src/lib/terminal-palette.test.ts +878 -0
  192. package/src/lib/terminal-palette.ts +383 -0
  193. package/src/lib/tree-sitter/assets/README.md +118 -0
  194. package/src/lib/tree-sitter/assets/update.ts +334 -0
  195. package/src/lib/tree-sitter/assets.d.ts +9 -0
  196. package/src/lib/tree-sitter/cache.test.ts +273 -0
  197. package/src/lib/tree-sitter/client.test.ts +1165 -0
  198. package/src/lib/tree-sitter/client.ts +607 -0
  199. package/src/lib/tree-sitter/default-parsers.ts +86 -0
  200. package/src/lib/tree-sitter/download-utils.ts +148 -0
  201. package/src/lib/tree-sitter/index.ts +28 -0
  202. package/src/lib/tree-sitter/parser.worker.ts +1042 -0
  203. package/src/lib/tree-sitter/parsers-config.ts +81 -0
  204. package/src/lib/tree-sitter/resolve-ft.test.ts +55 -0
  205. package/src/lib/tree-sitter/resolve-ft.ts +189 -0
  206. package/src/lib/tree-sitter/types.ts +82 -0
  207. package/src/lib/tree-sitter-styled-text.test.ts +1253 -0
  208. package/src/lib/tree-sitter-styled-text.ts +306 -0
  209. package/src/lib/validate-dir-name.ts +55 -0
  210. package/src/lib/yoga.options.test.ts +628 -0
  211. package/src/lib/yoga.options.ts +346 -0
  212. package/src/plugins/core-slot.ts +579 -0
  213. package/src/plugins/registry.ts +402 -0
  214. package/src/plugins/types.ts +46 -0
  215. package/src/post/effects.ts +930 -0
  216. package/src/post/filters.ts +489 -0
  217. package/src/post/matrices.ts +288 -0
  218. package/src/renderables/ASCIIFont.ts +219 -0
  219. package/src/renderables/Box.test.ts +205 -0
  220. package/src/renderables/Box.ts +326 -0
  221. package/src/renderables/Code.test.ts +2062 -0
  222. package/src/renderables/Code.ts +357 -0
  223. package/src/renderables/Diff.regression.test.ts +226 -0
  224. package/src/renderables/Diff.test.ts +3101 -0
  225. package/src/renderables/Diff.ts +1211 -0
  226. package/src/renderables/EditBufferRenderable.test.ts +288 -0
  227. package/src/renderables/EditBufferRenderable.ts +1166 -0
  228. package/src/renderables/FrameBuffer.ts +47 -0
  229. package/src/renderables/Input.test.ts +1228 -0
  230. package/src/renderables/Input.ts +247 -0
  231. package/src/renderables/LineNumberRenderable.ts +724 -0
  232. package/src/renderables/Markdown.ts +1393 -0
  233. package/src/renderables/ScrollBar.ts +422 -0
  234. package/src/renderables/ScrollBox.ts +883 -0
  235. package/src/renderables/Select.test.ts +1033 -0
  236. package/src/renderables/Select.ts +524 -0
  237. package/src/renderables/Slider.test.ts +456 -0
  238. package/src/renderables/Slider.ts +342 -0
  239. package/src/renderables/TabSelect.test.ts +197 -0
  240. package/src/renderables/TabSelect.ts +455 -0
  241. package/src/renderables/Text.selection-buffer.test.ts +123 -0
  242. package/src/renderables/Text.test.ts +2660 -0
  243. package/src/renderables/Text.ts +147 -0
  244. package/src/renderables/TextBufferRenderable.ts +518 -0
  245. package/src/renderables/TextNode.test.ts +1058 -0
  246. package/src/renderables/TextNode.ts +325 -0
  247. package/src/renderables/TextTable.test.ts +1421 -0
  248. package/src/renderables/TextTable.ts +1344 -0
  249. package/src/renderables/Textarea.ts +430 -0
  250. package/src/renderables/TimeToFirstDraw.ts +89 -0
  251. package/src/renderables/__snapshots__/Code.test.ts.snap +13 -0
  252. package/src/renderables/__snapshots__/Diff.test.ts.snap +785 -0
  253. package/src/renderables/__snapshots__/Text.test.ts.snap +421 -0
  254. package/src/renderables/__snapshots__/TextTable.test.ts.snap +215 -0
  255. package/src/renderables/__tests__/LineNumberRenderable.scrollbox-simple.test.ts +144 -0
  256. package/src/renderables/__tests__/LineNumberRenderable.scrollbox.test.ts +816 -0
  257. package/src/renderables/__tests__/LineNumberRenderable.test.ts +1865 -0
  258. package/src/renderables/__tests__/LineNumberRenderable.wrapping.test.ts +85 -0
  259. package/src/renderables/__tests__/Markdown.code-colors.test.ts +242 -0
  260. package/src/renderables/__tests__/Markdown.test.ts +2518 -0
  261. package/src/renderables/__tests__/MultiRenderable.selection.test.ts +87 -0
  262. package/src/renderables/__tests__/Textarea.buffer.test.ts +682 -0
  263. package/src/renderables/__tests__/Textarea.destroyed-events.test.ts +675 -0
  264. package/src/renderables/__tests__/Textarea.editing.test.ts +2041 -0
  265. package/src/renderables/__tests__/Textarea.error-handling.test.ts +35 -0
  266. package/src/renderables/__tests__/Textarea.events.test.ts +738 -0
  267. package/src/renderables/__tests__/Textarea.highlights.test.ts +590 -0
  268. package/src/renderables/__tests__/Textarea.keybinding.test.ts +3149 -0
  269. package/src/renderables/__tests__/Textarea.paste.test.ts +357 -0
  270. package/src/renderables/__tests__/Textarea.rendering.test.ts +1866 -0
  271. package/src/renderables/__tests__/Textarea.scroll.test.ts +733 -0
  272. package/src/renderables/__tests__/Textarea.selection.test.ts +1590 -0
  273. package/src/renderables/__tests__/Textarea.stress.test.ts +670 -0
  274. package/src/renderables/__tests__/Textarea.undo-redo.test.ts +383 -0
  275. package/src/renderables/__tests__/Textarea.visual-lines.test.ts +310 -0
  276. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.code.test.ts.snap +221 -0
  277. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox-simple.test.ts.snap +89 -0
  278. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox.test.ts.snap +457 -0
  279. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.test.ts.snap +158 -0
  280. package/src/renderables/__tests__/__snapshots__/Textarea.rendering.test.ts.snap +387 -0
  281. package/src/renderables/__tests__/markdown-parser.test.ts +217 -0
  282. package/src/renderables/__tests__/renderable-test-utils.ts +60 -0
  283. package/src/renderables/composition/README.md +8 -0
  284. package/src/renderables/composition/VRenderable.ts +32 -0
  285. package/src/renderables/composition/constructs.ts +127 -0
  286. package/src/renderables/composition/vnode.ts +289 -0
  287. package/src/renderables/index.ts +23 -0
  288. package/src/renderables/markdown-parser.ts +66 -0
  289. package/src/renderer.ts +2681 -0
  290. package/src/runtime-plugin-support.ts +39 -0
  291. package/src/runtime-plugin.ts +615 -0
  292. package/src/syntax-style.test.ts +841 -0
  293. package/src/syntax-style.ts +257 -0
  294. package/src/testing/README.md +210 -0
  295. package/src/testing/capture-spans.test.ts +194 -0
  296. package/src/testing/integration.test.ts +276 -0
  297. package/src/testing/manual-clock.ts +117 -0
  298. package/src/testing/mock-keys.test.ts +1378 -0
  299. package/src/testing/mock-keys.ts +457 -0
  300. package/src/testing/mock-mouse.test.ts +218 -0
  301. package/src/testing/mock-mouse.ts +247 -0
  302. package/src/testing/mock-tree-sitter-client.ts +73 -0
  303. package/src/testing/spy.ts +13 -0
  304. package/src/testing/test-recorder.test.ts +415 -0
  305. package/src/testing/test-recorder.ts +145 -0
  306. package/src/testing/test-renderer.ts +132 -0
  307. package/src/testing.ts +7 -0
  308. package/src/tests/__snapshots__/absolute-positioning.snapshot.test.ts.snap +481 -0
  309. package/src/tests/__snapshots__/renderable.snapshot.test.ts.snap +19 -0
  310. package/src/tests/__snapshots__/scrollbox.test.ts.snap +29 -0
  311. package/src/tests/absolute-positioning.snapshot.test.ts +638 -0
  312. package/src/tests/allocator-stats.test.ts +38 -0
  313. package/src/tests/destroy-during-render.test.ts +200 -0
  314. package/src/tests/destroy-on-exit.fixture.ts +36 -0
  315. package/src/tests/destroy-on-exit.test.ts +41 -0
  316. package/src/tests/hover-cursor.test.ts +98 -0
  317. package/src/tests/native-span-feed-async.test.ts +173 -0
  318. package/src/tests/native-span-feed-close.test.ts +120 -0
  319. package/src/tests/native-span-feed-coverage.test.ts +227 -0
  320. package/src/tests/native-span-feed-edge-cases.test.ts +352 -0
  321. package/src/tests/native-span-feed-use-after-free.test.ts +45 -0
  322. package/src/tests/opacity.test.ts +123 -0
  323. package/src/tests/renderable.snapshot.test.ts +524 -0
  324. package/src/tests/renderable.test.ts +1281 -0
  325. package/src/tests/renderer.clock.test.ts +158 -0
  326. package/src/tests/renderer.console-startup.test.ts +185 -0
  327. package/src/tests/renderer.control.test.ts +425 -0
  328. package/src/tests/renderer.core-slot-binding.test.ts +952 -0
  329. package/src/tests/renderer.cursor.test.ts +26 -0
  330. package/src/tests/renderer.destroy-during-render.test.ts +147 -0
  331. package/src/tests/renderer.focus-restore.test.ts +257 -0
  332. package/src/tests/renderer.focus.test.ts +294 -0
  333. package/src/tests/renderer.idle.test.ts +219 -0
  334. package/src/tests/renderer.input.test.ts +2237 -0
  335. package/src/tests/renderer.kitty-flags.test.ts +195 -0
  336. package/src/tests/renderer.mouse.test.ts +1274 -0
  337. package/src/tests/renderer.palette.test.ts +629 -0
  338. package/src/tests/renderer.selection.test.ts +49 -0
  339. package/src/tests/renderer.slot-registry.test.ts +684 -0
  340. package/src/tests/renderer.useMouse.test.ts +47 -0
  341. package/src/tests/runtime-plugin-node-modules-cycle.fixture.ts +76 -0
  342. package/src/tests/runtime-plugin-node-modules-mjs.fixture.ts +43 -0
  343. package/src/tests/runtime-plugin-node-modules-no-bare-rewrite.fixture.ts +67 -0
  344. package/src/tests/runtime-plugin-node-modules-package-type-cache.fixture.ts +72 -0
  345. package/src/tests/runtime-plugin-node-modules-runtime-specifier.fixture.ts +44 -0
  346. package/src/tests/runtime-plugin-node-modules-scoped-package-bare-rewrite.fixture.ts +85 -0
  347. package/src/tests/runtime-plugin-path-alias.fixture.ts +43 -0
  348. package/src/tests/runtime-plugin-resolve-roots.fixture.ts +65 -0
  349. package/src/tests/runtime-plugin-support.fixture.ts +11 -0
  350. package/src/tests/runtime-plugin-support.test.ts +19 -0
  351. package/src/tests/runtime-plugin-windows-file-url.fixture.ts +30 -0
  352. package/src/tests/runtime-plugin.fixture.ts +40 -0
  353. package/src/tests/runtime-plugin.test.ts +354 -0
  354. package/src/tests/scrollbox-culling-bug.test.ts +114 -0
  355. package/src/tests/scrollbox-hitgrid-resize.test.ts +136 -0
  356. package/src/tests/scrollbox-hitgrid.test.ts +909 -0
  357. package/src/tests/scrollbox.test.ts +1530 -0
  358. package/src/tests/wrap-resize-perf.test.ts +276 -0
  359. package/src/tests/yoga-setters.test.ts +921 -0
  360. package/src/text-buffer-view.test.ts +705 -0
  361. package/src/text-buffer-view.ts +189 -0
  362. package/src/text-buffer.test.ts +347 -0
  363. package/src/text-buffer.ts +250 -0
  364. package/src/types.ts +161 -0
  365. package/src/utils.ts +88 -0
  366. package/src/zig/ansi.zig +268 -0
  367. package/src/zig/bench/README.md +50 -0
  368. package/src/zig/bench/buffer-draw-text-buffer_bench.zig +887 -0
  369. package/src/zig/bench/edit-buffer_bench.zig +476 -0
  370. package/src/zig/bench/native-span-feed_bench.zig +100 -0
  371. package/src/zig/bench/rope-markers_bench.zig +713 -0
  372. package/src/zig/bench/rope_bench.zig +514 -0
  373. package/src/zig/bench/styled-text_bench.zig +470 -0
  374. package/src/zig/bench/text-buffer-coords_bench.zig +362 -0
  375. package/src/zig/bench/text-buffer-view_bench.zig +459 -0
  376. package/src/zig/bench/text-chunk-graphemes_bench.zig +273 -0
  377. package/src/zig/bench/utf8_bench.zig +799 -0
  378. package/src/zig/bench-utils.zig +431 -0
  379. package/src/zig/bench.zig +217 -0
  380. package/src/zig/buffer-methods.zig +211 -0
  381. package/src/zig/buffer.zig +2281 -0
  382. package/src/zig/build.zig +289 -0
  383. package/src/zig/build.zig.zon +16 -0
  384. package/src/zig/edit-buffer.zig +825 -0
  385. package/src/zig/editor-view.zig +802 -0
  386. package/src/zig/event-bus.zig +13 -0
  387. package/src/zig/event-emitter.zig +65 -0
  388. package/src/zig/file-logger.zig +92 -0
  389. package/src/zig/grapheme.zig +599 -0
  390. package/src/zig/lib.zig +1854 -0
  391. package/src/zig/link.zig +333 -0
  392. package/src/zig/logger.zig +43 -0
  393. package/src/zig/mem-registry.zig +125 -0
  394. package/src/zig/native-span-feed-bench-lib.zig +7 -0
  395. package/src/zig/native-span-feed.zig +708 -0
  396. package/src/zig/renderer.zig +1393 -0
  397. package/src/zig/rope.zig +1220 -0
  398. package/src/zig/syntax-style.zig +161 -0
  399. package/src/zig/terminal.zig +987 -0
  400. package/src/zig/test.zig +72 -0
  401. package/src/zig/tests/README.md +18 -0
  402. package/src/zig/tests/buffer-methods_test.zig +1109 -0
  403. package/src/zig/tests/buffer_test.zig +2557 -0
  404. package/src/zig/tests/edit-buffer-history_test.zig +271 -0
  405. package/src/zig/tests/edit-buffer_test.zig +1689 -0
  406. package/src/zig/tests/editor-view_test.zig +3299 -0
  407. package/src/zig/tests/event-emitter_test.zig +249 -0
  408. package/src/zig/tests/grapheme_test.zig +1304 -0
  409. package/src/zig/tests/link_test.zig +190 -0
  410. package/src/zig/tests/mem-registry_test.zig +473 -0
  411. package/src/zig/tests/memory_leak_regression_test.zig +159 -0
  412. package/src/zig/tests/native-span-feed_test.zig +1264 -0
  413. package/src/zig/tests/renderer_test.zig +1017 -0
  414. package/src/zig/tests/rope-nested_test.zig +712 -0
  415. package/src/zig/tests/rope_fuzz_test.zig +238 -0
  416. package/src/zig/tests/rope_test.zig +2362 -0
  417. package/src/zig/tests/segment-merge.test.zig +148 -0
  418. package/src/zig/tests/syntax-style_test.zig +557 -0
  419. package/src/zig/tests/terminal_test.zig +754 -0
  420. package/src/zig/tests/text-buffer-drawing_test.zig +3237 -0
  421. package/src/zig/tests/text-buffer-highlights_test.zig +666 -0
  422. package/src/zig/tests/text-buffer-iterators_test.zig +776 -0
  423. package/src/zig/tests/text-buffer-segment_test.zig +320 -0
  424. package/src/zig/tests/text-buffer-selection_test.zig +1035 -0
  425. package/src/zig/tests/text-buffer-selection_viewport_test.zig +358 -0
  426. package/src/zig/tests/text-buffer-view_test.zig +3649 -0
  427. package/src/zig/tests/text-buffer_test.zig +2191 -0
  428. package/src/zig/tests/unicode-width-map.zon +3909 -0
  429. package/src/zig/tests/utf8_no_zwj_test.zig +260 -0
  430. package/src/zig/tests/utf8_test.zig +4057 -0
  431. package/src/zig/tests/utf8_wcwidth_cursor_test.zig +267 -0
  432. package/src/zig/tests/utf8_wcwidth_test.zig +357 -0
  433. package/src/zig/tests/word-wrap-editing_test.zig +498 -0
  434. package/src/zig/tests/wrap-cache-perf_test.zig +113 -0
  435. package/src/zig/text-buffer-iterators.zig +499 -0
  436. package/src/zig/text-buffer-segment.zig +404 -0
  437. package/src/zig/text-buffer-view.zig +1371 -0
  438. package/src/zig/text-buffer.zig +1180 -0
  439. package/src/zig/utf8.zig +1948 -0
  440. package/src/zig/utils.zig +9 -0
  441. package/src/zig-structs.ts +261 -0
  442. package/src/zig.ts +3884 -0
  443. package/tsconfig.build.json +24 -0
  444. package/tsconfig.json +27 -0
  445. package/3d/SpriteResourceManager.d.ts +0 -74
  446. package/3d/SpriteUtils.d.ts +0 -13
  447. package/3d/TextureUtils.d.ts +0 -24
  448. package/3d/ThreeRenderable.d.ts +0 -40
  449. package/3d/WGPURenderer.d.ts +0 -61
  450. package/3d/animation/ExplodingSpriteEffect.d.ts +0 -71
  451. package/3d/animation/PhysicsExplodingSpriteEffect.d.ts +0 -76
  452. package/3d/animation/SpriteAnimator.d.ts +0 -124
  453. package/3d/animation/SpriteParticleGenerator.d.ts +0 -62
  454. package/3d/canvas.d.ts +0 -44
  455. package/3d/index.d.ts +0 -12
  456. package/3d/physics/PlanckPhysicsAdapter.d.ts +0 -19
  457. package/3d/physics/RapierPhysicsAdapter.d.ts +0 -19
  458. package/3d/physics/physics-interface.d.ts +0 -27
  459. package/3d.d.ts +0 -2
  460. package/3d.js +0 -34041
  461. package/3d.js.map +0 -155
  462. package/LICENSE +0 -21
  463. package/NativeSpanFeed.d.ts +0 -41
  464. package/Renderable.d.ts +0 -334
  465. package/animation/Timeline.d.ts +0 -126
  466. package/ansi.d.ts +0 -13
  467. package/buffer.d.ts +0 -111
  468. package/console.d.ts +0 -144
  469. package/edit-buffer.d.ts +0 -98
  470. package/editor-view.d.ts +0 -73
  471. package/index-9vwc3fg6.js +0 -12260
  472. package/index-9vwc3fg6.js.map +0 -42
  473. package/index-dcj62y8t.js +0 -20614
  474. package/index-dcj62y8t.js.map +0 -67
  475. package/index-f7n39gpy.js +0 -411
  476. package/index-f7n39gpy.js.map +0 -10
  477. package/index.d.ts +0 -23
  478. package/index.js +0 -478
  479. package/index.js.map +0 -9
  480. package/lib/KeyHandler.d.ts +0 -61
  481. package/lib/RGBA.d.ts +0 -25
  482. package/lib/ascii.font.d.ts +0 -508
  483. package/lib/border.d.ts +0 -51
  484. package/lib/bunfs.d.ts +0 -7
  485. package/lib/clipboard.d.ts +0 -17
  486. package/lib/clock.d.ts +0 -15
  487. package/lib/data-paths.d.ts +0 -26
  488. package/lib/debounce.d.ts +0 -42
  489. package/lib/detect-links.d.ts +0 -6
  490. package/lib/env.d.ts +0 -42
  491. package/lib/extmarks-history.d.ts +0 -17
  492. package/lib/extmarks.d.ts +0 -89
  493. package/lib/hast-styled-text.d.ts +0 -17
  494. package/lib/index.d.ts +0 -21
  495. package/lib/keymapping.d.ts +0 -25
  496. package/lib/objects-in-viewport.d.ts +0 -24
  497. package/lib/output.capture.d.ts +0 -24
  498. package/lib/parse.keypress-kitty.d.ts +0 -2
  499. package/lib/parse.keypress.d.ts +0 -26
  500. package/lib/parse.mouse.d.ts +0 -30
  501. package/lib/paste.d.ts +0 -7
  502. package/lib/queue.d.ts +0 -15
  503. package/lib/renderable.validations.d.ts +0 -12
  504. package/lib/scroll-acceleration.d.ts +0 -43
  505. package/lib/selection.d.ts +0 -63
  506. package/lib/singleton.d.ts +0 -7
  507. package/lib/stdin-parser.d.ts +0 -87
  508. package/lib/styled-text.d.ts +0 -63
  509. package/lib/terminal-capability-detection.d.ts +0 -30
  510. package/lib/terminal-palette.d.ts +0 -50
  511. package/lib/tree-sitter/assets/update.d.ts +0 -11
  512. package/lib/tree-sitter/client.d.ts +0 -47
  513. package/lib/tree-sitter/default-parsers.d.ts +0 -2
  514. package/lib/tree-sitter/download-utils.d.ts +0 -21
  515. package/lib/tree-sitter/index.d.ts +0 -8
  516. package/lib/tree-sitter/parser.worker.d.ts +0 -1
  517. package/lib/tree-sitter/parsers-config.d.ts +0 -53
  518. package/lib/tree-sitter/resolve-ft.d.ts +0 -5
  519. package/lib/tree-sitter/types.d.ts +0 -82
  520. package/lib/tree-sitter-styled-text.d.ts +0 -14
  521. package/lib/validate-dir-name.d.ts +0 -1
  522. package/lib/yoga.options.d.ts +0 -32
  523. package/parser.worker.js +0 -899
  524. package/parser.worker.js.map +0 -12
  525. package/plugins/core-slot.d.ts +0 -72
  526. package/plugins/registry.d.ts +0 -42
  527. package/plugins/types.d.ts +0 -34
  528. package/post/effects.d.ts +0 -147
  529. package/post/filters.d.ts +0 -65
  530. package/post/matrices.d.ts +0 -20
  531. package/renderables/ASCIIFont.d.ts +0 -52
  532. package/renderables/Box.d.ts +0 -81
  533. package/renderables/Code.d.ts +0 -78
  534. package/renderables/Diff.d.ts +0 -142
  535. package/renderables/EditBufferRenderable.d.ts +0 -237
  536. package/renderables/FrameBuffer.d.ts +0 -16
  537. package/renderables/Input.d.ts +0 -67
  538. package/renderables/LineNumberRenderable.d.ts +0 -78
  539. package/renderables/Markdown.d.ts +0 -185
  540. package/renderables/ScrollBar.d.ts +0 -77
  541. package/renderables/ScrollBox.d.ts +0 -124
  542. package/renderables/Select.d.ts +0 -115
  543. package/renderables/Slider.d.ts +0 -47
  544. package/renderables/TabSelect.d.ts +0 -96
  545. package/renderables/Text.d.ts +0 -36
  546. package/renderables/TextBufferRenderable.d.ts +0 -105
  547. package/renderables/TextNode.d.ts +0 -91
  548. package/renderables/TextTable.d.ts +0 -140
  549. package/renderables/Textarea.d.ts +0 -63
  550. package/renderables/TimeToFirstDraw.d.ts +0 -24
  551. package/renderables/__tests__/renderable-test-utils.d.ts +0 -12
  552. package/renderables/composition/VRenderable.d.ts +0 -16
  553. package/renderables/composition/constructs.d.ts +0 -35
  554. package/renderables/composition/vnode.d.ts +0 -46
  555. package/renderables/index.d.ts +0 -23
  556. package/renderables/markdown-parser.d.ts +0 -10
  557. package/renderer.d.ts +0 -419
  558. package/runtime-plugin-support.d.ts +0 -3
  559. package/runtime-plugin-support.js +0 -29
  560. package/runtime-plugin-support.js.map +0 -10
  561. package/runtime-plugin.d.ts +0 -16
  562. package/runtime-plugin.js +0 -16
  563. package/runtime-plugin.js.map +0 -9
  564. package/syntax-style.d.ts +0 -54
  565. package/testing/manual-clock.d.ts +0 -17
  566. package/testing/mock-keys.d.ts +0 -81
  567. package/testing/mock-mouse.d.ts +0 -38
  568. package/testing/mock-tree-sitter-client.d.ts +0 -23
  569. package/testing/spy.d.ts +0 -7
  570. package/testing/test-recorder.d.ts +0 -61
  571. package/testing/test-renderer.d.ts +0 -23
  572. package/testing.d.ts +0 -6
  573. package/testing.js +0 -697
  574. package/testing.js.map +0 -15
  575. package/text-buffer-view.d.ts +0 -42
  576. package/text-buffer.d.ts +0 -67
  577. package/types.d.ts +0 -139
  578. package/utils.d.ts +0 -14
  579. package/zig-structs.d.ts +0 -155
  580. package/zig.d.ts +0 -353
  581. /package/{assets → src/lib/tree-sitter/assets}/javascript/highlights.scm +0 -0
  582. /package/{assets → src/lib/tree-sitter/assets}/javascript/tree-sitter-javascript.wasm +0 -0
  583. /package/{assets → src/lib/tree-sitter/assets}/markdown/highlights.scm +0 -0
  584. /package/{assets → src/lib/tree-sitter/assets}/markdown/injections.scm +0 -0
  585. /package/{assets → src/lib/tree-sitter/assets}/markdown/tree-sitter-markdown.wasm +0 -0
  586. /package/{assets → src/lib/tree-sitter/assets}/markdown_inline/highlights.scm +0 -0
  587. /package/{assets → src/lib/tree-sitter/assets}/markdown_inline/tree-sitter-markdown_inline.wasm +0 -0
  588. /package/{assets → src/lib/tree-sitter/assets}/typescript/highlights.scm +0 -0
  589. /package/{assets → src/lib/tree-sitter/assets}/typescript/tree-sitter-typescript.wasm +0 -0
  590. /package/{assets → src/lib/tree-sitter/assets}/zig/highlights.scm +0 -0
  591. /package/{assets → src/lib/tree-sitter/assets}/zig/tree-sitter-zig.wasm +0 -0
@@ -0,0 +1,3101 @@
1
+ import { test, expect, beforeEach, afterEach } from "bun:test"
2
+ import { DiffRenderable } from "./Diff.js"
3
+ import { SyntaxStyle } from "../syntax-style.js"
4
+ import { RGBA, parseColor } from "../lib/RGBA.js"
5
+ import { createMockMouse, createTestRenderer, type TestRenderer } from "../testing.js"
6
+ import { MockTreeSitterClient } from "../testing/mock-tree-sitter-client.js"
7
+ import type { SimpleHighlight } from "../lib/tree-sitter/types.js"
8
+ import { settleDiffHighlighting } from "./__tests__/renderable-test-utils.js"
9
+
10
+ let currentRenderer: TestRenderer
11
+ let renderOnce: () => Promise<void>
12
+ let captureFrame: () => string
13
+
14
+ beforeEach(async () => {
15
+ const testRenderer = await createTestRenderer({ width: 80, height: 20 })
16
+ currentRenderer = testRenderer.renderer
17
+ renderOnce = testRenderer.renderOnce
18
+ captureFrame = testRenderer.captureCharFrame
19
+ })
20
+
21
+ afterEach(async () => {
22
+ if (currentRenderer) {
23
+ currentRenderer.destroy()
24
+ }
25
+ })
26
+
27
+ const simpleDiff = `--- a/test.js
28
+ +++ b/test.js
29
+ @@ -1,3 +1,3 @@
30
+ function hello() {
31
+ - console.log("Hello");
32
+ + console.log("Hello, World!");
33
+ }`
34
+
35
+ const multiLineDiff = `--- a/math.js
36
+ +++ b/math.js
37
+ @@ -1,7 +1,11 @@
38
+ function add(a, b) {
39
+ return a + b;
40
+ }
41
+
42
+ +function subtract(a, b) {
43
+ + return a - b;
44
+ +}
45
+ +
46
+ function multiply(a, b) {
47
+ - return a * b;
48
+ + return a * b * 1;
49
+ }`
50
+
51
+ const addOnlyDiff = `--- a/new.js
52
+ +++ b/new.js
53
+ @@ -0,0 +1,3 @@
54
+ +function newFunction() {
55
+ + return true;
56
+ +}`
57
+
58
+ const removeOnlyDiff = `--- a/old.js
59
+ +++ b/old.js
60
+ @@ -1,3 +0,0 @@
61
+ -function oldFunction() {
62
+ - return false;
63
+ -}`
64
+
65
+ const largeDiff = `--- a/large.js
66
+ +++ b/large.js
67
+ @@ -42,9 +42,10 @@
68
+ const line42 = 'context';
69
+ const line43 = 'context';
70
+ -const line44 = 'removed';
71
+ +const line44 = 'added';
72
+ const line45 = 'context';
73
+ +const line46 = 'added';
74
+ const line47 = 'context';
75
+ const line48 = 'context';
76
+ -const line49 = 'removed';
77
+ +const line49 = 'changed';
78
+ const line50 = 'context';
79
+ const line51 = 'context';`
80
+
81
+ test("DiffRenderable - basic construction with unified view", async () => {
82
+ const syntaxStyle = SyntaxStyle.fromStyles({
83
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
84
+ })
85
+
86
+ const diffRenderable = new DiffRenderable(currentRenderer, {
87
+ id: "test-diff",
88
+ diff: simpleDiff,
89
+ view: "unified",
90
+ syntaxStyle,
91
+ })
92
+
93
+ expect(diffRenderable.diff).toBe(simpleDiff)
94
+ expect(diffRenderable.view).toBe("unified")
95
+ })
96
+
97
+ test("DiffRenderable - basic construction with split view", async () => {
98
+ const syntaxStyle = SyntaxStyle.fromStyles({
99
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
100
+ })
101
+
102
+ const diffRenderable = new DiffRenderable(currentRenderer, {
103
+ id: "test-diff",
104
+ diff: simpleDiff,
105
+ view: "split",
106
+ syntaxStyle,
107
+ })
108
+
109
+ expect(diffRenderable.diff).toBe(simpleDiff)
110
+ expect(diffRenderable.view).toBe("split")
111
+ })
112
+
113
+ test("DiffRenderable - defaults to unified view", async () => {
114
+ const syntaxStyle = SyntaxStyle.fromStyles({
115
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
116
+ })
117
+
118
+ const diffRenderable = new DiffRenderable(currentRenderer, {
119
+ id: "test-diff",
120
+ diff: simpleDiff,
121
+ syntaxStyle,
122
+ })
123
+
124
+ expect(diffRenderable.view).toBe("unified")
125
+ })
126
+
127
+ test("DiffRenderable - unified view renders correctly", async () => {
128
+ const syntaxStyle = SyntaxStyle.fromStyles({
129
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
130
+ })
131
+
132
+ const diffRenderable = new DiffRenderable(currentRenderer, {
133
+ id: "test-diff",
134
+ diff: simpleDiff,
135
+ view: "unified",
136
+ syntaxStyle,
137
+ width: "100%",
138
+ height: "100%",
139
+ })
140
+
141
+ currentRenderer.root.add(diffRenderable)
142
+ await renderOnce()
143
+
144
+ const frame = captureFrame()
145
+ expect(frame).toMatchSnapshot("unified view simple diff")
146
+
147
+ // Check that both removed and added lines are present
148
+ expect(frame).toContain('console.log("Hello")')
149
+ expect(frame).toContain('console.log("Hello, World!")')
150
+ })
151
+
152
+ test("DiffRenderable - split view renders correctly", async () => {
153
+ const syntaxStyle = SyntaxStyle.fromStyles({
154
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
155
+ })
156
+
157
+ const diffRenderable = new DiffRenderable(currentRenderer, {
158
+ id: "test-diff",
159
+ diff: simpleDiff,
160
+ view: "split",
161
+ syntaxStyle,
162
+ width: "100%",
163
+ height: "100%",
164
+ })
165
+
166
+ currentRenderer.root.add(diffRenderable)
167
+ await renderOnce()
168
+
169
+ const frame = captureFrame()
170
+ expect(frame).toMatchSnapshot("split view simple diff")
171
+
172
+ // In split view, both sides should be visible (may be wrapped)
173
+ expect(frame).toContain("console.log")
174
+ expect(frame).toContain("Hello")
175
+ expect(frame).toContain("World")
176
+ })
177
+
178
+ test("DiffRenderable - multi-line diff unified view", async () => {
179
+ const syntaxStyle = SyntaxStyle.fromStyles({
180
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
181
+ })
182
+
183
+ const diffRenderable = new DiffRenderable(currentRenderer, {
184
+ id: "test-diff",
185
+ diff: multiLineDiff,
186
+ view: "unified",
187
+ syntaxStyle,
188
+ width: "100%",
189
+ height: "100%",
190
+ })
191
+
192
+ currentRenderer.root.add(diffRenderable)
193
+ await renderOnce()
194
+
195
+ const frame = captureFrame()
196
+ expect(frame).toMatchSnapshot("unified view multi-line diff")
197
+
198
+ // Check for additions
199
+ expect(frame).toContain("function subtract")
200
+ // Check for modifications
201
+ expect(frame).toContain("a * b * 1")
202
+ })
203
+
204
+ test("DiffRenderable - multi-line diff split view", async () => {
205
+ const syntaxStyle = SyntaxStyle.fromStyles({
206
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
207
+ })
208
+
209
+ const diffRenderable = new DiffRenderable(currentRenderer, {
210
+ id: "test-diff",
211
+ diff: multiLineDiff,
212
+ view: "split",
213
+ syntaxStyle,
214
+ width: "100%",
215
+ height: "100%",
216
+ })
217
+
218
+ currentRenderer.root.add(diffRenderable)
219
+ await renderOnce()
220
+
221
+ const frame = captureFrame()
222
+ expect(frame).toMatchSnapshot("split view multi-line diff")
223
+
224
+ // Left side should have old code
225
+ expect(frame).toContain("a * b")
226
+ // Right side should have new code
227
+ expect(frame).toContain("subtract")
228
+ })
229
+
230
+ test("DiffRenderable - add-only diff unified view", async () => {
231
+ const syntaxStyle = SyntaxStyle.fromStyles({
232
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
233
+ })
234
+
235
+ const diffRenderable = new DiffRenderable(currentRenderer, {
236
+ id: "test-diff",
237
+ diff: addOnlyDiff,
238
+ view: "unified",
239
+ syntaxStyle,
240
+ width: "100%",
241
+ height: "100%",
242
+ })
243
+
244
+ currentRenderer.root.add(diffRenderable)
245
+ await renderOnce()
246
+
247
+ const frame = captureFrame()
248
+ expect(frame).toMatchSnapshot("unified view add-only diff")
249
+
250
+ expect(frame).toContain("newFunction")
251
+ })
252
+
253
+ test("DiffRenderable - add-only diff split view", async () => {
254
+ const syntaxStyle = SyntaxStyle.fromStyles({
255
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
256
+ })
257
+
258
+ const diffRenderable = new DiffRenderable(currentRenderer, {
259
+ id: "test-diff",
260
+ diff: addOnlyDiff,
261
+ view: "split",
262
+ syntaxStyle,
263
+ width: "100%",
264
+ height: "100%",
265
+ })
266
+
267
+ currentRenderer.root.add(diffRenderable)
268
+ await renderOnce()
269
+
270
+ const frame = captureFrame()
271
+ expect(frame).toMatchSnapshot("split view add-only diff")
272
+
273
+ // Right side should have the new function
274
+ expect(frame).toContain("newFunction")
275
+ })
276
+
277
+ test("DiffRenderable - remove-only diff unified view", async () => {
278
+ const syntaxStyle = SyntaxStyle.fromStyles({
279
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
280
+ })
281
+
282
+ const diffRenderable = new DiffRenderable(currentRenderer, {
283
+ id: "test-diff",
284
+ diff: removeOnlyDiff,
285
+ view: "unified",
286
+ syntaxStyle,
287
+ width: "100%",
288
+ height: "100%",
289
+ })
290
+
291
+ currentRenderer.root.add(diffRenderable)
292
+ await renderOnce()
293
+
294
+ const frame = captureFrame()
295
+ expect(frame).toMatchSnapshot("unified view remove-only diff")
296
+
297
+ expect(frame).toContain("oldFunction")
298
+ })
299
+
300
+ test("DiffRenderable - remove-only diff split view", async () => {
301
+ const syntaxStyle = SyntaxStyle.fromStyles({
302
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
303
+ })
304
+
305
+ const diffRenderable = new DiffRenderable(currentRenderer, {
306
+ id: "test-diff",
307
+ diff: removeOnlyDiff,
308
+ view: "split",
309
+ syntaxStyle,
310
+ width: "100%",
311
+ height: "100%",
312
+ })
313
+
314
+ currentRenderer.root.add(diffRenderable)
315
+ await renderOnce()
316
+
317
+ const frame = captureFrame()
318
+ expect(frame).toMatchSnapshot("split view remove-only diff")
319
+
320
+ // Left side should have the old function
321
+ expect(frame).toContain("oldFunction")
322
+ })
323
+
324
+ test("DiffRenderable - large line numbers displayed correctly", async () => {
325
+ const syntaxStyle = SyntaxStyle.fromStyles({
326
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
327
+ })
328
+
329
+ const diffRenderable = new DiffRenderable(currentRenderer, {
330
+ id: "test-diff",
331
+ diff: largeDiff,
332
+ view: "unified",
333
+ syntaxStyle,
334
+ showLineNumbers: true,
335
+ width: "100%",
336
+ height: "100%",
337
+ })
338
+
339
+ currentRenderer.root.add(diffRenderable)
340
+ await renderOnce()
341
+
342
+ const frame = captureFrame()
343
+ expect(frame).toMatchSnapshot("unified view large line numbers")
344
+
345
+ // Check that line numbers in the 40s are displayed
346
+ expect(frame).toMatch(/4[0-9]/)
347
+ })
348
+
349
+ test("DiffRenderable - can toggle view mode", async () => {
350
+ const syntaxStyle = SyntaxStyle.fromStyles({
351
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
352
+ })
353
+
354
+ const diffRenderable = new DiffRenderable(currentRenderer, {
355
+ id: "test-diff",
356
+ diff: simpleDiff,
357
+ view: "unified",
358
+ syntaxStyle,
359
+ width: "100%",
360
+ height: "100%",
361
+ })
362
+
363
+ currentRenderer.root.add(diffRenderable)
364
+ await renderOnce()
365
+
366
+ const unifiedFrame = captureFrame()
367
+ expect(diffRenderable.view).toBe("unified")
368
+
369
+ // Switch to split view
370
+ diffRenderable.view = "split"
371
+ await renderOnce()
372
+
373
+ const splitFrame = captureFrame()
374
+ expect(diffRenderable.view).toBe("split")
375
+
376
+ // Frames should be different
377
+ expect(unifiedFrame).not.toBe(splitFrame)
378
+ })
379
+
380
+ test("DiffRenderable - can update diff content", async () => {
381
+ const syntaxStyle = SyntaxStyle.fromStyles({
382
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
383
+ })
384
+
385
+ const diffRenderable = new DiffRenderable(currentRenderer, {
386
+ id: "test-diff",
387
+ diff: simpleDiff,
388
+ view: "unified",
389
+ syntaxStyle,
390
+ width: "100%",
391
+ height: "100%",
392
+ })
393
+
394
+ currentRenderer.root.add(diffRenderable)
395
+ await renderOnce()
396
+
397
+ const frame1 = captureFrame()
398
+ expect(frame1).toContain("Hello")
399
+
400
+ // Update diff
401
+ diffRenderable.diff = multiLineDiff
402
+ await renderOnce()
403
+
404
+ const frame2 = captureFrame()
405
+ expect(frame2).toContain("subtract")
406
+ expect(frame2).not.toContain('console.log("Hello")')
407
+ })
408
+
409
+ test("DiffRenderable - can toggle line numbers", async () => {
410
+ const syntaxStyle = SyntaxStyle.fromStyles({
411
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
412
+ })
413
+
414
+ const diffRenderable = new DiffRenderable(currentRenderer, {
415
+ id: "test-diff",
416
+ diff: simpleDiff,
417
+ view: "unified",
418
+ syntaxStyle,
419
+ showLineNumbers: true,
420
+ width: "100%",
421
+ height: "100%",
422
+ })
423
+
424
+ currentRenderer.root.add(diffRenderable)
425
+ await renderOnce()
426
+
427
+ expect(diffRenderable.showLineNumbers).toBe(true)
428
+
429
+ // Hide line numbers
430
+ diffRenderable.showLineNumbers = false
431
+ await renderOnce()
432
+
433
+ expect(diffRenderable.showLineNumbers).toBe(false)
434
+ })
435
+
436
+ test("DiffRenderable - can update filetype", async () => {
437
+ const syntaxStyle = SyntaxStyle.fromStyles({
438
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
439
+ keyword: { fg: RGBA.fromValues(1, 0, 0, 1) },
440
+ })
441
+
442
+ const diffRenderable = new DiffRenderable(currentRenderer, {
443
+ id: "test-diff",
444
+ diff: simpleDiff,
445
+ view: "unified",
446
+ syntaxStyle,
447
+ filetype: "javascript",
448
+ width: "100%",
449
+ height: "100%",
450
+ })
451
+
452
+ currentRenderer.root.add(diffRenderable)
453
+ await renderOnce()
454
+
455
+ expect(diffRenderable.filetype).toBe("javascript")
456
+
457
+ // Update filetype
458
+ diffRenderable.filetype = "typescript"
459
+ expect(diffRenderable.filetype).toBe("typescript")
460
+ })
461
+
462
+ test("DiffRenderable - handles empty diff", async () => {
463
+ const syntaxStyle = SyntaxStyle.fromStyles({
464
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
465
+ })
466
+
467
+ const diffRenderable = new DiffRenderable(currentRenderer, {
468
+ id: "test-diff",
469
+ diff: "",
470
+ view: "unified",
471
+ syntaxStyle,
472
+ width: "100%",
473
+ height: "100%",
474
+ })
475
+
476
+ currentRenderer.root.add(diffRenderable)
477
+ await renderOnce()
478
+
479
+ // Should not crash with empty diff
480
+ expect(diffRenderable.diff).toBe("")
481
+ })
482
+
483
+ test("DiffRenderable - handles diff with no changes", async () => {
484
+ const syntaxStyle = SyntaxStyle.fromStyles({
485
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
486
+ })
487
+
488
+ const noChangeDiff = `--- a/test.js
489
+ +++ b/test.js
490
+ @@ -1,3 +1,3 @@
491
+ function hello() {
492
+ console.log("Hello");
493
+ }`
494
+
495
+ const diffRenderable = new DiffRenderable(currentRenderer, {
496
+ id: "test-diff",
497
+ diff: noChangeDiff,
498
+ view: "unified",
499
+ syntaxStyle,
500
+ width: "100%",
501
+ height: "100%",
502
+ })
503
+
504
+ currentRenderer.root.add(diffRenderable)
505
+ await renderOnce()
506
+
507
+ const frame = captureFrame()
508
+ expect(frame).toContain("function hello")
509
+ })
510
+
511
+ test("DiffRenderable - can update wrapMode", async () => {
512
+ const syntaxStyle = SyntaxStyle.fromStyles({
513
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
514
+ })
515
+
516
+ const diffRenderable = new DiffRenderable(currentRenderer, {
517
+ id: "test-diff",
518
+ diff: simpleDiff,
519
+ view: "unified",
520
+ syntaxStyle,
521
+ wrapMode: "word",
522
+ width: "100%",
523
+ height: "100%",
524
+ })
525
+
526
+ currentRenderer.root.add(diffRenderable)
527
+ await renderOnce()
528
+
529
+ expect(diffRenderable.wrapMode).toBe("word")
530
+
531
+ diffRenderable.wrapMode = "char"
532
+ expect(diffRenderable.wrapMode).toBe("char")
533
+ })
534
+
535
+ test("DiffRenderable - split view alignment with empty lines", async () => {
536
+ const syntaxStyle = SyntaxStyle.fromStyles({
537
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
538
+ })
539
+
540
+ // Diff with additions that should create empty lines on left
541
+ const alignmentDiff = `--- a/test.js
542
+ +++ b/test.js
543
+ @@ -1,2 +1,5 @@
544
+ line1
545
+ +line2_added
546
+ +line3_added
547
+ +line4_added
548
+ line5`
549
+
550
+ const diffRenderable = new DiffRenderable(currentRenderer, {
551
+ id: "test-diff",
552
+ diff: alignmentDiff,
553
+ view: "split",
554
+ syntaxStyle,
555
+ width: "100%",
556
+ height: "100%",
557
+ })
558
+
559
+ currentRenderer.root.add(diffRenderable)
560
+ await renderOnce()
561
+
562
+ const frame = captureFrame()
563
+ expect(frame).toMatchSnapshot("split view alignment")
564
+
565
+ // Both sides should have same number of lines (with empty lines for alignment)
566
+ expect(frame).toContain("line1")
567
+ expect(frame).toContain("line5")
568
+ expect(frame).toContain("line2_added")
569
+ })
570
+
571
+ test("DiffRenderable - context lines shown on both sides in split view", async () => {
572
+ const syntaxStyle = SyntaxStyle.fromStyles({
573
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
574
+ })
575
+
576
+ const diffRenderable = new DiffRenderable(currentRenderer, {
577
+ id: "test-diff",
578
+ diff: multiLineDiff,
579
+ view: "split",
580
+ syntaxStyle,
581
+ width: "100%",
582
+ height: "100%",
583
+ })
584
+
585
+ currentRenderer.root.add(diffRenderable)
586
+ await renderOnce()
587
+
588
+ const frame = captureFrame()
589
+
590
+ // Context lines should appear on both sides
591
+ expect(frame).toContain("function add")
592
+ expect(frame).toContain("function multiply")
593
+ })
594
+
595
+ test("DiffRenderable - custom colors applied correctly", async () => {
596
+ const syntaxStyle = SyntaxStyle.fromStyles({
597
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
598
+ })
599
+
600
+ const diffRenderable = new DiffRenderable(currentRenderer, {
601
+ id: "test-diff",
602
+ diff: simpleDiff,
603
+ view: "unified",
604
+ syntaxStyle,
605
+ addedBg: "#00ff00",
606
+ removedBg: "#ff0000",
607
+ addedSignColor: "#00ff00",
608
+ removedSignColor: "#ff0000",
609
+ width: "100%",
610
+ height: "100%",
611
+ })
612
+
613
+ currentRenderer.root.add(diffRenderable)
614
+ await renderOnce()
615
+
616
+ // Should not crash with custom colors
617
+ const frame = captureFrame()
618
+ expect(frame).toContain('console.log("Hello")')
619
+ })
620
+
621
+ test("DiffRenderable - line number fg/bg colors update after construction", async () => {
622
+ const syntaxStyle = SyntaxStyle.fromStyles({
623
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
624
+ })
625
+
626
+ const diffRenderable = new DiffRenderable(currentRenderer, {
627
+ id: "test-diff",
628
+ diff: simpleDiff,
629
+ view: "unified",
630
+ syntaxStyle,
631
+ lineNumberFg: "#445566",
632
+ lineNumberBg: "#101820",
633
+ width: "100%",
634
+ height: "100%",
635
+ })
636
+
637
+ currentRenderer.root.add(diffRenderable)
638
+ await renderOnce()
639
+
640
+ const findCharPosition = (char: string): { x: number; y: number } | null => {
641
+ const buffer = currentRenderer.currentRenderBuffer
642
+ const charBuffer = buffer.buffers.char
643
+ const codePoint = char.codePointAt(0)
644
+ if (codePoint === undefined) return null
645
+
646
+ for (let y = 0; y < buffer.height; y++) {
647
+ for (let x = 0; x < buffer.width; x++) {
648
+ if (charBuffer[y * buffer.width + x] === codePoint) {
649
+ return { x, y }
650
+ }
651
+ }
652
+ }
653
+
654
+ return null
655
+ }
656
+
657
+ const getColorAt = (channel: "fg" | "bg", x: number, y: number) => {
658
+ const buffer = currentRenderer.currentRenderBuffer
659
+ const colorBuffer = channel === "fg" ? buffer.buffers.fg : buffer.buffers.bg
660
+ const offset = (y * buffer.width + x) * 4
661
+
662
+ return {
663
+ r: colorBuffer[offset],
664
+ g: colorBuffer[offset + 1],
665
+ b: colorBuffer[offset + 2],
666
+ a: colorBuffer[offset + 3],
667
+ }
668
+ }
669
+
670
+ const expectColorClose = (
671
+ actual: { r: number; g: number; b: number; a: number },
672
+ expected: { r: number; g: number; b: number; a: number },
673
+ ) => {
674
+ expect(actual.r).toBeCloseTo(expected.r, 2)
675
+ expect(actual.g).toBeCloseTo(expected.g, 2)
676
+ expect(actual.b).toBeCloseTo(expected.b, 2)
677
+ expect(actual.a).toBeCloseTo(expected.a, 2)
678
+ }
679
+
680
+ const initialPos = findCharPosition("1")
681
+ expect(initialPos).not.toBeNull()
682
+ expectColorClose(getColorAt("fg", initialPos!.x, initialPos!.y), parseColor("#445566"))
683
+ expectColorClose(getColorAt("bg", initialPos!.x, initialPos!.y), parseColor("#101820"))
684
+
685
+ diffRenderable.lineNumberFg = "#ff00ff"
686
+ diffRenderable.lineNumberBg = "#2a2a2a"
687
+ await renderOnce()
688
+
689
+ const updatedPos = findCharPosition("1")
690
+ expect(updatedPos).not.toBeNull()
691
+ expectColorClose(getColorAt("fg", updatedPos!.x, updatedPos!.y), parseColor("#ff00ff"))
692
+ expectColorClose(getColorAt("bg", updatedPos!.x, updatedPos!.y), parseColor("#2a2a2a"))
693
+ })
694
+
695
+ test("DiffRenderable - line numbers hidden for empty alignment lines in split view", async () => {
696
+ const syntaxStyle = SyntaxStyle.fromStyles({
697
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
698
+ })
699
+
700
+ const diffRenderable = new DiffRenderable(currentRenderer, {
701
+ id: "test-diff",
702
+ diff: addOnlyDiff,
703
+ view: "split",
704
+ syntaxStyle,
705
+ showLineNumbers: true,
706
+ width: "100%",
707
+ height: "100%",
708
+ })
709
+
710
+ currentRenderer.root.add(diffRenderable)
711
+ await renderOnce()
712
+
713
+ const frame = captureFrame()
714
+ expect(frame).toMatchSnapshot("split view with hidden line numbers for empty lines")
715
+
716
+ // Right side should have line numbers for new lines
717
+ // Left side should have empty lines without line numbers
718
+ })
719
+
720
+ test("DiffRenderable - stable rendering across multiple frames (no visual glitches)", async () => {
721
+ const syntaxStyle = SyntaxStyle.fromStyles({
722
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
723
+ })
724
+
725
+ const diffRenderable = new DiffRenderable(currentRenderer, {
726
+ id: "test-diff",
727
+ diff: multiLineDiff,
728
+ view: "unified",
729
+ syntaxStyle,
730
+ showLineNumbers: true,
731
+ width: "100%",
732
+ height: "100%",
733
+ })
734
+
735
+ currentRenderer.root.add(diffRenderable)
736
+
737
+ // Render the initial frame
738
+ await renderOnce()
739
+
740
+ const frameAfterAutoRender = captureFrame()
741
+
742
+ // Now call renderOnce explicitly (this would be the second render)
743
+ await renderOnce()
744
+ const firstFrame = captureFrame()
745
+
746
+ // Render a third time
747
+ await renderOnce()
748
+ const secondFrame = captureFrame()
749
+
750
+ // BEHAVIORAL EXPECTATION: All frames should be identical
751
+ // If frames differ, it indicates a visual glitch (e.g., gutter width changing,
752
+ // content shifting, or partial rendering)
753
+ expect(frameAfterAutoRender).toBe(firstFrame)
754
+ expect(firstFrame).toBe(secondFrame)
755
+
756
+ // Verify all frames have complete content (not partial rendering)
757
+ expect(frameAfterAutoRender).toContain("function add")
758
+ expect(frameAfterAutoRender).toContain("function subtract")
759
+ expect(frameAfterAutoRender).toContain("function multiply")
760
+
761
+ // Verify line numbers are present and properly aligned
762
+ // If gutter width is wrong, line numbers will be misaligned or cut off
763
+ const frameLines = frameAfterAutoRender.split("\n")
764
+ const linesWithLineNumbers = frameLines.filter((l) => l.match(/^\s*\d+\s+/))
765
+
766
+ // Should have multiple lines with line numbers
767
+ expect(linesWithLineNumbers.length).toBeGreaterThan(5)
768
+
769
+ // All line number widths should be consistent (not change between renders)
770
+ // Extract just the line number part (before the sign)
771
+ const lineNumberWidths = linesWithLineNumbers
772
+ .map((line) => {
773
+ const match = line.match(/^(\s*\d+)\s/)
774
+ return match ? match[1].length : -1
775
+ })
776
+ .filter((w) => w > 0)
777
+
778
+ // All line numbers should have the same width (indicating stable gutter)
779
+ const uniqueWidths = new Set(lineNumberWidths)
780
+ expect(uniqueWidths.size).toBe(1) // Gutter width should be consistent
781
+ })
782
+
783
+ test("DiffRenderable - can be constructed without diff and set via setter", async () => {
784
+ const syntaxStyle = SyntaxStyle.fromStyles({
785
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
786
+ })
787
+
788
+ // Construct without diff
789
+ const diffRenderable = new DiffRenderable(currentRenderer, {
790
+ id: "test-diff",
791
+ view: "unified",
792
+ syntaxStyle,
793
+ width: "100%",
794
+ height: "100%",
795
+ })
796
+
797
+ currentRenderer.root.add(diffRenderable)
798
+ await renderOnce()
799
+
800
+ // Should render empty
801
+ let frame = captureFrame()
802
+ expect(frame.trim()).toBe("")
803
+
804
+ // Now set diff via setter
805
+ diffRenderable.diff = simpleDiff
806
+ await renderOnce()
807
+
808
+ frame = captureFrame()
809
+ expect(frame).toContain("function hello")
810
+ expect(frame).toContain('console.log("Hello")')
811
+ expect(frame).toContain('console.log("Hello, World!")')
812
+ })
813
+
814
+ test("DiffRenderable - consistent left padding for line numbers > 9", async () => {
815
+ const syntaxStyle = SyntaxStyle.fromStyles({
816
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
817
+ })
818
+
819
+ // Create a diff with line numbers that go into double digits
820
+ const diffWith10PlusLines = `--- a/test.js
821
+ +++ b/test.js
822
+ @@ -8,7 +8,9 @@
823
+ line8
824
+ line9
825
+ -line10_old
826
+ +line10_new
827
+ line11
828
+ +line12_added
829
+ +line13_added
830
+ line14
831
+ line15
832
+ -line16_old
833
+ +line16_new`
834
+
835
+ const diffRenderable = new DiffRenderable(currentRenderer, {
836
+ id: "test-diff",
837
+ diff: diffWith10PlusLines,
838
+ view: "unified",
839
+ syntaxStyle,
840
+ showLineNumbers: true,
841
+ width: "100%",
842
+ height: "100%",
843
+ })
844
+
845
+ currentRenderer.root.add(diffRenderable)
846
+ await renderOnce()
847
+
848
+ const frame = captureFrame()
849
+ expect(frame).toMatchSnapshot("unified view with double-digit line numbers")
850
+
851
+ const frameLines = frame.split("\n")
852
+
853
+ // Find lines in the output
854
+ // Line 8 (single digit) should have left padding (appears as " 8 line8")
855
+ const line8 = frameLines.find((l) => l.includes("line8"))
856
+ expect(line8).toBeTruthy()
857
+ const line8Match = line8!.match(/^( +)8 /)
858
+ expect(line8Match).toBeTruthy()
859
+ expect(line8Match![1].length).toBeGreaterThanOrEqual(1) // At least 1 space of left padding
860
+
861
+ // Line 10 (double digit) should have left padding (appears as " 10 line10" or " 11 line10")
862
+ const line10 = frameLines.find((l) => l.includes("line10"))
863
+ expect(line10).toBeTruthy()
864
+ const line10Match = line10!.match(/^( +)1[01] /)
865
+ expect(line10Match).toBeTruthy()
866
+ expect(line10Match![1].length).toBeGreaterThanOrEqual(1) // At least 1 space of left padding
867
+
868
+ // Line 16 (double digit) should have left padding
869
+ // Note: With correct line numbers, the removed line shows as 14 - and added shows as 16 +
870
+ const line16 = frameLines.find((l) => l.includes("line16"))
871
+ expect(line16).toBeTruthy()
872
+ // Match either 14 - or 16 + (the correct line numbers after the fix)
873
+ const line16Match = line16!.match(/^( +)(14 -|16 \+) /)
874
+ expect(line16Match).toBeTruthy()
875
+ expect(line16Match![1].length).toBeGreaterThanOrEqual(1) // At least 1 space of left padding
876
+ })
877
+
878
+ test("DiffRenderable - line numbers are correct in unified view", async () => {
879
+ const syntaxStyle = SyntaxStyle.fromStyles({
880
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
881
+ })
882
+
883
+ const diffRenderable = new DiffRenderable(currentRenderer, {
884
+ id: "test-diff",
885
+ diff: simpleDiff,
886
+ view: "unified",
887
+ syntaxStyle,
888
+ showLineNumbers: true,
889
+ width: "100%",
890
+ height: "100%",
891
+ })
892
+
893
+ currentRenderer.root.add(diffRenderable)
894
+ await renderOnce()
895
+
896
+ const frame = captureFrame()
897
+ const frameLines = frame.split("\n")
898
+
899
+ // Line 2 is removed (old file line 2)
900
+ const removedLine = frameLines.find((l) => l.includes('console.log("Hello");'))
901
+ expect(removedLine).toBeTruthy()
902
+ expect(removedLine).toMatch(/^ *2 -/)
903
+
904
+ // Line 2 is added (new file line 2) - NOT line 3!
905
+ const addedLine = frameLines.find((l) => l.includes('console.log("Hello, World!")'))
906
+ expect(addedLine).toBeTruthy()
907
+ expect(addedLine).toMatch(/^ *2 \+/)
908
+ })
909
+
910
+ test("DiffRenderable - line numbers are correct in split view", async () => {
911
+ const syntaxStyle = SyntaxStyle.fromStyles({
912
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
913
+ })
914
+
915
+ const diffRenderable = new DiffRenderable(currentRenderer, {
916
+ id: "test-diff",
917
+ diff: simpleDiff,
918
+ view: "split",
919
+ syntaxStyle,
920
+ showLineNumbers: true,
921
+ width: "100%",
922
+ height: "100%",
923
+ })
924
+
925
+ currentRenderer.root.add(diffRenderable)
926
+ await renderOnce()
927
+
928
+ const frame = captureFrame()
929
+ const frameLines = frame.split("\n")
930
+
931
+ // In split view, both sides are on the same terminal line
932
+ // Left side: line 2 is removed, Right side: line 2 is added
933
+ const splitLine = frameLines.find((l) => l.includes('console.log("Hello, World!")'))
934
+ expect(splitLine).toBeTruthy()
935
+ // Should contain line 2 with - on left side
936
+ expect(splitLine).toMatch(/^ *2 -/)
937
+ // Should contain line 2 with + on right side (later in the same line)
938
+ expect(splitLine).toMatch(/2 \+.*console\.log\("Hello, World!"\)/)
939
+ })
940
+
941
+ test("DiffRenderable - split view should not wrap lines prematurely", async () => {
942
+ const syntaxStyle = SyntaxStyle.fromStyles({
943
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
944
+ })
945
+
946
+ // Create a diff with long lines that should fit in split view
947
+ const longLineDiff = `--- a/test.js
948
+ +++ b/test.js
949
+ @@ -1,4 +1,4 @@
950
+ class Calculator {
951
+ - subtract(a: number, b: number): number {
952
+ + subtract(a: number, b: number, c: number = 0): number {
953
+ return a - b;
954
+ }`
955
+
956
+ const diffRenderable = new DiffRenderable(currentRenderer, {
957
+ id: "test-diff",
958
+ diff: longLineDiff,
959
+ view: "split",
960
+ syntaxStyle,
961
+ showLineNumbers: true,
962
+ wrapMode: "word",
963
+ width: "100%",
964
+ height: "100%",
965
+ })
966
+
967
+ currentRenderer.root.add(diffRenderable)
968
+ await renderOnce()
969
+
970
+ const frame = captureFrame()
971
+ const frameLines = frame.split("\n")
972
+
973
+ // Find the line with "subtract" on the left side
974
+ const leftSubtractLine = frameLines.find((l) => l.includes("subtract") && l.includes("b: number):"))
975
+ expect(leftSubtractLine).toBeTruthy()
976
+
977
+ // The line should NOT be wrapped - "subtract(a: number, b: number):" should be on one line
978
+ // In an 80-char terminal with split view, each side gets ~40 chars (minus line numbers)
979
+ // "subtract(a: number, b: number):" is 34 chars, so it should fit without wrapping
980
+ expect(leftSubtractLine).toMatch(/subtract\(a: number, b: number\):/)
981
+
982
+ // Find the line with "subtract" on the right side - it might be on the same line or next line
983
+ // The signature is longer and might wrap
984
+ const rightSubtractLines = frameLines.filter((l) => l.includes("subtract") || l.includes("c: number"))
985
+ expect(rightSubtractLines.length).toBeGreaterThan(0)
986
+
987
+ // The key assertion is that the left side doesn't wrap prematurely
988
+ // We've already verified that above
989
+ })
990
+
991
+ test("DiffRenderable - split view alignment with calculator diff", async () => {
992
+ const syntaxStyle = SyntaxStyle.fromStyles({
993
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
994
+ })
995
+
996
+ const calculatorDiff = `--- a/calculator.ts
997
+ +++ b/calculator.ts
998
+ @@ -1,13 +1,20 @@
999
+ class Calculator {
1000
+ add(a: number, b: number): number {
1001
+ return a + b;
1002
+ }
1003
+
1004
+ - subtract(a: number, b: number): number {
1005
+ - return a - b;
1006
+ + subtract(a: number, b: number, c: number = 0): number {
1007
+ + return a - b - c;
1008
+ }
1009
+
1010
+ multiply(a: number, b: number): number {
1011
+ return a * b;
1012
+ }
1013
+ +
1014
+ + divide(a: number, b: number): number {
1015
+ + if (b === 0) {
1016
+ + throw new Error("Division by zero");
1017
+ + }
1018
+ + return a / b;
1019
+ + }
1020
+ }`
1021
+
1022
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1023
+ id: "test-diff",
1024
+ diff: calculatorDiff,
1025
+ view: "split",
1026
+ syntaxStyle,
1027
+ showLineNumbers: true,
1028
+ wrapMode: "none",
1029
+ width: "100%",
1030
+ height: "100%",
1031
+ })
1032
+
1033
+ currentRenderer.root.add(diffRenderable)
1034
+ await renderOnce()
1035
+
1036
+ const frame = captureFrame()
1037
+ const frameLines = frame.split("\n")
1038
+
1039
+ // Find the closing brace on the left (old line 13)
1040
+ const leftClosingBrace = frameLines.find((l) => l.match(/^\s*13\s+\}/))
1041
+ expect(leftClosingBrace).toBeTruthy()
1042
+
1043
+ // Find the closing brace on the right (new line 20)
1044
+ const rightClosingBrace = frameLines.find((l) => l.match(/\s*20\s+\}/))
1045
+ expect(rightClosingBrace).toBeTruthy()
1046
+
1047
+ // They should be on the SAME line in the output
1048
+ expect(leftClosingBrace).toBe(rightClosingBrace)
1049
+ })
1050
+
1051
+ test("DiffRenderable - switching between unified and split views multiple times", async () => {
1052
+ const syntaxStyle = SyntaxStyle.fromStyles({
1053
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1054
+ })
1055
+
1056
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1057
+ id: "test-diff",
1058
+ diff: simpleDiff,
1059
+ view: "unified",
1060
+ syntaxStyle,
1061
+ showLineNumbers: true,
1062
+ width: "100%",
1063
+ height: "100%",
1064
+ })
1065
+
1066
+ currentRenderer.root.add(diffRenderable)
1067
+ await renderOnce()
1068
+
1069
+ // Step 1: Verify unified view works
1070
+ let frame = captureFrame()
1071
+ expect(frame).toContain("function hello")
1072
+ expect(frame).toContain('console.log("Hello")')
1073
+ expect(frame).toContain('console.log("Hello, World!")')
1074
+
1075
+ // Step 2: Switch to split view
1076
+ diffRenderable.view = "split"
1077
+ await renderOnce()
1078
+
1079
+ frame = captureFrame()
1080
+ expect(frame).toContain("function hello")
1081
+ expect(frame).toContain('console.log("Hello")')
1082
+ expect(frame).toContain('console.log("Hello, World!")')
1083
+
1084
+ // Step 3: Switch back to unified view
1085
+ diffRenderable.view = "unified"
1086
+ await renderOnce()
1087
+
1088
+ frame = captureFrame()
1089
+ expect(frame).toContain("function hello")
1090
+ expect(frame).toContain('console.log("Hello")')
1091
+ expect(frame).toContain('console.log("Hello, World!")')
1092
+
1093
+ // Step 4: Switch to split view again (this currently fails)
1094
+ diffRenderable.view = "split"
1095
+ await renderOnce()
1096
+
1097
+ frame = captureFrame()
1098
+ expect(frame).toContain("function hello")
1099
+ expect(frame).toContain('console.log("Hello")')
1100
+ expect(frame).toContain('console.log("Hello, World!")')
1101
+ })
1102
+
1103
+ test("DiffRenderable - wrapMode works in unified view", async () => {
1104
+ const syntaxStyle = SyntaxStyle.fromStyles({
1105
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1106
+ })
1107
+
1108
+ // Create a diff with a very long line that will wrap
1109
+ const longLineDiff = `--- a/test.js
1110
+ +++ b/test.js
1111
+ @@ -1,3 +1,3 @@
1112
+ function hello() {
1113
+ - console.log("This is a very long line that should wrap when wrapMode is set to word but not when it is set to none");
1114
+ + console.log("This is a very long line that has been modified and should wrap when wrapMode is set to word but not when it is set to none");
1115
+ }`
1116
+
1117
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1118
+ id: "test-diff",
1119
+ diff: longLineDiff,
1120
+ view: "unified",
1121
+ syntaxStyle,
1122
+ showLineNumbers: true,
1123
+ wrapMode: "none",
1124
+ width: 80,
1125
+ height: "100%",
1126
+ })
1127
+
1128
+ currentRenderer.root.add(diffRenderable)
1129
+ await renderOnce()
1130
+
1131
+ // Capture with wrapMode: none
1132
+ const frameNone = captureFrame()
1133
+ expect(frameNone).toMatchSnapshot("wrapMode-none")
1134
+
1135
+ // Change to wrapMode: word
1136
+ diffRenderable.wrapMode = "word"
1137
+ await renderOnce()
1138
+
1139
+ // Capture with wrapMode: word
1140
+ const frameWord = captureFrame()
1141
+ expect(frameWord).toMatchSnapshot("wrapMode-word")
1142
+
1143
+ // Frames should be different (word wrapping should create more lines)
1144
+ expect(frameNone).not.toBe(frameWord)
1145
+
1146
+ // Change back to wrapMode: none
1147
+ diffRenderable.wrapMode = "none"
1148
+ await renderOnce()
1149
+
1150
+ // Should match the original
1151
+ const frameNoneAgain = captureFrame()
1152
+ expect(frameNoneAgain).toMatchSnapshot("wrapMode-none")
1153
+ expect(frameNoneAgain).toBe(frameNone)
1154
+ })
1155
+
1156
+ test("DiffRenderable - split view with wrapMode honors wrapping alignment", async () => {
1157
+ // Create a larger test renderer to fit the whole diff with wrapping
1158
+ const testRenderer = await createTestRenderer({ width: 80, height: 40 })
1159
+ const renderer = testRenderer.renderer
1160
+ const renderOnce = testRenderer.renderOnce
1161
+ const captureFrame = testRenderer.captureCharFrame
1162
+
1163
+ const syntaxStyle = SyntaxStyle.fromStyles({
1164
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1165
+ })
1166
+
1167
+ const calculatorDiff = `--- a/calculator.ts
1168
+ +++ b/calculator.ts
1169
+ @@ -1,13 +1,20 @@
1170
+ class Calculator {
1171
+ add(a: number, b: number): number {
1172
+ return a + b;
1173
+ }
1174
+
1175
+ - subtract(a: number, b: number): number {
1176
+ - return a - b;
1177
+ + subtract(a: number, b: number, c: number = 0): number {
1178
+ + return a - b - c;
1179
+ }
1180
+
1181
+ multiply(a: number, b: number): number {
1182
+ return a * b;
1183
+ }
1184
+ +
1185
+ + divide(a: number, b: number): number {
1186
+ + if (b === 0) {
1187
+ + throw new Error("Division by zero");
1188
+ + }
1189
+ + return a / b;
1190
+ + }
1191
+ }`
1192
+
1193
+ const diffRenderable = new DiffRenderable(renderer, {
1194
+ id: "test-diff",
1195
+ diff: calculatorDiff,
1196
+ view: "split",
1197
+ syntaxStyle,
1198
+ showLineNumbers: true,
1199
+ wrapMode: "word",
1200
+ width: "100%",
1201
+ height: "100%",
1202
+ })
1203
+
1204
+ renderer.root.add(diffRenderable)
1205
+ await renderOnce()
1206
+
1207
+ // Flush microtask-based deferred rebuild for wrap alignment
1208
+ await Promise.resolve()
1209
+ await renderOnce()
1210
+
1211
+ const frame = captureFrame()
1212
+ const frameLines = frame.split("\n")
1213
+
1214
+ // Find the closing brace on the left (old line 13)
1215
+ const leftClosingBraceLine = frameLines.find((l) => l.match(/^\s*13\s+\}/))
1216
+ expect(leftClosingBraceLine).toBeTruthy()
1217
+
1218
+ // Find the closing brace on the right (new line 20)
1219
+ const rightClosingBraceLine = frameLines.find((l) => l.match(/\s*20\s+\}/))
1220
+ expect(rightClosingBraceLine).toBeTruthy()
1221
+
1222
+ // They should be on the SAME line in the output (same visual row)
1223
+ // even though the right side has wrapped lines above it
1224
+ expect(leftClosingBraceLine).toBe(rightClosingBraceLine)
1225
+
1226
+ // Both sides should have the same number of final visual lines
1227
+ // (counting both logical lines and wrap continuations)
1228
+ // This is hard to assert directly, but if alignment is correct,
1229
+ // the closing braces being on the same line proves it worked
1230
+
1231
+ // Clean up
1232
+ renderer.destroy()
1233
+ })
1234
+
1235
+ test("DiffRenderable - context lines show new line numbers in unified view", async () => {
1236
+ // Create a larger test renderer to fit the whole diff
1237
+ const testRenderer = await createTestRenderer({ width: 80, height: 30 })
1238
+ const renderer = testRenderer.renderer
1239
+ const renderOnce = testRenderer.renderOnce
1240
+ const captureFrame = testRenderer.captureCharFrame
1241
+
1242
+ const syntaxStyle = SyntaxStyle.fromStyles({
1243
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1244
+ })
1245
+
1246
+ // This diff adds lines in the middle, so context lines after additions
1247
+ // should show their NEW line numbers, not old ones
1248
+ const calculatorDiff = `--- a/calculator.ts
1249
+ +++ b/calculator.ts
1250
+ @@ -1,13 +1,20 @@
1251
+ class Calculator {
1252
+ add(a: number, b: number): number {
1253
+ return a + b;
1254
+ }
1255
+
1256
+ - subtract(a: number, b: number): number {
1257
+ - return a - b;
1258
+ + subtract(a: number, b: number, c: number = 0): number {
1259
+ + return a - b - c;
1260
+ }
1261
+
1262
+ multiply(a: number, b: number): number {
1263
+ return a * b;
1264
+ }
1265
+ +
1266
+ + divide(a: number, b: number): number {
1267
+ + if (b === 0) {
1268
+ + throw new Error("Division by zero");
1269
+ + }
1270
+ + return a / b;
1271
+ + }
1272
+ }`
1273
+
1274
+ const diffRenderable = new DiffRenderable(renderer, {
1275
+ id: "test-diff",
1276
+ diff: calculatorDiff,
1277
+ view: "unified",
1278
+ syntaxStyle,
1279
+ showLineNumbers: true,
1280
+ width: "100%",
1281
+ height: "100%",
1282
+ })
1283
+
1284
+ renderer.root.add(diffRenderable)
1285
+ await renderOnce()
1286
+
1287
+ const frame = captureFrame()
1288
+ const frameLines = frame.split("\n")
1289
+
1290
+ // The closing brace "}" for the Calculator class is a context line
1291
+ // In the old file it was at line 13
1292
+ // In the new file it's at line 20 (after adding 7 lines for divide method)
1293
+ // Unified view should show line 20, not line 13
1294
+ // Find the LAST closing brace that's just "}" (at the beginning of indentation, not nested)
1295
+ // This regex matches: optional spaces, digits, spaces, optional sign (+/-), spaces, "}", trailing spaces
1296
+ const closingBraceLines = frameLines.filter((l) => l.match(/^\s*\d+\s+[+-]?\s*\}\s*$/))
1297
+
1298
+ // The last one should be the class closing brace
1299
+ const classClosingBraceLine = closingBraceLines[closingBraceLines.length - 1]
1300
+ expect(classClosingBraceLine).toBeTruthy()
1301
+
1302
+ // Extract the line number from the closing brace line
1303
+ const lineNumberMatch = classClosingBraceLine!.match(/^\s*(\d+)/)
1304
+ expect(lineNumberMatch).toBeTruthy()
1305
+
1306
+ const lineNumber = parseInt(lineNumberMatch![1])
1307
+
1308
+ // The closing brace should show line 20 (new file position), not 13 (old file position)
1309
+ expect(lineNumber).toBe(20)
1310
+
1311
+ // Clean up
1312
+ renderer.destroy()
1313
+ })
1314
+
1315
+ test("DiffRenderable - multiple hunks in unified view", async () => {
1316
+ const syntaxStyle = SyntaxStyle.fromStyles({
1317
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1318
+ })
1319
+
1320
+ // Diff with three separate hunks
1321
+ const multiHunkDiff = `--- a/file.js
1322
+ +++ b/file.js
1323
+ @@ -1,3 +1,3 @@
1324
+ function first() {
1325
+ - return 1;
1326
+ + return "one";
1327
+ }
1328
+ @@ -15,4 +15,5 @@
1329
+ function second() {
1330
+ var x = 10;
1331
+ + var y = 20;
1332
+ return x;
1333
+ }
1334
+ @@ -30,3 +31,3 @@
1335
+ function third() {
1336
+ - console.log("old");
1337
+ + console.log("new");
1338
+ }`
1339
+
1340
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1341
+ id: "test-diff",
1342
+ diff: multiHunkDiff,
1343
+ view: "unified",
1344
+ syntaxStyle,
1345
+ showLineNumbers: true,
1346
+ width: "100%",
1347
+ height: "100%",
1348
+ })
1349
+
1350
+ currentRenderer.root.add(diffRenderable)
1351
+ await renderOnce()
1352
+
1353
+ const frame = captureFrame()
1354
+ expect(frame).toMatchSnapshot("unified view multiple hunks")
1355
+
1356
+ // All three hunks should be present
1357
+ expect(frame).toContain('return "one"')
1358
+ expect(frame).toContain("var y = 20")
1359
+ expect(frame).toContain('console.log("new")')
1360
+
1361
+ // Line numbers should be correct for each hunk
1362
+ const frameLines = frame.split("\n")
1363
+
1364
+ // First hunk around line 2
1365
+ const firstHunkLine = frameLines.find((l) => l.includes('return "one"'))
1366
+ expect(firstHunkLine).toMatch(/2 \+/)
1367
+
1368
+ // Second hunk around line 17 (added line)
1369
+ const secondHunkLine = frameLines.find((l) => l.includes("var y = 20"))
1370
+ expect(secondHunkLine).toMatch(/17 \+/)
1371
+
1372
+ // Third hunk around line 32
1373
+ const thirdHunkLine = frameLines.find((l) => l.includes('console.log("new")'))
1374
+ expect(thirdHunkLine).toMatch(/32 \+/)
1375
+ })
1376
+
1377
+ test("DiffRenderable - multiple hunks in split view", async () => {
1378
+ const syntaxStyle = SyntaxStyle.fromStyles({
1379
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1380
+ })
1381
+
1382
+ const multiHunkDiff = `--- a/file.js
1383
+ +++ b/file.js
1384
+ @@ -1,3 +1,3 @@
1385
+ function first() {
1386
+ - return 1;
1387
+ + return "one";
1388
+ }
1389
+ @@ -15,4 +15,5 @@
1390
+ function second() {
1391
+ var x = 10;
1392
+ + var y = 20;
1393
+ return x;
1394
+ }
1395
+ @@ -30,3 +31,3 @@
1396
+ function third() {
1397
+ - console.log("old");
1398
+ + console.log("new");
1399
+ }`
1400
+
1401
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1402
+ id: "test-diff",
1403
+ diff: multiHunkDiff,
1404
+ view: "split",
1405
+ syntaxStyle,
1406
+ showLineNumbers: true,
1407
+ width: "100%",
1408
+ height: "100%",
1409
+ })
1410
+
1411
+ currentRenderer.root.add(diffRenderable)
1412
+ await renderOnce()
1413
+
1414
+ const frame = captureFrame()
1415
+ expect(frame).toMatchSnapshot("split view multiple hunks")
1416
+
1417
+ // All three hunks should be present in split view
1418
+ expect(frame).toContain('return "one"')
1419
+ expect(frame).toContain("var y = 20")
1420
+ expect(frame).toContain('console.log("new")')
1421
+
1422
+ // Both old and new content should be visible
1423
+ expect(frame).toContain("return 1")
1424
+ expect(frame).toContain('console.log("old")')
1425
+ })
1426
+
1427
+ test("DiffRenderable - no newline at end of file in unified view", async () => {
1428
+ const syntaxStyle = SyntaxStyle.fromStyles({
1429
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1430
+ })
1431
+
1432
+ const noNewlineDiff = `--- a/test.js
1433
+ +++ b/test.js
1434
+ @@ -1,3 +1,3 @@
1435
+ line1
1436
+ line2
1437
+ -line3
1438
+ \
1439
+ +line3_modified
1440
+ \`
1441
+
1442
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1443
+ id: "test-diff",
1444
+ diff: noNewlineDiff,
1445
+ view: "unified",
1446
+ syntaxStyle,
1447
+ showLineNumbers: true,
1448
+ width: "100%",
1449
+ height: "100%",
1450
+ })
1451
+
1452
+ currentRenderer.root.add(diffRenderable)
1453
+ await renderOnce()
1454
+
1455
+ const frame = captureFrame()
1456
+ expect(frame).toMatchSnapshot("unified view with no newline marker")
1457
+
1458
+ // Should show both old and new versions
1459
+ expect(frame).toContain("line3")
1460
+ expect(frame).toContain("line3_modified")
1461
+
1462
+ // Should NOT show the "No newline" marker as content
1463
+ // (it's a special marker that should be skipped)
1464
+ const frameLines = frame.split("\n")
1465
+ const markerLines = frameLines.filter((l) => l.includes("No newline at end of file"))
1466
+ expect(markerLines.length).toBe(0)
1467
+ })
1468
+
1469
+ test("DiffRenderable - no newline at end of file in split view", async () => {
1470
+ const syntaxStyle = SyntaxStyle.fromStyles({
1471
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1472
+ })
1473
+
1474
+ const noNewlineDiff = `--- a/test.js
1475
+ +++ b/test.js
1476
+ @@ -1,3 +1,3 @@
1477
+ line1
1478
+ line2
1479
+ -line3
1480
+ \
1481
+ +line3_modified
1482
+ \`
1483
+
1484
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1485
+ id: "test-diff",
1486
+ diff: noNewlineDiff,
1487
+ view: "split",
1488
+ syntaxStyle,
1489
+ showLineNumbers: true,
1490
+ width: "100%",
1491
+ height: "100%",
1492
+ })
1493
+
1494
+ currentRenderer.root.add(diffRenderable)
1495
+ await renderOnce()
1496
+
1497
+ const frame = captureFrame()
1498
+ expect(frame).toMatchSnapshot("split view with no newline marker")
1499
+
1500
+ // Both sides should show their respective versions
1501
+ expect(frame).toContain("line3")
1502
+ expect(frame).toContain("line3_modified")
1503
+
1504
+ // Should NOT show the "No newline" marker
1505
+ const frameLines = frame.split("\n")
1506
+ const markerLines = frameLines.filter((l) => l.includes("No newline at end of file"))
1507
+ expect(markerLines.length).toBe(0)
1508
+ })
1509
+
1510
+ test("DiffRenderable - asymmetric block with more removes than adds in split view", async () => {
1511
+ const syntaxStyle = SyntaxStyle.fromStyles({
1512
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1513
+ })
1514
+
1515
+ const asymmetricDiff = `--- a/test.js
1516
+ +++ b/test.js
1517
+ @@ -1,7 +1,4 @@
1518
+ context_before
1519
+ -remove1
1520
+ -remove2
1521
+ -remove3
1522
+ -remove4
1523
+ -remove5
1524
+ +add1
1525
+ +add2
1526
+ context_after`
1527
+
1528
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1529
+ id: "test-diff",
1530
+ diff: asymmetricDiff,
1531
+ view: "split",
1532
+ syntaxStyle,
1533
+ showLineNumbers: true,
1534
+ width: "100%",
1535
+ height: "100%",
1536
+ })
1537
+
1538
+ currentRenderer.root.add(diffRenderable)
1539
+ await renderOnce()
1540
+
1541
+ const frame = captureFrame()
1542
+ expect(frame).toMatchSnapshot("split view asymmetric block more removes")
1543
+
1544
+ // Left side should have all 5 removes
1545
+ expect(frame).toContain("remove1")
1546
+ expect(frame).toContain("remove2")
1547
+ expect(frame).toContain("remove3")
1548
+ expect(frame).toContain("remove4")
1549
+ expect(frame).toContain("remove5")
1550
+
1551
+ // Right side should have 2 adds
1552
+ expect(frame).toContain("add1")
1553
+ expect(frame).toContain("add2")
1554
+
1555
+ // Context lines should appear on both sides at the same visual position
1556
+ const frameLines = frame.split("\n")
1557
+ const contextBeforeLines = frameLines.filter((l) => l.includes("context_before"))
1558
+ const contextAfterLines = frameLines.filter((l) => l.includes("context_after"))
1559
+
1560
+ // context_before should appear once (on same visual line for both sides)
1561
+ expect(contextBeforeLines.length).toBeGreaterThanOrEqual(1)
1562
+
1563
+ // context_after should appear once (on same visual line for both sides)
1564
+ expect(contextAfterLines.length).toBeGreaterThanOrEqual(1)
1565
+
1566
+ // The right side should have empty padding lines to align with left side's extra removes
1567
+ // We can verify this by checking that context_after appears at similar vertical positions
1568
+ })
1569
+
1570
+ test("DiffRenderable - asymmetric block with more adds than removes in split view", async () => {
1571
+ const syntaxStyle = SyntaxStyle.fromStyles({
1572
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1573
+ })
1574
+
1575
+ const asymmetricDiff = `--- a/test.js
1576
+ +++ b/test.js
1577
+ @@ -1,4 +1,7 @@
1578
+ context_before
1579
+ -remove1
1580
+ -remove2
1581
+ +add1
1582
+ +add2
1583
+ +add3
1584
+ +add4
1585
+ +add5
1586
+ context_after`
1587
+
1588
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1589
+ id: "test-diff",
1590
+ diff: asymmetricDiff,
1591
+ view: "split",
1592
+ syntaxStyle,
1593
+ showLineNumbers: true,
1594
+ width: "100%",
1595
+ height: "100%",
1596
+ })
1597
+
1598
+ currentRenderer.root.add(diffRenderable)
1599
+ await renderOnce()
1600
+
1601
+ const frame = captureFrame()
1602
+ expect(frame).toMatchSnapshot("split view asymmetric block more adds")
1603
+
1604
+ // Left side should have 2 removes
1605
+ expect(frame).toContain("remove1")
1606
+ expect(frame).toContain("remove2")
1607
+
1608
+ // Right side should have all 5 adds
1609
+ expect(frame).toContain("add1")
1610
+ expect(frame).toContain("add2")
1611
+ expect(frame).toContain("add3")
1612
+ expect(frame).toContain("add4")
1613
+ expect(frame).toContain("add5")
1614
+
1615
+ // Context lines should be aligned
1616
+ const frameLines = frame.split("\n")
1617
+ const contextBeforeLines = frameLines.filter((l) => l.includes("context_before"))
1618
+ const contextAfterLines = frameLines.filter((l) => l.includes("context_after"))
1619
+
1620
+ expect(contextBeforeLines.length).toBeGreaterThanOrEqual(1)
1621
+ expect(contextAfterLines.length).toBeGreaterThanOrEqual(1)
1622
+ })
1623
+
1624
+ test("DiffRenderable - back-to-back change blocks without context lines in split view", async () => {
1625
+ const syntaxStyle = SyntaxStyle.fromStyles({
1626
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1627
+ })
1628
+
1629
+ const backToBackDiff = `--- a/test.js
1630
+ +++ b/test.js
1631
+ @@ -1,4 +1,4 @@
1632
+ -remove1
1633
+ -remove2
1634
+ -remove3
1635
+ -remove4
1636
+ +add1
1637
+ +add2
1638
+ +add3
1639
+ +add4`
1640
+
1641
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1642
+ id: "test-diff",
1643
+ diff: backToBackDiff,
1644
+ view: "split",
1645
+ syntaxStyle,
1646
+ showLineNumbers: true,
1647
+ width: "100%",
1648
+ height: "100%",
1649
+ })
1650
+
1651
+ currentRenderer.root.add(diffRenderable)
1652
+ await renderOnce()
1653
+
1654
+ const frame = captureFrame()
1655
+ expect(frame).toMatchSnapshot("split view back-to-back blocks")
1656
+
1657
+ // All removes should be on left
1658
+ expect(frame).toContain("remove1")
1659
+ expect(frame).toContain("remove2")
1660
+ expect(frame).toContain("remove3")
1661
+ expect(frame).toContain("remove4")
1662
+
1663
+ // All adds should be on right
1664
+ expect(frame).toContain("add1")
1665
+ expect(frame).toContain("add2")
1666
+ expect(frame).toContain("add3")
1667
+ expect(frame).toContain("add4")
1668
+
1669
+ // Both sides should have same number of visual lines (with alignment)
1670
+ const frameLines = frame.split("\n").filter((l) => l.trim().length > 0)
1671
+ expect(frameLines.length).toBeGreaterThan(0)
1672
+ })
1673
+
1674
+ test("DiffRenderable - very long lines wrapping multiple times in split view", async () => {
1675
+ const syntaxStyle = SyntaxStyle.fromStyles({
1676
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1677
+ })
1678
+
1679
+ const longLineDiff = `--- a/test.js
1680
+ +++ b/test.js
1681
+ @@ -1,3 +1,3 @@
1682
+ short line
1683
+ -This is an extremely long line that will definitely wrap multiple times when rendered in a split view with word wrapping enabled because it contains so many words and characters
1684
+ +This is an extremely long line that has been modified and will definitely wrap multiple times when rendered in a split view with word wrapping enabled because it contains so many words and characters and even more content
1685
+ another short line`
1686
+
1687
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1688
+ id: "test-diff",
1689
+ diff: longLineDiff,
1690
+ view: "split",
1691
+ syntaxStyle,
1692
+ showLineNumbers: true,
1693
+ wrapMode: "word",
1694
+ width: "100%",
1695
+ height: "100%",
1696
+ })
1697
+
1698
+ currentRenderer.root.add(diffRenderable)
1699
+ await renderOnce()
1700
+
1701
+ // Flush microtask-based wrap alignment
1702
+ await Promise.resolve()
1703
+ await renderOnce()
1704
+
1705
+ const frame = captureFrame()
1706
+ expect(frame).toMatchSnapshot("split view multi-wrap lines")
1707
+
1708
+ // Both versions of the long line should be present
1709
+ expect(frame).toContain("extremely long line")
1710
+ expect(frame).toContain("has been modified")
1711
+
1712
+ // Short lines should still be aligned
1713
+ expect(frame).toContain("short line")
1714
+ expect(frame).toContain("another short line")
1715
+
1716
+ const frameLines = frame.split("\n")
1717
+
1718
+ // Find the "another short line" on both sides
1719
+ const shortLineMatches = frameLines.filter((l) => l.includes("another short line"))
1720
+
1721
+ // Should appear (on the same visual line in split view)
1722
+ expect(shortLineMatches.length).toBeGreaterThanOrEqual(1)
1723
+ })
1724
+
1725
+ test("DiffRenderable - rapid diff updates trigger microtask coalescing", async () => {
1726
+ const syntaxStyle = SyntaxStyle.fromStyles({
1727
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1728
+ })
1729
+
1730
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1731
+ id: "test-diff",
1732
+ diff: simpleDiff,
1733
+ view: "split",
1734
+ syntaxStyle,
1735
+ showLineNumbers: true,
1736
+ wrapMode: "word",
1737
+ width: "100%",
1738
+ height: "100%",
1739
+ })
1740
+
1741
+ currentRenderer.root.add(diffRenderable)
1742
+ await renderOnce()
1743
+
1744
+ // Rapidly update the diff multiple times
1745
+ diffRenderable.diff = multiLineDiff
1746
+ diffRenderable.diff = addOnlyDiff
1747
+ diffRenderable.diff = removeOnlyDiff
1748
+ diffRenderable.diff = simpleDiff
1749
+
1750
+ // Flush microtask-based coalesced rebuild
1751
+ await Promise.resolve()
1752
+ await renderOnce()
1753
+
1754
+ const frame = captureFrame()
1755
+
1756
+ // Should show the final diff (simpleDiff)
1757
+ expect(frame).toContain("function hello")
1758
+ expect(frame).toContain('console.log("Hello")')
1759
+ expect(frame).toContain('console.log("Hello, World!")')
1760
+
1761
+ // Should NOT show content from intermediate diffs
1762
+ expect(frame).not.toContain("subtract")
1763
+ expect(frame).not.toContain("newFunction")
1764
+ expect(frame).not.toContain("oldFunction")
1765
+ })
1766
+
1767
+ test("DiffRenderable - explicit content background colors differ from gutter", async () => {
1768
+ const syntaxStyle = SyntaxStyle.fromStyles({
1769
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1770
+ })
1771
+
1772
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1773
+ id: "test-diff",
1774
+ diff: simpleDiff,
1775
+ view: "unified",
1776
+ syntaxStyle,
1777
+ showLineNumbers: true,
1778
+ addedBg: "#1a4d1a",
1779
+ removedBg: "#4d1a1a",
1780
+ addedContentBg: "#2a5d2a",
1781
+ removedContentBg: "#5d2a2a",
1782
+ width: "100%",
1783
+ height: "100%",
1784
+ })
1785
+
1786
+ currentRenderer.root.add(diffRenderable)
1787
+ await renderOnce()
1788
+
1789
+ const frame = captureFrame()
1790
+
1791
+ // Verify content is rendered
1792
+ expect(frame).toContain("function hello")
1793
+ expect(frame).toContain('console.log("Hello")')
1794
+ expect(frame).toContain('console.log("Hello, World!")')
1795
+
1796
+ // Verify properties are set correctly
1797
+ expect(diffRenderable.addedBg).toEqual(RGBA.fromHex("#1a4d1a"))
1798
+ expect(diffRenderable.removedBg).toEqual(RGBA.fromHex("#4d1a1a"))
1799
+ expect(diffRenderable.addedContentBg).toEqual(RGBA.fromHex("#2a5d2a"))
1800
+ expect(diffRenderable.removedContentBg).toEqual(RGBA.fromHex("#5d2a2a"))
1801
+
1802
+ // Test that we can update them
1803
+ diffRenderable.addedContentBg = "#3a6d3a"
1804
+ expect(diffRenderable.addedContentBg).toEqual(RGBA.fromHex("#3a6d3a"))
1805
+
1806
+ await renderOnce()
1807
+ const frame2 = captureFrame()
1808
+
1809
+ // Should still render correctly after update
1810
+ expect(frame2).toContain("function hello")
1811
+ })
1812
+
1813
+ test("DiffRenderable - malformed diff string handled gracefully", async () => {
1814
+ const syntaxStyle = SyntaxStyle.fromStyles({
1815
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1816
+ })
1817
+
1818
+ const malformedDiff = `This is not a valid diff format
1819
+ Just some random text
1820
+ Without proper headers`
1821
+
1822
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1823
+ id: "test-diff",
1824
+ diff: malformedDiff,
1825
+ view: "unified",
1826
+ syntaxStyle,
1827
+ width: "100%",
1828
+ height: "100%",
1829
+ })
1830
+
1831
+ currentRenderer.root.add(diffRenderable)
1832
+
1833
+ // Should not crash when rendering malformed diff
1834
+ await renderOnce()
1835
+
1836
+ const frame = captureFrame()
1837
+
1838
+ // Should render empty/blank since diff can't be parsed
1839
+ // The important thing is it doesn't crash
1840
+ expect(diffRenderable.diff).toBe(malformedDiff)
1841
+ })
1842
+
1843
+ test("DiffRenderable - invalid diff format shows error with raw diff", async () => {
1844
+ const syntaxStyle = SyntaxStyle.fromStyles({
1845
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1846
+ })
1847
+
1848
+ // This diff has a malformed hunk header that will cause parsePatch to throw
1849
+ // The hunk header must have the format @@ -oldStart,oldLines +newStart,newLines @@
1850
+ const invalidDiff = `--- a/test.js
1851
+ +++ b/test.js
1852
+ @@ -a,b +c,d @@
1853
+ function hello() {
1854
+ - console.log("Hello");
1855
+ + console.log("Hello, World!");
1856
+ }`
1857
+
1858
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1859
+ id: "test-diff",
1860
+ diff: invalidDiff,
1861
+ view: "unified",
1862
+ syntaxStyle,
1863
+ width: "100%",
1864
+ height: "100%",
1865
+ })
1866
+
1867
+ currentRenderer.root.add(diffRenderable)
1868
+
1869
+ // Should not crash when rendering invalid diff
1870
+ await renderOnce()
1871
+
1872
+ const frame = captureFrame()
1873
+ expect(frame).toMatchSnapshot("invalid diff format with error")
1874
+
1875
+ // Should contain error message (the error from parsePatch)
1876
+ expect(frame).toContain("Unknown line")
1877
+
1878
+ // Should show the raw diff content
1879
+ expect(frame).toContain("@@ -a,b +c,d @@")
1880
+ expect(frame).toContain("function hello")
1881
+ })
1882
+
1883
+ test("DiffRenderable - diff with only context lines (no changes)", async () => {
1884
+ const syntaxStyle = SyntaxStyle.fromStyles({
1885
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1886
+ })
1887
+
1888
+ const contextOnlyDiff = `--- a/test.js
1889
+ +++ b/test.js
1890
+ @@ -1,5 +1,5 @@
1891
+ line1
1892
+ line2
1893
+ line3
1894
+ line4
1895
+ line5`
1896
+
1897
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1898
+ id: "test-diff",
1899
+ diff: contextOnlyDiff,
1900
+ view: "unified",
1901
+ syntaxStyle,
1902
+ showLineNumbers: true,
1903
+ width: "100%",
1904
+ height: "100%",
1905
+ })
1906
+
1907
+ currentRenderer.root.add(diffRenderable)
1908
+ await renderOnce()
1909
+
1910
+ const frame = captureFrame()
1911
+ expect(frame).toMatchSnapshot("diff with only context lines")
1912
+
1913
+ // All lines should be present as context
1914
+ expect(frame).toContain("line1")
1915
+ expect(frame).toContain("line2")
1916
+ expect(frame).toContain("line3")
1917
+ expect(frame).toContain("line4")
1918
+ expect(frame).toContain("line5")
1919
+
1920
+ // No +/- signs should be present (only context)
1921
+ const frameLines = frame.split("\n")
1922
+ const changedLines = frameLines.filter((l) => l.match(/[+-]\s*line/))
1923
+ expect(changedLines.length).toBe(0)
1924
+ })
1925
+
1926
+ test("DiffRenderable - should not leak listeners on unified view updates", async () => {
1927
+ const syntaxStyle = SyntaxStyle.fromStyles({
1928
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1929
+ })
1930
+
1931
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1932
+ id: "test-diff",
1933
+ diff: simpleDiff,
1934
+ view: "unified",
1935
+ syntaxStyle,
1936
+ width: "100%",
1937
+ height: "100%",
1938
+ })
1939
+
1940
+ currentRenderer.root.add(diffRenderable)
1941
+ await renderOnce()
1942
+
1943
+ // Get the underlying CodeRenderable (leftCodeRenderable in unified view)
1944
+ const codeRenderable = (diffRenderable as any).leftCodeRenderable
1945
+ expect(codeRenderable).toBeDefined()
1946
+
1947
+ // Check initial listener count
1948
+ const initialListenerCount = codeRenderable.listenerCount("line-info-change")
1949
+ expect(initialListenerCount).toBeGreaterThanOrEqual(1)
1950
+
1951
+ // Update the diff multiple times - this should not add more listeners
1952
+ for (let i = 0; i < 10; i++) {
1953
+ diffRenderable.diff = simpleDiff.replace('"Hello"', `"Hello${i}"`)
1954
+ await renderOnce()
1955
+ }
1956
+
1957
+ // Check that listener count hasn't grown
1958
+ const finalListenerCount = codeRenderable.listenerCount("line-info-change")
1959
+ expect(finalListenerCount).toBe(initialListenerCount)
1960
+ })
1961
+
1962
+ test("DiffRenderable - should not leak listeners on split view updates", async () => {
1963
+ const syntaxStyle = SyntaxStyle.fromStyles({
1964
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
1965
+ })
1966
+
1967
+ const diffRenderable = new DiffRenderable(currentRenderer, {
1968
+ id: "test-diff",
1969
+ diff: simpleDiff,
1970
+ view: "split",
1971
+ syntaxStyle,
1972
+ width: "100%",
1973
+ height: "100%",
1974
+ })
1975
+
1976
+ currentRenderer.root.add(diffRenderable)
1977
+ await renderOnce()
1978
+
1979
+ // Get the underlying CodeRenderables
1980
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
1981
+ const rightCodeRenderable = (diffRenderable as any).rightCodeRenderable
1982
+ expect(leftCodeRenderable).toBeDefined()
1983
+ expect(rightCodeRenderable).toBeDefined()
1984
+
1985
+ // Check initial listener counts
1986
+ const leftInitialCount = leftCodeRenderable.listenerCount("line-info-change")
1987
+ const rightInitialCount = rightCodeRenderable.listenerCount("line-info-change")
1988
+ expect(leftInitialCount).toBeGreaterThanOrEqual(1)
1989
+ expect(rightInitialCount).toBeGreaterThanOrEqual(1)
1990
+
1991
+ // Update the diff multiple times - this should not add more listeners
1992
+ for (let i = 0; i < 10; i++) {
1993
+ diffRenderable.diff = simpleDiff.replace('"Hello"', `"Hello${i}"`)
1994
+ await renderOnce()
1995
+ }
1996
+
1997
+ // Check that listener counts haven't grown
1998
+ const leftFinalCount = leftCodeRenderable.listenerCount("line-info-change")
1999
+ const rightFinalCount = rightCodeRenderable.listenerCount("line-info-change")
2000
+ expect(leftFinalCount).toBe(leftInitialCount)
2001
+ expect(rightFinalCount).toBe(rightInitialCount)
2002
+ })
2003
+
2004
+ test("DiffRenderable - should not leak listeners when switching views", async () => {
2005
+ const syntaxStyle = SyntaxStyle.fromStyles({
2006
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2007
+ })
2008
+
2009
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2010
+ id: "test-diff",
2011
+ diff: simpleDiff,
2012
+ view: "unified",
2013
+ syntaxStyle,
2014
+ width: "100%",
2015
+ height: "100%",
2016
+ })
2017
+
2018
+ currentRenderer.root.add(diffRenderable)
2019
+ await renderOnce()
2020
+
2021
+ // Get initial renderables
2022
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2023
+ expect(leftCodeRenderable).toBeDefined()
2024
+ const initialLeftCount = leftCodeRenderable.listenerCount("line-info-change")
2025
+
2026
+ // Switch to split view and back multiple times
2027
+ for (let i = 0; i < 5; i++) {
2028
+ diffRenderable.view = "split"
2029
+ await renderOnce()
2030
+
2031
+ diffRenderable.view = "unified"
2032
+ await renderOnce()
2033
+ }
2034
+
2035
+ const finalLeftCount = leftCodeRenderable.listenerCount("line-info-change")
2036
+
2037
+ // Listener count should remain stable (allow some flexibility for implementation details)
2038
+ expect(finalLeftCount).toBeLessThanOrEqual(initialLeftCount + 2)
2039
+ })
2040
+
2041
+ test("DiffRenderable - should not leak listeners on rapid property changes", async () => {
2042
+ const syntaxStyle = SyntaxStyle.fromStyles({
2043
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2044
+ })
2045
+
2046
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2047
+ id: "test-diff",
2048
+ diff: simpleDiff,
2049
+ view: "split",
2050
+ syntaxStyle,
2051
+ width: "100%",
2052
+ height: "100%",
2053
+ })
2054
+
2055
+ currentRenderer.root.add(diffRenderable)
2056
+ await renderOnce()
2057
+
2058
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2059
+ const rightCodeRenderable = (diffRenderable as any).rightCodeRenderable
2060
+ const leftInitialCount = leftCodeRenderable.listenerCount("line-info-change")
2061
+ const rightInitialCount = rightCodeRenderable.listenerCount("line-info-change")
2062
+
2063
+ // Make rapid changes that trigger rebuilds
2064
+ for (let i = 0; i < 10; i++) {
2065
+ diffRenderable.wrapMode = i % 2 === 0 ? "word" : "char"
2066
+ diffRenderable.addedBg = i % 2 === 0 ? "#ff0000" : "#00ff00"
2067
+ diffRenderable.removedBg = i % 2 === 0 ? "#0000ff" : "#ffff00"
2068
+ await renderOnce()
2069
+ }
2070
+
2071
+ const leftFinalCount = leftCodeRenderable.listenerCount("line-info-change")
2072
+ const rightFinalCount = rightCodeRenderable.listenerCount("line-info-change")
2073
+
2074
+ // Listener counts should remain stable
2075
+ expect(leftFinalCount).toBe(leftInitialCount)
2076
+ expect(rightFinalCount).toBe(rightInitialCount)
2077
+ })
2078
+
2079
+ test("DiffRenderable - can toggle conceal with markdown diff", async () => {
2080
+ const syntaxStyle = SyntaxStyle.fromStyles({
2081
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2082
+ })
2083
+
2084
+ const mockClient = new MockTreeSitterClient()
2085
+
2086
+ const markdownDiff = `--- a/test.md
2087
+ +++ b/test.md
2088
+ @@ -1,3 +1,3 @@
2089
+ First line
2090
+ -Some text **old**
2091
+ +Some text **boldtext** and *italic*
2092
+ End line`
2093
+
2094
+ const mockHighlightsWithConceal: SimpleHighlight[] = [
2095
+ [21, 23, "conceal", { isInjection: true, injectionLang: "markdown_inline", conceal: "" }], // **
2096
+ [31, 33, "conceal", { isInjection: true, injectionLang: "markdown_inline", conceal: "" }], // **
2097
+ [38, 39, "conceal", { isInjection: true, injectionLang: "markdown_inline", conceal: "" }], // *
2098
+ [45, 46, "conceal", { isInjection: true, injectionLang: "markdown_inline", conceal: "" }], // *
2099
+ ]
2100
+
2101
+ mockClient.setMockResult({ highlights: mockHighlightsWithConceal })
2102
+
2103
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2104
+ id: "test-diff",
2105
+ diff: markdownDiff,
2106
+ view: "unified",
2107
+ syntaxStyle,
2108
+ filetype: "markdown",
2109
+ conceal: true,
2110
+ treeSitterClient: mockClient,
2111
+ width: "100%",
2112
+ height: "100%",
2113
+ })
2114
+
2115
+ currentRenderer.root.add(diffRenderable)
2116
+ await settleDiffHighlighting(diffRenderable, mockClient, renderOnce)
2117
+
2118
+ const frameWithConceal = captureFrame()
2119
+ expect(frameWithConceal).toMatchSnapshot("markdown diff with conceal enabled")
2120
+ expect(diffRenderable.conceal).toBe(true)
2121
+
2122
+ diffRenderable.conceal = false
2123
+ await settleDiffHighlighting(diffRenderable, mockClient, renderOnce)
2124
+
2125
+ const frameWithoutConceal = captureFrame()
2126
+ expect(frameWithoutConceal).toMatchSnapshot("markdown diff with conceal disabled")
2127
+ expect(diffRenderable.conceal).toBe(false)
2128
+
2129
+ expect(frameWithConceal).not.toBe(frameWithoutConceal)
2130
+
2131
+ diffRenderable.conceal = true
2132
+ await settleDiffHighlighting(diffRenderable, mockClient, renderOnce)
2133
+
2134
+ const frameWithConcealAgain = captureFrame()
2135
+ expect(frameWithConcealAgain).toBe(frameWithConceal)
2136
+ })
2137
+
2138
+ test("DiffRenderable - conceal works in split view", async () => {
2139
+ const syntaxStyle = SyntaxStyle.fromStyles({
2140
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2141
+ })
2142
+
2143
+ const mockClient = new MockTreeSitterClient()
2144
+
2145
+ const markdownDiff = `--- a/test.md
2146
+ +++ b/test.md
2147
+ @@ -1,3 +1,3 @@
2148
+ First line
2149
+ -Some **old** text
2150
+ +Some **new** text
2151
+ End line`
2152
+
2153
+ const mockHighlightsWithConceal: SimpleHighlight[] = [
2154
+ [16, 18, "conceal", { isInjection: true, injectionLang: "markdown_inline", conceal: "" }], // **
2155
+ [21, 23, "conceal", { isInjection: true, injectionLang: "markdown_inline", conceal: "" }], // **
2156
+ ]
2157
+
2158
+ mockClient.setMockResult({ highlights: mockHighlightsWithConceal })
2159
+
2160
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2161
+ id: "test-diff",
2162
+ diff: markdownDiff,
2163
+ view: "split",
2164
+ syntaxStyle,
2165
+ filetype: "markdown",
2166
+ conceal: true,
2167
+ treeSitterClient: mockClient,
2168
+ width: "100%",
2169
+ height: "100%",
2170
+ })
2171
+
2172
+ currentRenderer.root.add(diffRenderable)
2173
+ await settleDiffHighlighting(diffRenderable, mockClient, renderOnce)
2174
+
2175
+ const frameWithConceal = captureFrame()
2176
+ expect(frameWithConceal).toMatchSnapshot("split view markdown diff with conceal enabled")
2177
+ expect(diffRenderable.conceal).toBe(true)
2178
+
2179
+ diffRenderable.conceal = false
2180
+ await settleDiffHighlighting(diffRenderable, mockClient, renderOnce)
2181
+
2182
+ const frameWithoutConceal = captureFrame()
2183
+ expect(frameWithoutConceal).toMatchSnapshot("split view markdown diff with conceal disabled")
2184
+ expect(diffRenderable.conceal).toBe(false)
2185
+
2186
+ expect(frameWithConceal).not.toBe(frameWithoutConceal)
2187
+ })
2188
+
2189
+ test("DiffRenderable - conceal defaults to false when not specified", async () => {
2190
+ const syntaxStyle = SyntaxStyle.fromStyles({
2191
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2192
+ })
2193
+
2194
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2195
+ id: "test-diff",
2196
+ diff: simpleDiff,
2197
+ view: "unified",
2198
+ syntaxStyle,
2199
+ filetype: "javascript",
2200
+ width: "100%",
2201
+ height: "100%",
2202
+ })
2203
+
2204
+ currentRenderer.root.add(diffRenderable)
2205
+ await renderOnce()
2206
+
2207
+ expect(diffRenderable.conceal).toBe(false)
2208
+ })
2209
+
2210
+ test("DiffRenderable - should handle resize with wrapping without leaking listeners", async () => {
2211
+ const syntaxStyle = SyntaxStyle.fromStyles({
2212
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2213
+ })
2214
+
2215
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2216
+ id: "test-diff",
2217
+ diff: simpleDiff,
2218
+ view: "split",
2219
+ syntaxStyle,
2220
+ wrapMode: "word",
2221
+ width: 100,
2222
+ height: "100%",
2223
+ })
2224
+
2225
+ currentRenderer.root.add(diffRenderable)
2226
+ await renderOnce()
2227
+
2228
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2229
+ const rightCodeRenderable = (diffRenderable as any).rightCodeRenderable
2230
+ const leftInitialCount = leftCodeRenderable.listenerCount("line-info-change")
2231
+ const rightInitialCount = rightCodeRenderable.listenerCount("line-info-change")
2232
+
2233
+ // Simulate multiple resizes (which trigger rebuilds in split view with wrapping)
2234
+ for (let i = 0; i < 10; i++) {
2235
+ diffRenderable.width = 50 + i * 5
2236
+ await renderOnce()
2237
+ // Flush microtask rebuild
2238
+ await Promise.resolve()
2239
+ await renderOnce()
2240
+ }
2241
+
2242
+ const leftFinalCount = leftCodeRenderable.listenerCount("line-info-change")
2243
+ const rightFinalCount = rightCodeRenderable.listenerCount("line-info-change")
2244
+
2245
+ expect(leftFinalCount).toBe(leftInitialCount)
2246
+ expect(rightFinalCount).toBe(rightInitialCount)
2247
+ })
2248
+
2249
+ test("DiffRenderable - gutter configuration updates work correctly", async () => {
2250
+ const syntaxStyle = SyntaxStyle.fromStyles({
2251
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2252
+ })
2253
+
2254
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2255
+ id: "test-diff",
2256
+ diff: simpleDiff,
2257
+ view: "unified",
2258
+ syntaxStyle,
2259
+ showLineNumbers: true,
2260
+ width: "100%",
2261
+ height: "100%",
2262
+ })
2263
+
2264
+ currentRenderer.root.add(diffRenderable)
2265
+ await renderOnce()
2266
+
2267
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2268
+ const leftSide = (diffRenderable as any).leftSide
2269
+
2270
+ // Verify initial state
2271
+ expect(leftSide).toBeDefined()
2272
+ expect(leftCodeRenderable).toBeDefined()
2273
+ const initialListenerCount = leftCodeRenderable.listenerCount("line-info-change")
2274
+
2275
+ // Get initial frame to verify line numbers are showing
2276
+ let frame = captureFrame()
2277
+ expect(frame).toContain("function hello")
2278
+
2279
+ // Update multiple gutter configurations that trigger recreateGutter()
2280
+ // Each of these calls setLineNumbers/setHideLineNumbers internally
2281
+ for (let i = 0; i < 5; i++) {
2282
+ diffRenderable.diff = simpleDiff.replace('"Hello"', `"Hello${i}"`)
2283
+ await renderOnce()
2284
+ }
2285
+
2286
+ // Verify listener count is stable
2287
+ const finalListenerCount = leftCodeRenderable.listenerCount("line-info-change")
2288
+ expect(finalListenerCount).toBe(initialListenerCount)
2289
+
2290
+ // Verify rendering still works
2291
+ frame = captureFrame()
2292
+ expect(frame).toContain("function hello")
2293
+ expect(frame).toContain("Hello4") // Last update should be visible
2294
+ })
2295
+
2296
+ test("DiffRenderable - target remains functional after multiple updates", async () => {
2297
+ const syntaxStyle = SyntaxStyle.fromStyles({
2298
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2299
+ })
2300
+
2301
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2302
+ id: "test-diff",
2303
+ diff: multiLineDiff,
2304
+ view: "split",
2305
+ syntaxStyle,
2306
+ showLineNumbers: true,
2307
+ width: "100%",
2308
+ height: "100%",
2309
+ })
2310
+
2311
+ currentRenderer.root.add(diffRenderable)
2312
+ await renderOnce()
2313
+
2314
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2315
+ const rightCodeRenderable = (diffRenderable as any).rightCodeRenderable
2316
+
2317
+ // Verify targets are responding to line-info-change events
2318
+ let leftEventFired = false
2319
+ let rightEventFired = false
2320
+
2321
+ const leftListener = () => {
2322
+ leftEventFired = true
2323
+ }
2324
+ const rightListener = () => {
2325
+ rightEventFired = true
2326
+ }
2327
+
2328
+ leftCodeRenderable.on("line-info-change", leftListener)
2329
+ rightCodeRenderable.on("line-info-change", rightListener)
2330
+
2331
+ // Update diff multiple times
2332
+ for (let i = 0; i < 5; i++) {
2333
+ leftEventFired = false
2334
+ rightEventFired = false
2335
+
2336
+ diffRenderable.diff = multiLineDiff.replace("add(a, b)", `add(a, b, ${i})`)
2337
+ await renderOnce()
2338
+
2339
+ // Events should have fired during the update
2340
+ expect(leftEventFired).toBe(true)
2341
+ expect(rightEventFired).toBe(true)
2342
+ }
2343
+
2344
+ leftCodeRenderable.off("line-info-change", leftListener)
2345
+ rightCodeRenderable.off("line-info-change", rightListener)
2346
+ })
2347
+
2348
+ test("DiffRenderable - split view scroll is not synchronized by default", async () => {
2349
+ const mockMouse = createMockMouse(currentRenderer)
2350
+ const syntaxStyle = SyntaxStyle.fromStyles({
2351
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2352
+ })
2353
+
2354
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2355
+ id: "test-diff",
2356
+ diff: multiLineDiff,
2357
+ view: "split",
2358
+ syntaxStyle,
2359
+ showLineNumbers: true,
2360
+ width: "100%",
2361
+ height: 4,
2362
+ })
2363
+
2364
+ currentRenderer.root.add(diffRenderable)
2365
+ await renderOnce()
2366
+
2367
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2368
+ const rightCodeRenderable = (diffRenderable as any).rightCodeRenderable
2369
+
2370
+ expect(leftCodeRenderable).toBeTruthy()
2371
+ expect(rightCodeRenderable).toBeTruthy()
2372
+
2373
+ // Scroll over left pane
2374
+ mockMouse.scroll(leftCodeRenderable.x, leftCodeRenderable.y + 1, "down")
2375
+ await renderOnce()
2376
+
2377
+ expect(leftCodeRenderable.scrollY).toBe(1)
2378
+ expect(rightCodeRenderable.scrollY).toBe(0)
2379
+
2380
+ // Scroll over right pane
2381
+ mockMouse.scroll(rightCodeRenderable.x + 1, rightCodeRenderable.y + 1, "down")
2382
+ await renderOnce()
2383
+
2384
+ expect(rightCodeRenderable.scrollY).toBe(1)
2385
+ expect(leftCodeRenderable.scrollY).toBe(1)
2386
+ })
2387
+
2388
+ test("DiffRenderable - split view wheel scroll keeps panes synchronized", async () => {
2389
+ const mockMouse = createMockMouse(currentRenderer)
2390
+ const syntaxStyle = SyntaxStyle.fromStyles({
2391
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2392
+ })
2393
+
2394
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2395
+ id: "test-diff",
2396
+ diff: multiLineDiff,
2397
+ syncScroll: true,
2398
+ view: "split",
2399
+ syntaxStyle,
2400
+ showLineNumbers: true,
2401
+ width: "100%",
2402
+ height: 4,
2403
+ })
2404
+
2405
+ currentRenderer.root.add(diffRenderable)
2406
+ await renderOnce()
2407
+
2408
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2409
+ const rightCodeRenderable = (diffRenderable as any).rightCodeRenderable
2410
+
2411
+ expect(leftCodeRenderable).toBeTruthy()
2412
+ expect(rightCodeRenderable).toBeTruthy()
2413
+
2414
+ // Scroll over left pane
2415
+ await mockMouse.scroll(leftCodeRenderable.x + 1, leftCodeRenderable.y + 1, "down")
2416
+ await renderOnce()
2417
+
2418
+ expect(leftCodeRenderable.scrollY).toBeGreaterThan(0)
2419
+ expect(leftCodeRenderable.scrollY).toBe(rightCodeRenderable.scrollY)
2420
+
2421
+ // Scroll over right pane
2422
+ await mockMouse.scroll(rightCodeRenderable.x + 1, rightCodeRenderable.y + 1, "down")
2423
+ await renderOnce()
2424
+
2425
+ expect(rightCodeRenderable.scrollY).toBeGreaterThan(0)
2426
+ expect(leftCodeRenderable.scrollY).toBe(rightCodeRenderable.scrollY)
2427
+ })
2428
+
2429
+ test("DiffRenderable - gutter remains in correct position after updates", async () => {
2430
+ const syntaxStyle = SyntaxStyle.fromStyles({
2431
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2432
+ })
2433
+
2434
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2435
+ id: "test-diff",
2436
+ diff: simpleDiff,
2437
+ view: "unified",
2438
+ syntaxStyle,
2439
+ showLineNumbers: true,
2440
+ width: "100%",
2441
+ height: "100%",
2442
+ })
2443
+
2444
+ currentRenderer.root.add(diffRenderable)
2445
+ await renderOnce()
2446
+
2447
+ // Initial frame should have line numbers on the left
2448
+ let frame = captureFrame()
2449
+ const lines = frame.split("\n")
2450
+
2451
+ // Find a line with content
2452
+ const contentLine = lines.find((l) => l.includes("function hello"))
2453
+ expect(contentLine).toBeDefined()
2454
+
2455
+ // Line number should be at the start (before the content)
2456
+ expect(contentLine).toMatch(/^\s*\d+/)
2457
+
2458
+ // Update diff multiple times
2459
+ for (let i = 0; i < 5; i++) {
2460
+ diffRenderable.diff = simpleDiff.replace('"Hello"', `"Hello${i}"`)
2461
+ await renderOnce()
2462
+
2463
+ frame = captureFrame()
2464
+ const updatedLines = frame.split("\n")
2465
+ const updatedContentLine = updatedLines.find((l) => l.includes("function hello"))
2466
+
2467
+ // Line numbers should still be at the start
2468
+ expect(updatedContentLine).toBeDefined()
2469
+ expect(updatedContentLine).toMatch(/^\s*\d+/)
2470
+ }
2471
+ })
2472
+
2473
+ test("DiffRenderable - properly cleans up listeners on destroy", async () => {
2474
+ const syntaxStyle = SyntaxStyle.fromStyles({
2475
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2476
+ })
2477
+
2478
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2479
+ id: "test-diff",
2480
+ diff: simpleDiff,
2481
+ view: "split",
2482
+ syntaxStyle,
2483
+ width: "100%",
2484
+ height: "100%",
2485
+ })
2486
+
2487
+ currentRenderer.root.add(diffRenderable)
2488
+ await renderOnce()
2489
+
2490
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2491
+ const rightCodeRenderable = (diffRenderable as any).rightCodeRenderable
2492
+
2493
+ // Update multiple times to potentially create leaks
2494
+ for (let i = 0; i < 5; i++) {
2495
+ diffRenderable.diff = simpleDiff.replace('"Hello"', `"Hello${i}"`)
2496
+ await renderOnce()
2497
+ }
2498
+
2499
+ const leftCountBeforeDestroy = leftCodeRenderable.listenerCount("line-info-change")
2500
+ const rightCountBeforeDestroy = rightCodeRenderable.listenerCount("line-info-change")
2501
+
2502
+ // Verify listeners exist
2503
+ expect(leftCountBeforeDestroy).toBeGreaterThan(0)
2504
+ expect(rightCountBeforeDestroy).toBeGreaterThan(0)
2505
+
2506
+ // Destroy the diff
2507
+ diffRenderable.destroyRecursively()
2508
+
2509
+ // The LineNumberRenderables should have been destroyed
2510
+ // Check that they're either null or destroyed
2511
+ const leftSide = (diffRenderable as any).leftSide
2512
+ const rightSide = (diffRenderable as any).rightSide
2513
+
2514
+ if (leftSide) {
2515
+ expect(leftSide.isDestroyed).toBe(true)
2516
+ }
2517
+ if (rightSide) {
2518
+ expect(rightSide.isDestroyed).toBe(true)
2519
+ }
2520
+ })
2521
+
2522
+ test("DiffRenderable - line numbers update correctly after resize causes wrapping changes", async () => {
2523
+ const testRenderer = await createTestRenderer({ width: 120, height: 40 })
2524
+ const renderer = testRenderer.renderer
2525
+ const renderOnce = testRenderer.renderOnce
2526
+ const captureFrame = testRenderer.captureCharFrame
2527
+ const resize = testRenderer.resize
2528
+
2529
+ const syntaxStyle = SyntaxStyle.fromStyles({
2530
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2531
+ })
2532
+
2533
+ const longLineDiff = `--- a/test.js
2534
+ +++ b/test.js
2535
+ @@ -1,4 +1,4 @@
2536
+ function calculateSomethingVeryComplexWithALongFunctionNameThatWillWrap() {
2537
+ - const oldResultWithAVeryLongVariableNameThatWillDefinitelyWrapWhenRenderedInASmallerTerminal = 42;
2538
+ + const newResultWithAVeryLongVariableNameThatWillDefinitelyWrapWhenRenderedInASmallerTerminal = 100;
2539
+ return result;
2540
+ }`
2541
+
2542
+ const diffRenderable = new DiffRenderable(renderer, {
2543
+ id: "test-diff",
2544
+ diff: longLineDiff,
2545
+ view: "unified",
2546
+ syntaxStyle,
2547
+ showLineNumbers: true,
2548
+ wrapMode: "word",
2549
+ width: "100%",
2550
+ height: "100%",
2551
+ })
2552
+
2553
+ renderer.root.add(diffRenderable)
2554
+ await renderOnce()
2555
+
2556
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2557
+
2558
+ let lineInfoChangeEmitted = false
2559
+ const lineInfoChangeListener = () => {
2560
+ lineInfoChangeEmitted = true
2561
+ }
2562
+ leftCodeRenderable.on("line-info-change", lineInfoChangeListener)
2563
+
2564
+ const frameBefore = captureFrame()
2565
+ expect(frameBefore).toMatchSnapshot("before resize - line numbers with no wrapping")
2566
+
2567
+ const lineInfoBefore = leftCodeRenderable.lineInfo
2568
+ expect(lineInfoBefore.lineSources).toEqual([0, 1, 2, 3, 4])
2569
+ expect(leftCodeRenderable.virtualLineCount).toBe(5)
2570
+
2571
+ lineInfoChangeEmitted = false
2572
+
2573
+ resize(60, 40)
2574
+
2575
+ await Promise.resolve()
2576
+ await renderOnce()
2577
+
2578
+ expect(lineInfoChangeEmitted).toBe(true)
2579
+ expect(leftCodeRenderable.virtualLineCount).toBe(11)
2580
+
2581
+ await Promise.resolve()
2582
+ await renderOnce()
2583
+
2584
+ const frameAfter = captureFrame()
2585
+ expect(frameAfter).toMatchSnapshot("after resize - line numbers with wrapping")
2586
+
2587
+ const lineInfoAfter = leftCodeRenderable.lineInfo
2588
+ expect(lineInfoAfter.lineSources).toEqual([0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 4])
2589
+
2590
+ const linesAfter = frameAfter.split("\n").filter((l) => l.trim().length > 0)
2591
+
2592
+ const lineNumberMatches = linesAfter
2593
+ .map((line, idx) => {
2594
+ const match = line.match(/^\s*(\d+)\s+([+-]?)/)
2595
+ if (match) {
2596
+ return { lineIdx: idx, lineNum: parseInt(match[1]), sign: match[2], content: line }
2597
+ }
2598
+ return null
2599
+ })
2600
+ .filter((m) => m !== null)
2601
+
2602
+ expect(lineNumberMatches.length).toBe(5)
2603
+
2604
+ expect(lineNumberMatches[0]!.lineNum).toBe(1)
2605
+ expect(lineNumberMatches[1]!.lineNum).toBe(2)
2606
+ expect(lineNumberMatches[1]!.sign).toBe("-")
2607
+ expect(lineNumberMatches[2]!.lineNum).toBe(2)
2608
+ expect(lineNumberMatches[2]!.sign).toBe("+")
2609
+ expect(lineNumberMatches[3]!.lineNum).toBe(3)
2610
+ expect(lineNumberMatches[4]!.lineNum).toBe(4)
2611
+
2612
+ leftCodeRenderable.off("line-info-change", lineInfoChangeListener)
2613
+ renderer.destroy()
2614
+ })
2615
+
2616
+ test("DiffRenderable - fg prop is passed to CodeRenderable on construction", async () => {
2617
+ const syntaxStyle = SyntaxStyle.fromStyles({
2618
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2619
+ })
2620
+ const customFg = "#000000"
2621
+
2622
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2623
+ id: "test-diff",
2624
+ diff: simpleDiff,
2625
+ view: "unified",
2626
+ syntaxStyle,
2627
+ fg: customFg,
2628
+ width: "100%",
2629
+ height: "100%",
2630
+ })
2631
+
2632
+ currentRenderer.root.add(diffRenderable)
2633
+ await renderOnce()
2634
+
2635
+ expect(diffRenderable.fg).toEqual(RGBA.fromHex(customFg))
2636
+
2637
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2638
+ expect(leftCodeRenderable).toBeDefined()
2639
+ expect(leftCodeRenderable.fg).toEqual(RGBA.fromHex(customFg))
2640
+ })
2641
+
2642
+ test("DiffRenderable - fg prop can be updated via setter", async () => {
2643
+ const syntaxStyle = SyntaxStyle.fromStyles({
2644
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2645
+ })
2646
+ const initialFg = "#000000"
2647
+ const updatedFg = "#333333"
2648
+
2649
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2650
+ id: "test-diff",
2651
+ diff: simpleDiff,
2652
+ view: "unified",
2653
+ syntaxStyle,
2654
+ fg: initialFg,
2655
+ width: "100%",
2656
+ height: "100%",
2657
+ })
2658
+
2659
+ currentRenderer.root.add(diffRenderable)
2660
+ await renderOnce()
2661
+
2662
+ diffRenderable.fg = updatedFg
2663
+ await renderOnce()
2664
+
2665
+ expect(diffRenderable.fg).toEqual(RGBA.fromHex(updatedFg))
2666
+
2667
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2668
+ expect(leftCodeRenderable.fg).toEqual(RGBA.fromHex(updatedFg))
2669
+ })
2670
+
2671
+ test("DiffRenderable - fg prop is passed to both CodeRenderables in split view", async () => {
2672
+ const syntaxStyle = SyntaxStyle.fromStyles({
2673
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2674
+ })
2675
+ const customFg = "#222222"
2676
+
2677
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2678
+ id: "test-diff",
2679
+ diff: simpleDiff,
2680
+ view: "split",
2681
+ syntaxStyle,
2682
+ fg: customFg,
2683
+ width: "100%",
2684
+ height: "100%",
2685
+ })
2686
+
2687
+ currentRenderer.root.add(diffRenderable)
2688
+ await renderOnce()
2689
+
2690
+ expect(diffRenderable.fg).toEqual(RGBA.fromHex(customFg))
2691
+
2692
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2693
+ const rightCodeRenderable = (diffRenderable as any).rightCodeRenderable
2694
+
2695
+ expect(leftCodeRenderable).toBeDefined()
2696
+ expect(rightCodeRenderable).toBeDefined()
2697
+ expect(leftCodeRenderable.fg).toEqual(RGBA.fromHex(customFg))
2698
+ expect(rightCodeRenderable.fg).toEqual(RGBA.fromHex(customFg))
2699
+ })
2700
+
2701
+ test("DiffRenderable - fg prop updates both CodeRenderables in split view", async () => {
2702
+ const syntaxStyle = SyntaxStyle.fromStyles({
2703
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2704
+ })
2705
+ const initialFg = "#111111"
2706
+ const updatedFg = "#444444"
2707
+
2708
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2709
+ id: "test-diff",
2710
+ diff: simpleDiff,
2711
+ view: "split",
2712
+ syntaxStyle,
2713
+ fg: initialFg,
2714
+ width: "100%",
2715
+ height: "100%",
2716
+ })
2717
+
2718
+ currentRenderer.root.add(diffRenderable)
2719
+ await renderOnce()
2720
+
2721
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2722
+ const rightCodeRenderable = (diffRenderable as any).rightCodeRenderable
2723
+
2724
+ diffRenderable.fg = updatedFg
2725
+ await renderOnce()
2726
+
2727
+ expect(diffRenderable.fg).toEqual(RGBA.fromHex(updatedFg))
2728
+ expect(leftCodeRenderable.fg).toEqual(RGBA.fromHex(updatedFg))
2729
+ expect(rightCodeRenderable.fg).toEqual(RGBA.fromHex(updatedFg))
2730
+ })
2731
+
2732
+ test("DiffRenderable - fg prop defaults to undefined when not specified", async () => {
2733
+ const syntaxStyle = SyntaxStyle.fromStyles({
2734
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2735
+ })
2736
+
2737
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2738
+ id: "test-diff",
2739
+ diff: simpleDiff,
2740
+ view: "unified",
2741
+ syntaxStyle,
2742
+ width: "100%",
2743
+ height: "100%",
2744
+ })
2745
+
2746
+ currentRenderer.root.add(diffRenderable)
2747
+ await renderOnce()
2748
+
2749
+ expect(diffRenderable.fg).toBeUndefined()
2750
+ })
2751
+
2752
+ test("DiffRenderable - fg prop can be set to undefined to clear it", async () => {
2753
+ const syntaxStyle = SyntaxStyle.fromStyles({
2754
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2755
+ })
2756
+ const initialFg = "#000000"
2757
+
2758
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2759
+ id: "test-diff",
2760
+ diff: simpleDiff,
2761
+ view: "unified",
2762
+ syntaxStyle,
2763
+ fg: initialFg,
2764
+ width: "100%",
2765
+ height: "100%",
2766
+ })
2767
+
2768
+ currentRenderer.root.add(diffRenderable)
2769
+ await renderOnce()
2770
+
2771
+ expect(diffRenderable.fg).toEqual(RGBA.fromHex(initialFg))
2772
+
2773
+ diffRenderable.fg = undefined
2774
+ await renderOnce()
2775
+
2776
+ expect(diffRenderable.fg).toBeUndefined()
2777
+ })
2778
+
2779
+ test("DiffRenderable - fg prop accepts RGBA directly", async () => {
2780
+ const syntaxStyle = SyntaxStyle.fromStyles({
2781
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
2782
+ })
2783
+ const customFg = RGBA.fromValues(0.2, 0.2, 0.2, 1)
2784
+
2785
+ const diffRenderable = new DiffRenderable(currentRenderer, {
2786
+ id: "test-diff",
2787
+ diff: simpleDiff,
2788
+ view: "unified",
2789
+ syntaxStyle,
2790
+ fg: customFg,
2791
+ width: "100%",
2792
+ height: "100%",
2793
+ })
2794
+
2795
+ currentRenderer.root.add(diffRenderable)
2796
+ await renderOnce()
2797
+
2798
+ expect(diffRenderable.fg).toEqual(customFg)
2799
+
2800
+ const leftCodeRenderable = (diffRenderable as any).leftCodeRenderable
2801
+ expect(leftCodeRenderable.fg).toEqual(customFg)
2802
+ })
2803
+
2804
+ test("DiffRenderable - split view with word wrapping: changing diff content should not misalign sides", async () => {
2805
+ const { BoxRenderable } = await import("./Box.js")
2806
+ const { parseColor } = await import("../lib/RGBA.js")
2807
+
2808
+ // Use terminal width that matches the demo (~116 chars)
2809
+ const testRenderer = await createTestRenderer({ width: 116, height: 30 })
2810
+ const renderer = testRenderer.renderer
2811
+ const captureFrame = testRenderer.captureCharFrame
2812
+
2813
+ // GitHub Dark theme - EXACTLY as in diff-demo.ts
2814
+ const theme = {
2815
+ backgroundColor: "#0D1117",
2816
+ addedBg: "#1a4d1a",
2817
+ removedBg: "#4d1a1a",
2818
+ contextBg: "transparent",
2819
+ addedSignColor: "#22c55e",
2820
+ removedSignColor: "#ef4444",
2821
+ lineNumberFg: "#6b7280",
2822
+ lineNumberBg: "#161b22",
2823
+ addedLineNumberBg: "#0d3a0d",
2824
+ removedLineNumberBg: "#3a0d0d",
2825
+ selectionBg: "#264F78",
2826
+ selectionFg: "#FFFFFF",
2827
+ }
2828
+
2829
+ // Syntax style EXACTLY as in diff-demo.ts GitHub Dark theme
2830
+ const syntaxStyle = SyntaxStyle.fromStyles({
2831
+ keyword: { fg: parseColor("#FF7B72"), bold: true },
2832
+ "keyword.import": { fg: parseColor("#FF7B72"), bold: true },
2833
+ string: { fg: parseColor("#A5D6FF") },
2834
+ comment: { fg: parseColor("#8B949E"), italic: true },
2835
+ number: { fg: parseColor("#79C0FF") },
2836
+ boolean: { fg: parseColor("#79C0FF") },
2837
+ constant: { fg: parseColor("#79C0FF") },
2838
+ function: { fg: parseColor("#D2A8FF") },
2839
+ "function.call": { fg: parseColor("#D2A8FF") },
2840
+ constructor: { fg: parseColor("#FFA657") },
2841
+ type: { fg: parseColor("#FFA657") },
2842
+ operator: { fg: parseColor("#FF7B72") },
2843
+ variable: { fg: parseColor("#E6EDF3") },
2844
+ property: { fg: parseColor("#79C0FF") },
2845
+ bracket: { fg: parseColor("#F0F6FC") },
2846
+ punctuation: { fg: parseColor("#F0F6FC") },
2847
+ default: { fg: parseColor("#E6EDF3") },
2848
+ })
2849
+
2850
+ // contentExamples[0] - TypeScript Calculator diff
2851
+ const calculatorDiff = `--- a/calculator.ts
2852
+ +++ b/calculator.ts
2853
+ @@ -1,13 +1,20 @@
2854
+ class Calculator {
2855
+ add(a: number, b: number): number {
2856
+ return a + b;
2857
+ }
2858
+
2859
+ - subtract(a: number, b: number): number {
2860
+ - return a - b;
2861
+ + subtract(a: number, b: number, c: number = 0): number {
2862
+ + return a - b - c;
2863
+ }
2864
+
2865
+ multiply(a: number, b: number): number {
2866
+ return a * b;
2867
+ }
2868
+ +
2869
+ + divide(a: number, b: number): number {
2870
+ + if (b === 0) {
2871
+ + throw new Error("Division by zero");
2872
+ + }
2873
+ + return a / b;
2874
+ + }
2875
+ }`
2876
+
2877
+ // contentExamples[1] - Real Session: Text Demo
2878
+ const textDemoDiff = `Index: packages/core/src/examples/index.ts
2879
+ ===================================================================
2880
+ --- packages/core/src/examples/index.ts before
2881
+ +++ packages/core/src/examples/index.ts after
2882
+ @@ -56,6 +56,7 @@
2883
+ import * as terminalDemo from "./terminal"
2884
+ import * as diffDemo from "./diff-demo"
2885
+ import * as keypressDebugDemo from "./keypress-debug-demo"
2886
+ +import * as textTruncationDemo from "./text-truncation-demo"
2887
+ import { setupCommonDemoKeys } from "./lib/standalone-keys"
2888
+
2889
+ interface Example {
2890
+ @@ -85,6 +86,12 @@
2891
+ destroy: textSelectionExample.destroy,
2892
+ },
2893
+ {
2894
+ + name: "Text Truncation Demo",
2895
+ + description: "Middle truncation with ellipsis - toggle with 'T' key and resize to test responsive behavior",
2896
+ + run: textTruncationDemo.run,
2897
+ + destroy: textTruncationDemo.destroy,
2898
+ + },
2899
+ + {
2900
+ name: "ASCII Font Selection Demo",
2901
+ description: "Text selection with ASCII fonts - precise character-level selection across different font types",
2902
+ run: asciiFontSelectionExample.run,`
2903
+
2904
+ renderer.setBackgroundColor(theme.backgroundColor)
2905
+
2906
+ // PART 1: CORRECT PATH
2907
+ // Start with textDemoDiff, view="unified", wrapMode="none"
2908
+ // Then toggle to split, then toggle to word wrap
2909
+ // This produces CORRECT alignment
2910
+ const parentContainer1 = new BoxRenderable(renderer, {
2911
+ id: "parent-container-1",
2912
+ padding: 1,
2913
+ })
2914
+ renderer.root.add(parentContainer1)
2915
+
2916
+ const correctDiff = new DiffRenderable(renderer, {
2917
+ id: "correct-diff",
2918
+ diff: textDemoDiff, // Start with textDemoDiff directly
2919
+ view: "unified",
2920
+ filetype: "typescript",
2921
+ syntaxStyle,
2922
+ showLineNumbers: true,
2923
+ wrapMode: "none",
2924
+ conceal: true,
2925
+ addedBg: theme.addedBg,
2926
+ removedBg: theme.removedBg,
2927
+ contextBg: theme.contextBg,
2928
+ addedSignColor: theme.addedSignColor,
2929
+ removedSignColor: theme.removedSignColor,
2930
+ lineNumberFg: theme.lineNumberFg,
2931
+ lineNumberBg: theme.lineNumberBg,
2932
+ addedLineNumberBg: theme.addedLineNumberBg,
2933
+ removedLineNumberBg: theme.removedLineNumberBg,
2934
+ selectionBg: theme.selectionBg,
2935
+ selectionFg: theme.selectionFg,
2936
+ flexGrow: 1,
2937
+ flexShrink: 1,
2938
+ })
2939
+
2940
+ parentContainer1.add(correctDiff)
2941
+ await renderOnce()
2942
+
2943
+ // Press V - toggle to split view
2944
+ correctDiff.view = "split"
2945
+ await Promise.resolve()
2946
+ await renderOnce()
2947
+
2948
+ // Press W - toggle to word wrap
2949
+ correctDiff.wrapMode = "word"
2950
+ await Promise.resolve()
2951
+ await renderOnce()
2952
+ await Promise.resolve()
2953
+ await renderOnce()
2954
+
2955
+ const correctFrame = captureFrame()
2956
+
2957
+ // Clean up
2958
+ parentContainer1.destroyRecursively()
2959
+ renderer.root.remove("parent-container-1")
2960
+ await renderOnce()
2961
+
2962
+ // PART 2: BUGGY PATH
2963
+ // Start with calculatorDiff, view="unified", wrapMode="none"
2964
+ // Press V (split), Press W (word), Press C (change to textDemoDiff)
2965
+ // This produces WRONG alignment due to stale lineInfo
2966
+ const parentContainer2 = new BoxRenderable(renderer, {
2967
+ id: "parent-container-2",
2968
+ padding: 1,
2969
+ })
2970
+ renderer.root.add(parentContainer2)
2971
+
2972
+ const buggyDiff = new DiffRenderable(renderer, {
2973
+ id: "buggy-diff",
2974
+ diff: calculatorDiff, // Start with calculatorDiff (contentExamples[0])
2975
+ view: "unified",
2976
+ filetype: "typescript",
2977
+ syntaxStyle,
2978
+ showLineNumbers: true,
2979
+ wrapMode: "none",
2980
+ conceal: true,
2981
+ addedBg: theme.addedBg,
2982
+ removedBg: theme.removedBg,
2983
+ contextBg: theme.contextBg,
2984
+ addedSignColor: theme.addedSignColor,
2985
+ removedSignColor: theme.removedSignColor,
2986
+ lineNumberFg: theme.lineNumberFg,
2987
+ lineNumberBg: theme.lineNumberBg,
2988
+ addedLineNumberBg: theme.addedLineNumberBg,
2989
+ removedLineNumberBg: theme.removedLineNumberBg,
2990
+ selectionBg: theme.selectionBg,
2991
+ selectionFg: theme.selectionFg,
2992
+ flexGrow: 1,
2993
+ flexShrink: 1,
2994
+ })
2995
+
2996
+ parentContainer2.add(buggyDiff)
2997
+ await renderOnce()
2998
+
2999
+ // Press V - toggle to split view
3000
+ buggyDiff.view = "split"
3001
+ await Promise.resolve()
3002
+ await renderOnce()
3003
+
3004
+ // Press W - toggle to word wrap
3005
+ buggyDiff.wrapMode = "word"
3006
+ await Promise.resolve()
3007
+ await renderOnce()
3008
+
3009
+ // Press C - change diff content to textDemoDiff
3010
+ // THIS IS WHERE THE BUG MANIFESTS - lineInfo is STALE
3011
+ buggyDiff.diff = textDemoDiff
3012
+ buggyDiff.filetype = "typescript"
3013
+ await Promise.resolve()
3014
+ await renderOnce()
3015
+ await Promise.resolve()
3016
+ await renderOnce()
3017
+
3018
+ const buggyFrame = captureFrame()
3019
+
3020
+ // Clean up
3021
+ renderer.destroy()
3022
+
3023
+ // ASSERTION: Both frames should be identical since they show the same diff content
3024
+ // with the same view settings (split + word wrap)
3025
+ // But due to the bug, the buggy frame has misaligned left/right sides because
3026
+ // the lineInfo from CodeRenderable is STALE after changing diff content
3027
+ expect(buggyFrame).toBe(correctFrame)
3028
+ })
3029
+
3030
+ test("DiffRenderable - setLineColor applies color to line", async () => {
3031
+ const syntaxStyle = SyntaxStyle.fromStyles({
3032
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
3033
+ })
3034
+
3035
+ const diffRenderable = new DiffRenderable(currentRenderer, {
3036
+ id: "test-diff",
3037
+ diff: simpleDiff,
3038
+ view: "unified",
3039
+ syntaxStyle,
3040
+ })
3041
+
3042
+ diffRenderable.setLineColor(0, "#ff0000")
3043
+ diffRenderable.setLineColor(1, { gutter: "#00ff00", content: "#0000ff" })
3044
+ diffRenderable.clearLineColor(0)
3045
+ diffRenderable.clearLineColor(1)
3046
+ })
3047
+
3048
+ test("DiffRenderable - highlightLines applies color to range", async () => {
3049
+ const syntaxStyle = SyntaxStyle.fromStyles({
3050
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
3051
+ })
3052
+
3053
+ const diffRenderable = new DiffRenderable(currentRenderer, {
3054
+ id: "test-diff",
3055
+ diff: multiLineDiff,
3056
+ view: "unified",
3057
+ syntaxStyle,
3058
+ })
3059
+
3060
+ diffRenderable.highlightLines(0, 3, "#ff0000")
3061
+ diffRenderable.clearHighlightLines(0, 3)
3062
+ })
3063
+
3064
+ test("DiffRenderable - setLineColors and clearAllLineColors", async () => {
3065
+ const syntaxStyle = SyntaxStyle.fromStyles({
3066
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
3067
+ })
3068
+
3069
+ const diffRenderable = new DiffRenderable(currentRenderer, {
3070
+ id: "test-diff",
3071
+ diff: simpleDiff,
3072
+ view: "unified",
3073
+ syntaxStyle,
3074
+ })
3075
+
3076
+ const lineColors = new Map<number, string>()
3077
+ lineColors.set(0, "#ff0000")
3078
+ lineColors.set(1, "#00ff00")
3079
+ lineColors.set(2, "#0000ff")
3080
+
3081
+ diffRenderable.setLineColors(lineColors)
3082
+ diffRenderable.clearAllLineColors()
3083
+ })
3084
+
3085
+ test("DiffRenderable - line highlighting works in split view", async () => {
3086
+ const syntaxStyle = SyntaxStyle.fromStyles({
3087
+ default: { fg: RGBA.fromValues(1, 1, 1, 1) },
3088
+ })
3089
+
3090
+ const diffRenderable = new DiffRenderable(currentRenderer, {
3091
+ id: "test-diff",
3092
+ diff: simpleDiff,
3093
+ view: "split",
3094
+ syntaxStyle,
3095
+ })
3096
+
3097
+ diffRenderable.setLineColor(0, "#ff0000")
3098
+ diffRenderable.highlightLines(0, 2, "#00ff00")
3099
+ diffRenderable.clearHighlightLines(0, 2)
3100
+ diffRenderable.clearAllLineColors()
3101
+ })