@fairyhunter13/opentui-core 0.1.88 → 0.1.90

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