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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (345) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +41 -27
  8. package/src/annotations/annotation-palette.ts +62 -0
  9. package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
  10. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  11. package/src/annotations/components/AnnotationCards.tsx +364 -0
  12. package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
  13. package/src/annotations/components/DiscussionMarkerRail.tsx +186 -0
  14. package/src/annotations/discussions.ts +77 -0
  15. package/src/annotations/thread-selection.test.ts +210 -0
  16. package/src/annotations/thread-selection.ts +242 -0
  17. package/src/annotations/use-annotation-surface.tsx +1238 -0
  18. package/src/app/App.appearance.test.tsx +53 -0
  19. package/src/app/App.autoclose.test.tsx +144 -0
  20. package/src/app/App.deleted-scroll.test.tsx +187 -0
  21. package/src/app/App.diff.test.tsx +313 -0
  22. package/src/app/App.export.test.tsx +105 -0
  23. package/src/app/App.focus.test.ts +89 -0
  24. package/src/app/App.identity.test.tsx +84 -0
  25. package/src/app/App.plan-render.test.tsx +209 -0
  26. package/src/app/App.plan-review.test.tsx +428 -0
  27. package/src/app/App.prototype-markdown.test.tsx +128 -0
  28. package/src/app/App.prototype.test.tsx +176 -0
  29. package/src/app/App.readonly.test.tsx +210 -0
  30. package/src/app/App.regression.test.tsx +99 -0
  31. package/src/app/App.rendering.test.tsx +134 -0
  32. package/src/app/App.share-dialog.test.tsx +177 -0
  33. package/src/app/App.submit-confirm.test.tsx +267 -0
  34. package/src/app/App.test.tsx +496 -0
  35. package/src/app/App.thread-agent.test.ts +12 -0
  36. package/src/app/App.toast.test.tsx +124 -0
  37. package/src/app/App.tsx +1650 -0
  38. package/src/app/App.visual.test.tsx +59 -0
  39. package/src/app/App.walk.test.tsx +299 -0
  40. package/src/app/App.workbench.test.tsx +1149 -0
  41. package/src/app/app-key-state.ts +61 -0
  42. package/src/app/app-screens.tsx +606 -0
  43. package/src/app/app-view-model.ts +168 -0
  44. package/src/app/components/AppHeader.stories.tsx +50 -0
  45. package/src/app/components/AppHeader.tsx +81 -0
  46. package/src/app/components/AppShell.brand.test.tsx +58 -0
  47. package/src/app/components/AppShell.extensions.test.tsx +191 -0
  48. package/src/app/components/AppShell.stories.tsx +89 -0
  49. package/src/app/components/AppShell.tsx +358 -0
  50. package/src/app/components/AppShell.zoom.test.tsx +149 -0
  51. package/src/app/components/Breadcrumb.stories.tsx +19 -0
  52. package/src/app/components/Breadcrumb.tsx +45 -0
  53. package/src/app/components/MenuBar.stories.tsx +28 -0
  54. package/src/app/components/MenuBar.tsx +44 -0
  55. package/src/app/components/PaneGrid.stories.tsx +84 -0
  56. package/src/app/components/PaneGrid.tsx +128 -0
  57. package/src/app/components/PanelColumn.stories.tsx +25 -0
  58. package/src/app/components/PanelColumn.tsx +93 -0
  59. package/src/app/components/ShellHeader.stories.tsx +50 -0
  60. package/src/app/components/ShellHeader.tsx +137 -0
  61. package/src/app/components/WorkspacePanels.stories.tsx +36 -0
  62. package/src/app/components/WorkspacePanels.tsx +120 -0
  63. package/src/app/components/pages/Workspace.stories.tsx +181 -0
  64. package/src/app/intent-dispatch.test.ts +728 -0
  65. package/src/app/intent-dispatch.ts +687 -0
  66. package/src/app/launch-layout.test.ts +78 -0
  67. package/src/app/launch-layout.ts +57 -0
  68. package/src/app/ready-signal.test.ts +85 -0
  69. package/src/app/ready-signal.ts +52 -0
  70. package/src/app/run.ts +109 -0
  71. package/src/{serve.test.ts → app/serve.test.ts} +24 -12
  72. package/src/app/serve.ts +111 -0
  73. package/src/app/version.ts +5 -0
  74. package/src/appearance/components/theme-context.stories.tsx +46 -0
  75. package/src/appearance/components/theme-context.tsx +37 -0
  76. package/src/appearance/theme-presets.test.ts +51 -0
  77. package/src/appearance/theme-presets.ts +219 -0
  78. package/src/appearance/theme.ts +112 -0
  79. package/src/appearance/visual/contrast.test.ts +35 -0
  80. package/src/appearance/visual/contrast.ts +33 -0
  81. package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
  82. package/src/diff/components/AnnotatableFileView.test.tsx +157 -0
  83. package/src/diff/components/AnnotatableFileView.tsx +141 -0
  84. package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
  85. package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
  86. package/src/diff/components/DiffContentView.scroll.test.tsx +434 -0
  87. package/src/diff/components/DiffContentView.split.test.tsx +140 -0
  88. package/src/diff/components/DiffContentView.stories.tsx +80 -0
  89. package/src/diff/components/DiffContentView.syntax.test.tsx +82 -0
  90. package/src/diff/components/DiffContentView.tsx +1055 -0
  91. package/src/diff/components/WalkWizard.stories.tsx +100 -0
  92. package/src/diff/components/WalkWizard.tsx +146 -0
  93. package/src/diff/components/diff-content-view-layout.test.ts +198 -0
  94. package/src/diff/components/diff-content-view-layout.ts +84 -0
  95. package/src/diff/diff-fold.test.ts +78 -0
  96. package/src/diff/diff-fold.ts +126 -0
  97. package/src/diff/diff-hunk-curate.test.ts +315 -0
  98. package/src/diff/diff-hunk-curate.ts +152 -0
  99. package/src/diff/diff-intraline.test.ts +239 -0
  100. package/src/diff/diff-intraline.ts +238 -0
  101. package/src/diff/diff-syntax.test.ts +125 -0
  102. package/src/diff/diff-syntax.ts +186 -0
  103. package/src/diff/split-diff.test.ts +110 -0
  104. package/src/diff/split-diff.ts +144 -0
  105. package/src/diff/view-diff.test.ts +325 -0
  106. package/src/diff/view-diff.ts +522 -0
  107. package/src/diff/walk.test.ts +121 -0
  108. package/src/diff/walk.ts +73 -0
  109. package/src/index.ts +31 -2
  110. package/src/integrations/attribution.test.ts +31 -0
  111. package/src/integrations/attribution.ts +42 -0
  112. package/src/integrations/client-extension-registry.test.tsx +61 -0
  113. package/src/integrations/client-extension-registry.ts +159 -0
  114. package/src/integrations/clipboard.test.ts +23 -0
  115. package/src/integrations/clipboard.ts +33 -0
  116. package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
  117. package/src/integrations/components/ExtensionZone.tsx +145 -0
  118. package/src/integrations/components/ShareDialog.stories.tsx +121 -0
  119. package/src/integrations/components/ShareDialog.tsx +550 -0
  120. package/src/integrations/components/share-dialog-store.ts +55 -0
  121. package/src/integrations/editor.test.ts +149 -0
  122. package/src/integrations/editor.ts +160 -0
  123. package/src/integrations/github-identity.test.ts +58 -0
  124. package/src/integrations/github-identity.ts +41 -0
  125. package/src/integrations/herdr.test.ts +43 -0
  126. package/src/integrations/herdr.ts +38 -0
  127. package/src/integrations/integrations.test.ts +41 -0
  128. package/src/integrations/integrations.ts +32 -0
  129. package/src/integrations/pasted-image.test.ts +67 -0
  130. package/src/integrations/pasted-image.ts +60 -0
  131. package/src/integrations/share.ts +238 -0
  132. package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
  133. package/src/keyboard/components/CompletionOverlay.tsx +75 -0
  134. package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
  135. package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
  136. package/src/keyboard/components/HandleChipsInput.tsx +143 -0
  137. package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
  138. package/src/keyboard/components/KeybindsDialog.tsx +103 -0
  139. package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
  140. package/src/keyboard/components/NavModeHint.tsx +31 -0
  141. package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
  142. package/src/keyboard/components/SlashComposer.tsx +87 -0
  143. package/src/keyboard/key-bindings.test.ts +156 -0
  144. package/src/keyboard/key-bindings.ts +459 -0
  145. package/src/keyboard/keymap.test.ts +611 -0
  146. package/src/keyboard/keymap.ts +395 -0
  147. package/src/keyboard/skills.test.ts +74 -0
  148. package/src/keyboard/skills.ts +75 -0
  149. package/src/keyboard/slash-palette.test.ts +103 -0
  150. package/src/keyboard/slash-palette.ts +129 -0
  151. package/src/keyboard/thread-chords.test.ts +79 -0
  152. package/src/keyboard/thread-chords.ts +163 -0
  153. package/src/keyboard/use-shared-keyboard.ts +55 -0
  154. package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  155. package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
  156. package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
  157. package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
  158. package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
  159. package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
  160. package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
  161. package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
  162. package/src/markdown/components/ThreadView.stories.tsx +26 -0
  163. package/src/markdown/components/ThreadView.tsx +560 -0
  164. package/src/markdown/mark-runs.ts +121 -0
  165. package/src/markdown/markdown-editor-syntax-style.ts +50 -0
  166. package/src/markdown/markdown-grid.test.ts +101 -0
  167. package/src/markdown/markdown-grid.ts +203 -0
  168. package/src/markdown/markdown-highlight.test.ts +61 -0
  169. package/src/markdown/markdown-highlight.ts +124 -0
  170. package/src/markdown/thread-code-syntax.ts +45 -0
  171. package/src/markdown/use-thread-body-editing.tsx +54 -0
  172. package/src/markdown/view-plan.regression.test.ts +60 -0
  173. package/src/markdown/view-plan.test.ts +545 -0
  174. package/src/markdown/view-plan.ts +1009 -0
  175. package/src/performance/otlp-trace.test.ts +119 -0
  176. package/src/performance/otlp-trace.ts +134 -0
  177. package/src/performance/perf-report.ts +18 -0
  178. package/src/performance/perf-timings.test.ts +18 -0
  179. package/src/performance/perf-timings.ts +39 -0
  180. package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
  181. package/src/prototype/components/PrototypeContentView.tsx +442 -0
  182. package/src/prototype/prototype-browser.test.ts +81 -0
  183. package/src/prototype/prototype-browser.ts +321 -0
  184. package/src/prototype/prototype-pixels.tsx +35 -0
  185. package/src/settings/components/SettingsDialog.stories.tsx +117 -0
  186. package/src/settings/components/SettingsDialog.tsx +183 -0
  187. package/src/settings/components/SettingsRows.stories.tsx +18 -0
  188. package/src/settings/components/SettingsRows.tsx +112 -0
  189. package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
  190. package/src/settings/components/quick-actions-editor.test.tsx +120 -0
  191. package/src/settings/components/quick-actions-editor.tsx +222 -0
  192. package/src/settings/config-identity.test.ts +78 -0
  193. package/src/settings/config.test.ts +676 -0
  194. package/src/settings/config.ts +628 -0
  195. package/src/settings/use-settings-dialog.tsx +351 -0
  196. package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
  197. package/src/stories/stories-app.tsx +196 -0
  198. package/src/stories/stories.test.tsx +74 -0
  199. package/src/stories/story-fixtures.ts +102 -0
  200. package/src/stories/story-tree.test.ts +108 -0
  201. package/src/stories/story.ts +154 -0
  202. package/src/terminal/ansi-screen.test.ts +28 -0
  203. package/src/terminal/ansi-screen.ts +72 -0
  204. package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
  205. package/src/terminal/components/AnsiScreen.tsx +28 -0
  206. package/src/terminal/components/terminal-pane.test.tsx +40 -0
  207. package/src/terminal/components/terminal-pane.ts +196 -0
  208. package/src/terminal/ghostty-terminal.test.ts +85 -0
  209. package/src/terminal/ghostty-terminal.ts +197 -0
  210. package/src/terminal/kitty-image.test.ts +66 -0
  211. package/src/terminal/kitty-image.ts +137 -0
  212. package/src/terminal/pty.test.ts +81 -0
  213. package/src/terminal/pty.ts +246 -0
  214. package/src/testing/test-support.test.ts +264 -0
  215. package/src/testing/test-support.ts +367 -0
  216. package/src/thread/agent-continuation.test.ts +32 -0
  217. package/src/thread/agent-continuation.ts +13 -0
  218. package/src/thread/agent-transcript.test.ts +161 -0
  219. package/src/thread/agent-transcript.ts +336 -0
  220. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  221. package/src/thread/components/AgentConfigControls.test.tsx +112 -0
  222. package/src/thread/components/AgentConfigControls.tsx +114 -0
  223. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  224. package/src/thread/components/AgentThreadPane.test.tsx +829 -0
  225. package/src/thread/components/AgentThreadPane.tsx +367 -0
  226. package/src/thread/components/ThreadFooter.stories.tsx +30 -0
  227. package/src/thread/components/ThreadFooter.test.tsx +49 -0
  228. package/src/thread/components/ThreadFooter.tsx +77 -0
  229. package/src/thread/components/ThreadTree.stories.tsx +46 -0
  230. package/src/thread/components/ThreadTree.test.tsx +41 -0
  231. package/src/thread/components/ThreadTree.tsx +267 -0
  232. package/src/thread/components/thread-tree.test.ts +137 -0
  233. package/src/thread/components/thread-tree.ts +112 -0
  234. package/src/thread/thread-controller.curate.test.ts +502 -0
  235. package/src/thread/thread-controller.derivation.test.ts +62 -0
  236. package/src/thread/thread-controller.live-diff.test.ts +357 -0
  237. package/src/thread/thread-controller.optimistic.test.ts +69 -0
  238. package/src/thread/thread-controller.share.test.ts +573 -0
  239. package/src/thread/thread-controller.switch-cache.test.ts +174 -0
  240. package/src/thread/thread-controller.tree.test.ts +263 -0
  241. package/src/thread/thread-controller.ts +2018 -0
  242. package/src/thread/tree-view.test.ts +128 -0
  243. package/src/thread/tree-view.ts +146 -0
  244. package/src/thread/use-thread-agent.test.tsx +208 -0
  245. package/src/thread/use-thread-agent.ts +111 -0
  246. package/src/ui/components/CodeBlock.stories.tsx +42 -0
  247. package/src/ui/components/CodeBlock.tsx +80 -0
  248. package/src/ui/components/ConfirmCard.stories.tsx +37 -0
  249. package/src/ui/components/ConfirmCard.tsx +151 -0
  250. package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
  251. package/src/ui/components/ConfirmDialog.tsx +72 -0
  252. package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
  253. package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
  254. package/src/ui/components/OverlayScrollbar.tsx +129 -0
  255. package/src/ui/components/PromptDialog.stories.tsx +36 -0
  256. package/src/ui/components/PromptDialog.test.tsx +65 -0
  257. package/src/ui/components/PromptDialog.tsx +99 -0
  258. package/src/ui/components/RootOverlay.stories.tsx +45 -0
  259. package/src/ui/components/RootOverlay.tsx +49 -0
  260. package/src/ui/components/ScrollArea.stories.tsx +20 -0
  261. package/src/ui/components/ScrollArea.tsx +61 -0
  262. package/src/ui/components/Toast.stories.tsx +19 -0
  263. package/src/ui/components/Toast.tsx +54 -0
  264. package/src/ui/components/Tooltip.stories.tsx +26 -0
  265. package/src/ui/components/Tooltip.tsx +72 -0
  266. package/src/ui/components/menu-control.stories.tsx +35 -0
  267. package/src/ui/components/menu-control.tsx +43 -0
  268. package/src/ui/components/primitives/Button.stories.tsx +32 -0
  269. package/src/ui/components/primitives/Button.tsx +57 -0
  270. package/src/ui/components/primitives/Card.stories.tsx +25 -0
  271. package/src/ui/components/primitives/Card.tsx +62 -0
  272. package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
  273. package/src/ui/components/primitives/Dialog.test.tsx +38 -0
  274. package/src/ui/components/primitives/Dialog.tsx +69 -0
  275. package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
  276. package/src/ui/components/primitives/DialogActions.tsx +39 -0
  277. package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
  278. package/src/ui/components/primitives/IconButton.tsx +58 -0
  279. package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
  280. package/src/ui/components/primitives/StatusBar.tsx +23 -0
  281. package/src/ui/components/primitives/Switch.stories.tsx +17 -0
  282. package/src/ui/components/primitives/Switch.tsx +32 -0
  283. package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
  284. package/src/ui/components/primitives/Tabs.tsx +101 -0
  285. package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
  286. package/src/ui/components/primitives/Toolbar.tsx +14 -0
  287. package/src/ui/components/primitives/Tree.stories.tsx +124 -0
  288. package/src/ui/components/primitives/Tree.tsx +133 -0
  289. package/src/ui/components/primitives/frame.test.ts +12 -0
  290. package/src/ui/components/primitives/frame.ts +17 -0
  291. package/src/ui/components/primitives/icons.ts +61 -0
  292. package/src/ui/components/primitives/tree-model.test.ts +99 -0
  293. package/src/ui/components/primitives/tree-model.ts +137 -0
  294. package/src/ui/components/syntax-highlight.ts +133 -0
  295. package/src/ui/components/truncate-text.ts +7 -0
  296. package/src/ui/components/truncate-title.test.ts +31 -0
  297. package/src/ui/components/truncate-title.ts +15 -0
  298. package/src/ui/use-frame-measure.ts +74 -0
  299. package/src/ui/use-terminal-virtualizer.ts +259 -0
  300. package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
  301. package/src/workbench/components/ChangesColumn.tsx +203 -0
  302. package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
  303. package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
  304. package/src/workbench/components/EditorGrid.stories.tsx +86 -0
  305. package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
  306. package/src/workbench/components/EditorGrid.tsx +544 -0
  307. package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
  308. package/src/workbench/components/FileContentsView.stories.tsx +16 -0
  309. package/src/workbench/components/FileContentsView.test.tsx +29 -0
  310. package/src/workbench/components/FileContentsView.tsx +100 -0
  311. package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
  312. package/src/workbench/components/GridTabContent.stories.tsx +59 -0
  313. package/src/workbench/components/GridTabContent.tsx +193 -0
  314. package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
  315. package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
  316. package/src/workbench/components/ProjectTreeView.tsx +151 -0
  317. package/src/workbench/components/TreePane.stories.tsx +106 -0
  318. package/src/workbench/components/TreePane.tsx +155 -0
  319. package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
  320. package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
  321. package/src/workbench/components/WelcomePlayground.tsx +138 -0
  322. package/src/workbench/components/editor-grid.test.ts +184 -0
  323. package/src/workbench/components/editor-grid.ts +281 -0
  324. package/src/workbench/components/file-tree.test.ts +60 -0
  325. package/src/workbench/components/file-tree.ts +112 -0
  326. package/src/workbench/use-changes-workbench.ts +205 -0
  327. package/src/workbench/use-remember-layout.ts +31 -0
  328. package/src/workbench/use-repo-changes.ts +91 -0
  329. package/src/workbench/workbench-snapshot.test.ts +99 -0
  330. package/src/workbench/workbench-snapshot.ts +59 -0
  331. package/src/App.diff.test.tsx +0 -99
  332. package/src/App.export.test.tsx +0 -80
  333. package/src/App.readonly.test.tsx +0 -148
  334. package/src/App.test.tsx +0 -217
  335. package/src/App.tsx +0 -0
  336. package/src/config.test.ts +0 -116
  337. package/src/config.ts +0 -113
  338. package/src/editor.ts +0 -34
  339. package/src/run.ts +0 -28
  340. package/src/serve.ts +0 -75
  341. package/src/theme.ts +0 -46
  342. package/src/view-diff.test.ts +0 -55
  343. package/src/view-diff.ts +0 -86
  344. package/src/view.test.ts +0 -157
  345. package/src/view.ts +0 -0
@@ -0,0 +1,544 @@
1
+ // Renders the Changes pane's editor grid: recursive branches split into rows/columns, each leaf an
2
+ // editor group with its own tab strip and header controls. The caller supplies renderTab so the grid
3
+ // stays layout-only - a tab becomes a diff, a file's contents, or the aggregate Changes view upstream.
4
+
5
+ import React, { useEffect, useRef, useState } from "react";
6
+ import type { BoxRenderable } from "@opentui/core";
7
+ import { isDoubleClick, type ClickStamp } from "../../annotations/thread-selection";
8
+ import { DARK, type Theme } from "../../appearance/theme";
9
+ import { useFrameMeasure } from "../../ui/use-frame-measure";
10
+ import { useSharedKeyboard } from "../../keyboard/use-shared-keyboard";
11
+ import { useRootOverlay } from "../../ui/components/RootOverlay";
12
+ import { useMenuControl } from "../../ui/components/menu-control";
13
+ import { IconButton } from "../../ui/components/primitives/IconButton";
14
+ import { NERD, HEADER_UNDERLINE_CHARS } from "../../ui/components/primitives/icons";
15
+ import {
16
+ countEditorGroups,
17
+ MAX_EDITOR_GROUPS,
18
+ type EditorBranch,
19
+ type EditorGroup,
20
+ type EditorNode,
21
+ type EditorTab,
22
+ type SplitDirection,
23
+ } from "./editor-grid";
24
+
25
+ export interface EditorGridProps {
26
+ tree: EditorNode;
27
+ focusedGroupId: string | null;
28
+ onFocusGroup: (groupId: string) => void;
29
+ onActivateTab: (groupId: string, tabId: string) => void;
30
+ onKeepTab?: (groupId: string, tabId: string) => void;
31
+ onCloseTab: (groupId: string, tabId: string) => void;
32
+ onSplit: (groupId: string, direction: SplitDirection) => void;
33
+ onZoom: () => void;
34
+ zoomed: boolean;
35
+ renderTab: (tab: EditorTab, groupFocused: boolean) => React.ReactNode;
36
+ commentCounts?: ReadonlyMap<string, number>;
37
+ theme?: Theme;
38
+ }
39
+
40
+ const SPLIT_ITEMS: ReadonlyArray<{ label: string; direction: SplitDirection; arrow: string }> = [
41
+ { label: "left", direction: "left", arrow: "←" },
42
+ { label: "right", direction: "right", arrow: "→" },
43
+ { label: "up", direction: "up", arrow: "↑" },
44
+ { label: "down", direction: "down", arrow: "↓" },
45
+ ];
46
+
47
+ const MENU_WIDTH = 12;
48
+ const MIN_GROUP_WIDTH = 24;
49
+ const MIN_GROUP_HEIGHT = 8;
50
+
51
+ function canSplitGroup(width: number, height: number, direction: SplitDirection): boolean {
52
+ return direction === "left" || direction === "right"
53
+ ? width >= MIN_GROUP_WIDTH * 2 + 1
54
+ : height >= MIN_GROUP_HEIGHT * 2 + 1;
55
+ }
56
+
57
+ /** An arrow key maps straight to its split direction; other keys leave the menu untouched. */
58
+ function splitDirectionForKey(name: string): SplitDirection | null {
59
+ const item = SPLIT_ITEMS.find((entry) => entry.direction === name);
60
+
61
+ return item ? item.direction : null;
62
+ }
63
+
64
+ function EditorTabButton({
65
+ tab,
66
+ active,
67
+ commentCount,
68
+ onSelect,
69
+ onKeep,
70
+ onClose,
71
+ tokens,
72
+ }: {
73
+ tab: EditorTab;
74
+ active: boolean;
75
+ /** Comments on this tab's file, shown as a dot-and-count badge; 0 shows nothing. */
76
+ commentCount: number;
77
+ onSelect: () => void;
78
+ onKeep?: () => void;
79
+ onClose: () => void;
80
+ tokens: Theme;
81
+ }): React.ReactNode {
82
+ const [hovered, setHovered] = useState(false);
83
+ const lastClick = useRef<ClickStamp | null>(null);
84
+
85
+ return (
86
+ <box
87
+ id={tab.id}
88
+ onMouseUp={(event) => {
89
+ const stamp = { time: Date.now(), x: event.x, y: event.y };
90
+ const doubleClick = isDoubleClick(lastClick.current, stamp);
91
+
92
+ lastClick.current = stamp;
93
+ onSelect();
94
+ if (doubleClick && tab.preview) onKeep?.();
95
+ }}
96
+ onMouseOver={() => setHovered(true)}
97
+ onMouseOut={() => setHovered(false)}
98
+ style={{
99
+ flexDirection: "row",
100
+ // a tab keeps the width of its name; the strip scrolls when the row overflows
101
+ flexShrink: 0,
102
+ paddingLeft: 1,
103
+ paddingRight: 1,
104
+ backgroundColor: active ? tokens.background : undefined,
105
+ borderStyle: "single",
106
+ border: ["right"],
107
+ borderColor: tokens.border,
108
+ }}
109
+ >
110
+ <text fg={active ? tokens.textMuted : tokens.textDim} style={{ wrapMode: "none" }}>
111
+ {tab.label}
112
+ </text>
113
+ {commentCount > 0 ? (
114
+ <text
115
+ fg={tokens.textDim}
116
+ style={{ flexShrink: 0, wrapMode: "none" }}
117
+ >{` ● ${commentCount}`}</text>
118
+ ) : null}
119
+ <box
120
+ onMouseUp={(event) => {
121
+ event.stopPropagation();
122
+ onClose();
123
+ }}
124
+ style={{ paddingLeft: 1 }}
125
+ >
126
+ <text fg={tokens.textDim}>{hovered ? NERD.close : " "}</text>
127
+ </box>
128
+ </box>
129
+ );
130
+ }
131
+
132
+ /** Cells a tab occupies: padding, label, the ` ● N` comment badge when present, the close cell, the right rule. */
133
+ export function tabCellWidth(tab: EditorTab, commentCount = 0): number {
134
+ const badge = commentCount > 0 ? 3 + String(commentCount).length : 0;
135
+
136
+ return tab.label.length + 5 + badge;
137
+ }
138
+
139
+ /** The run of tabs `[first, end)` the strip shows. */
140
+ export interface TabWindow {
141
+ first: number;
142
+ end: number;
143
+ }
144
+
145
+ /** The run of tabs that fits the strip and contains the active tab; markers take a cell each side. */
146
+ export function visibleTabWindow(
147
+ tabs: EditorTab[],
148
+ firstVisible: number,
149
+ activeIndex: number,
150
+ stripWidth: number,
151
+ commentCount: (tab: EditorTab) => number = () => 0,
152
+ ): TabWindow {
153
+ // unmeasured strip: show everything, the header clips until the width is known
154
+ if (stripWidth <= 0) return { first: 0, end: tabs.length };
155
+ const width = (index: number): number => tabCellWidth(tabs[index]!, commentCount(tabs[index]!));
156
+ const fitEnd = (first: number): number => {
157
+ let used = first > 0 ? 1 : 0;
158
+ let end = first;
159
+
160
+ while (end < tabs.length) {
161
+ const remaining = end + 1 < tabs.length ? 1 : 0;
162
+
163
+ if (used + width(end) + remaining > stripWidth) break;
164
+ used += width(end);
165
+ end++;
166
+ }
167
+
168
+ // always show at least the tab the window starts on, even when it is wider than the strip
169
+ return Math.max(end, Math.min(first + 1, tabs.length));
170
+ };
171
+ let first = Math.min(Math.max(0, firstVisible), Math.max(0, activeIndex));
172
+ let end = fitEnd(first);
173
+
174
+ // the active tab past the right edge pulls the window along until it fits
175
+ while (activeIndex >= end && first < activeIndex) {
176
+ first++;
177
+ end = fitEnd(first);
178
+ }
179
+
180
+ return { first, end };
181
+ }
182
+
183
+ function SplitMenu({
184
+ onPick,
185
+ canPick,
186
+ tokens,
187
+ }: {
188
+ onPick: (direction: SplitDirection) => void;
189
+ canPick: (direction: SplitDirection) => boolean;
190
+ tokens: Theme;
191
+ }): React.ReactNode {
192
+ return (
193
+ <box
194
+ onMouseUp={(event) => event.stopPropagation()}
195
+ style={{
196
+ width: MENU_WIDTH,
197
+ flexDirection: "column",
198
+ borderStyle: "single",
199
+ borderColor: tokens.border,
200
+ backgroundColor: tokens.elevated,
201
+ }}
202
+ >
203
+ {SPLIT_ITEMS.map((item) => (
204
+ <box
205
+ key={item.direction}
206
+ onMouseUp={canPick(item.direction) ? () => onPick(item.direction) : undefined}
207
+ style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1 }}
208
+ >
209
+ <text fg={canPick(item.direction) ? tokens.text : tokens.textDim}>{item.label}</text>
210
+ <box style={{ flexGrow: 1 }} />
211
+ <text fg={tokens.textDim}>{item.arrow}</text>
212
+ </box>
213
+ ))}
214
+ </box>
215
+ );
216
+ }
217
+
218
+ function EditorGroupPane({
219
+ group,
220
+ props,
221
+ showZoom,
222
+ splitDisabled,
223
+ }: {
224
+ group: EditorGroup;
225
+ props: EditorGridProps;
226
+ showZoom: boolean;
227
+ splitDisabled: boolean;
228
+ }): React.ReactNode {
229
+ const tokens = props.theme ?? DARK;
230
+ const menuControl = useMenuControl();
231
+ const { setOverlay, clearOverlay } = useRootOverlay();
232
+ const menuId = `split:${group.id}`;
233
+ const menuOpen = menuControl.openMenuId === menuId;
234
+ const groupRef = useRef<BoxRenderable | null>(null);
235
+ const groupSize = useFrameMeasure(
236
+ () => ({ width: groupRef.current?.width ?? 0, height: groupRef.current?.height ?? 0 }),
237
+ (left, right) => left.width === right.width && left.height === right.height,
238
+ { width: 0, height: 0 },
239
+ );
240
+ const canSplit = (direction: SplitDirection): boolean =>
241
+ !splitDisabled && canSplitGroup(groupSize.width, groupSize.height, direction);
242
+ const cannotSplit = !SPLIT_ITEMS.some((item) => canSplit(item.direction));
243
+ const splitRef = useRef<BoxRenderable | null>(null);
244
+ const anchor = useFrameMeasure(
245
+ () => ({
246
+ x: splitRef.current?.x ?? 0,
247
+ y: splitRef.current?.y ?? 0,
248
+ width: splitRef.current?.width ?? 0,
249
+ }),
250
+ (left, right) => left.x === right.x && left.y === right.y && left.width === right.width,
251
+ { x: 0, y: 0, width: 0 },
252
+ menuOpen,
253
+ );
254
+ const active = group.tabs.find((tab) => tab.id === group.activeTabId) ?? group.tabs[0];
255
+
256
+ useSharedKeyboard((key) => {
257
+ if (!menuOpen) return;
258
+ if (key.name === "escape") return menuControl.closeMenu();
259
+ const direction = splitDirectionForKey(key.name);
260
+
261
+ if (direction && canSplit(direction)) {
262
+ props.onSplit(group.id, direction);
263
+ menuControl.closeMenu();
264
+ }
265
+ });
266
+
267
+ useEffect(() => {
268
+ if (!menuOpen) return;
269
+ setOverlay(
270
+ "split-menu",
271
+ <box
272
+ onMouseUp={menuControl.closeMenu}
273
+ style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%" }}
274
+ >
275
+ <box
276
+ style={{
277
+ position: "absolute",
278
+ top: anchor.y + 1,
279
+ left: Math.max(0, anchor.x + anchor.width - MENU_WIDTH),
280
+ }}
281
+ >
282
+ <SplitMenu
283
+ onPick={(direction) => {
284
+ if (!canSplit(direction)) return;
285
+ props.onSplit(group.id, direction);
286
+ menuControl.closeMenu();
287
+ }}
288
+ canPick={canSplit}
289
+ tokens={tokens}
290
+ />
291
+ </box>
292
+ </box>,
293
+ );
294
+
295
+ return () => clearOverlay("split-menu");
296
+ // eslint-disable-next-line react-hooks/exhaustive-deps
297
+ }, [menuOpen, anchor, tokens, groupSize.width, groupSize.height, splitDisabled]);
298
+ // The strip windows its tabs rather than scrolling a nested scrollbox (which stalls the
299
+ // renderer inside this header): tabs keep the width of their names, only the run that fits
300
+ // renders, and the window follows the active tab so a tab opened past the edge is never hidden.
301
+ const stripRef = useRef<BoxRenderable | null>(null);
302
+ const stripWidth = useFrameMeasure(
303
+ () => stripRef.current?.width ?? 0,
304
+ (left, right) => left === right,
305
+ 0,
306
+ );
307
+ const [firstVisible, setFirstVisible] = useState(0);
308
+ // a zoom re-anchors the strip to the leftmost tab, so the new width fills from the left
309
+ // and the step markers disappear when every tab now fits
310
+ const [wasZoomed, setWasZoomed] = useState(props.zoomed);
311
+
312
+ if (wasZoomed !== props.zoomed) {
313
+ setWasZoomed(props.zoomed);
314
+ setFirstVisible(0);
315
+ }
316
+ const activeIndex = Math.max(
317
+ 0,
318
+ group.tabs.findIndex((tab) => tab.id === active?.id),
319
+ );
320
+ // derived every render: the manual offset is a floor the active tab can pull the window past
321
+ const tabWindow = visibleTabWindow(group.tabs, firstVisible, activeIndex, stripWidth, (tab) =>
322
+ tab.path ? (props.commentCounts?.get(tab.path) ?? 0) : 0,
323
+ );
324
+
325
+ return (
326
+ <box
327
+ id={`editor-group:${group.id}`}
328
+ ref={groupRef}
329
+ onMouseDown={() => props.onFocusGroup(group.id)}
330
+ style={{ flexDirection: "column", flexGrow: 1, flexBasis: 0, minWidth: 0, minHeight: 0 }}
331
+ >
332
+ <box
333
+ customBorderChars={HEADER_UNDERLINE_CHARS}
334
+ style={{
335
+ flexDirection: "row",
336
+ // pin the controls to the right edge so their column never shifts with the container
337
+ // width parity; a lone flexGrow tab strip would drag them a cell on zoom
338
+ justifyContent: "space-between",
339
+ height: 2,
340
+ // match the pane headers: the panel token is transparent in the branded theme, so the
341
+ // header reads as terminal-through rather than a raised box
342
+ backgroundColor: tokens.panel,
343
+ borderStyle: "single",
344
+ border: ["bottom"],
345
+ borderColor: tokens.border,
346
+ }}
347
+ >
348
+ {/* the tab strip windows its tabs: the run that fits renders in full, edge markers step
349
+ the window, and the controls stay pinned on the right */}
350
+ <box
351
+ ref={stripRef}
352
+ style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, flexBasis: 0, minWidth: 0 }}
353
+ >
354
+ {tabWindow.first > 0 ? (
355
+ <box
356
+ onMouseUp={(event) => {
357
+ event.stopPropagation();
358
+ setFirstVisible(tabWindow.first - 1);
359
+ }}
360
+ style={{ flexShrink: 0 }}
361
+ >
362
+ <text fg={tokens.textDim} selectable={false}>
363
+ {"‹"}
364
+ </text>
365
+ </box>
366
+ ) : null}
367
+ {group.tabs.slice(tabWindow.first, tabWindow.end).map((tab) => (
368
+ <EditorTabButton
369
+ key={tab.id}
370
+ tab={tab}
371
+ active={tab.id === active?.id}
372
+ commentCount={tab.path ? (props.commentCounts?.get(tab.path) ?? 0) : 0}
373
+ onSelect={() => props.onActivateTab(group.id, tab.id)}
374
+ onKeep={() => props.onKeepTab?.(group.id, tab.id)}
375
+ onClose={() => props.onCloseTab(group.id, tab.id)}
376
+ tokens={tokens}
377
+ />
378
+ ))}
379
+ {tabWindow.end < group.tabs.length ? (
380
+ <box
381
+ onMouseUp={(event) => {
382
+ event.stopPropagation();
383
+ setFirstVisible(tabWindow.first + 1);
384
+ }}
385
+ style={{ flexShrink: 0 }}
386
+ >
387
+ <text fg={tokens.textDim} selectable={false}>
388
+ {"›"}
389
+ </text>
390
+ </box>
391
+ ) : null}
392
+ </box>
393
+ <box style={{ flexDirection: "row", flexShrink: 0, paddingLeft: 1, paddingRight: 1 }}>
394
+ {/* search sits out until it does something; split takes its place */}
395
+ <box
396
+ id={`split-button:${group.id}`}
397
+ ref={splitRef}
398
+ style={{ flexShrink: 0, marginRight: showZoom ? 2 : 0 }}
399
+ >
400
+ <IconButton
401
+ glyph="split"
402
+ active={menuOpen}
403
+ disabled={cannotSplit}
404
+ onPress={() => menuControl.toggleMenu(menuId)}
405
+ tip={
406
+ splitDisabled
407
+ ? "Maximum 8 editor groups"
408
+ : cannotSplit
409
+ ? "Tile too small to split"
410
+ : undefined
411
+ }
412
+ theme={tokens}
413
+ />
414
+ </box>
415
+ {showZoom ? (
416
+ <IconButton
417
+ glyph={NERD.zoom}
418
+ active={props.zoomed}
419
+ onPress={props.onZoom}
420
+ tip={props.zoomed ? "Zoom Out" : "Zoom In"}
421
+ theme={tokens}
422
+ />
423
+ ) : null}
424
+ </box>
425
+ </box>
426
+ <box
427
+ style={{ flexGrow: 1, flexBasis: 0, minHeight: 0, minWidth: 0, flexDirection: "column" }}
428
+ >
429
+ {active ? props.renderTab(active, props.focusedGroupId === group.id) : null}
430
+ </box>
431
+ </box>
432
+ );
433
+ }
434
+
435
+ function GridNode({
436
+ node,
437
+ props,
438
+ showZoom,
439
+ splitDisabled,
440
+ }: {
441
+ node: EditorNode;
442
+ props: EditorGridProps;
443
+ showZoom: boolean;
444
+ splitDisabled: boolean;
445
+ }): React.ReactNode {
446
+ if (node.type === "group") {
447
+ return (
448
+ <EditorGroupPane
449
+ group={node}
450
+ props={props}
451
+ showZoom={showZoom}
452
+ splitDisabled={splitDisabled}
453
+ />
454
+ );
455
+ }
456
+
457
+ return (
458
+ <GridBranchPane node={node} props={props} showZoom={showZoom} splitDisabled={splitDisabled} />
459
+ );
460
+ }
461
+
462
+ function GridBranchPane({
463
+ node,
464
+ props,
465
+ showZoom,
466
+ splitDisabled,
467
+ }: {
468
+ node: EditorBranch;
469
+ props: EditorGridProps;
470
+ showZoom: boolean;
471
+ splitDisabled: boolean;
472
+ }): React.ReactNode {
473
+ const tokens = props.theme ?? DARK;
474
+ const horizontal = node.orientation === "horizontal";
475
+ const branchRef = useRef<BoxRenderable | null>(null);
476
+ const branchWidth = useFrameMeasure(
477
+ () => branchRef.current?.width ?? 0,
478
+ (left, right) => left === right,
479
+ 0,
480
+ );
481
+ const leadingWidth = Math.floor((branchWidth - node.children.length + 1) / node.children.length);
482
+
483
+ return (
484
+ <box
485
+ ref={branchRef}
486
+ style={{
487
+ flexDirection: horizontal ? "row" : "column",
488
+ flexGrow: 1,
489
+ flexBasis: 0,
490
+ minWidth: 0,
491
+ minHeight: 0,
492
+ }}
493
+ >
494
+ {node.children.map((child, index) => {
495
+ const fixedWidth = horizontal && index < node.children.length - 1 && leadingWidth > 0;
496
+
497
+ return (
498
+ <React.Fragment key={child.id}>
499
+ {index > 0 ? (
500
+ <box
501
+ style={{
502
+ borderStyle: "single",
503
+ border: [horizontal ? "left" : "top"],
504
+ borderColor: tokens.border,
505
+ }}
506
+ />
507
+ ) : null}
508
+ <box
509
+ style={{
510
+ flexDirection: "column",
511
+ // A fixed whole-cell share for leading horizontal groups leaves the last group
512
+ // the exact remainder. Equal fractional flex shares can overflow the right edge.
513
+ width: fixedWidth ? leadingWidth : undefined,
514
+ flexGrow: fixedWidth ? 0 : 1,
515
+ flexBasis: fixedWidth ? undefined : 0,
516
+ flexShrink: fixedWidth ? 0 : 1,
517
+ minWidth: 0,
518
+ minHeight: 0,
519
+ }}
520
+ >
521
+ <GridNode
522
+ node={child}
523
+ props={props}
524
+ showZoom={showZoom && index === (horizontal ? node.children.length - 1 : 0)}
525
+ splitDisabled={splitDisabled}
526
+ />
527
+ </box>
528
+ </React.Fragment>
529
+ );
530
+ })}
531
+ </box>
532
+ );
533
+ }
534
+
535
+ /** The Changes pane body: the editor grid of groups and splits, each rendering its active tab. */
536
+ export function EditorGrid(props: EditorGridProps): React.ReactNode {
537
+ const splitDisabled = countEditorGroups(props.tree) >= MAX_EDITOR_GROUPS;
538
+
539
+ return (
540
+ <box style={{ flexDirection: "column", flexGrow: 1, minWidth: 0, minHeight: 0 }}>
541
+ <GridNode node={props.tree} props={props} showZoom splitDisabled={splitDisabled} />
542
+ </box>
543
+ );
544
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Regression: the header controls (search, zoom, split) stay pinned to the right
3
+ * edge whatever the container width parity. A lone flexGrow tab strip used to
4
+ * underfill by a cell at even widths, dragging the zoom icon a column left when
5
+ * zoom widened the pane; space-between pins the controls so the column is stable.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { testRender } from "@opentui/react/test-utils";
10
+ import React from "react";
11
+ import { EditorGrid } from "./EditorGrid";
12
+ import { changesTab, fileTab, makeGroup, splitGroup } from "./editor-grid";
13
+ import { NERD } from "../../ui/components/primitives/icons";
14
+ import { allowEventLoopUpdates } from "../../testing/test-support";
15
+
16
+ const grid = makeGroup([changesTab(), fileTab("App.tsx", "src/App.tsx", "diff")]);
17
+
18
+ async function zoomIconInsetFromRight(width: number, zoomed: boolean): Promise<number> {
19
+ const setup = await testRender(
20
+ <EditorGrid
21
+ tree={grid}
22
+ focusedGroupId={grid.id}
23
+ onFocusGroup={() => {}}
24
+ onActivateTab={() => {}}
25
+ onCloseTab={() => {}}
26
+ onSplit={() => {}}
27
+ onZoom={() => {}}
28
+ zoomed={zoomed}
29
+ renderTab={() => <text>body</text>}
30
+ />,
31
+ { width, height: 6 },
32
+ );
33
+
34
+ allowEventLoopUpdates();
35
+ await setup.waitForVisualIdle();
36
+ const line = setup
37
+ .captureCharFrame()
38
+ .split("\n")
39
+ .find((row) => row.includes("⛶"));
40
+ const inset = line ? width - line.indexOf("⛶") : -1;
41
+
42
+ setup.renderer.destroy();
43
+
44
+ return inset;
45
+ }
46
+
47
+ describe("zoom icon position", () => {
48
+ test("stays a fixed inset from the right edge across width parities", async () => {
49
+ // Act - even and odd widths flip the flexGrow rounding the old layout was sensitive to
50
+ const insets = await Promise.all([
51
+ zoomIconInsetFromRight(100, false),
52
+ zoomIconInsetFromRight(101, false),
53
+ zoomIconInsetFromRight(102, false),
54
+ ]);
55
+
56
+ // Assert - one inset for all widths: the icon is pinned right, not drifting with parity
57
+ expect(new Set(insets).size).toBe(1);
58
+ expect(insets[0]).toBeGreaterThan(0);
59
+ });
60
+
61
+ test("does not shift when zoom becomes active", async () => {
62
+ // Act
63
+ const inactive = await zoomIconInsetFromRight(100, false);
64
+ const active = await zoomIconInsetFromRight(100, true);
65
+
66
+ // Assert - active only recolours the glyph; it must not move
67
+ expect(active).toBe(inactive);
68
+ });
69
+ });
70
+
71
+ test("split stays in every header while zoom appears only in the upper-right group", async () => {
72
+ const first = makeGroup([changesTab()]);
73
+ const right = splitGroup(first, first.id, "right");
74
+ const upperRight = splitGroup(right.tree, right.focusGroupId, "up");
75
+ const setup = await testRender(
76
+ <EditorGrid
77
+ tree={upperRight.tree}
78
+ focusedGroupId={upperRight.focusGroupId}
79
+ onFocusGroup={() => {}}
80
+ onActivateTab={() => {}}
81
+ onCloseTab={() => {}}
82
+ onSplit={() => {}}
83
+ onZoom={() => {}}
84
+ zoomed={false}
85
+ renderTab={() => <text>body</text>}
86
+ />,
87
+ { width: 100, height: 16 },
88
+ );
89
+
90
+ allowEventLoopUpdates();
91
+ await setup.waitForVisualIdle();
92
+ const rows = setup.captureCharFrame().split("\n");
93
+ const headers = rows.filter((row) => row.includes("split"));
94
+
95
+ expect([...rows.join("\n").matchAll(/split/g)]).toHaveLength(3);
96
+ expect(headers[0]).toContain(NERD.zoom);
97
+ expect(headers[1]).not.toContain(NERD.zoom);
98
+ expect(rows.filter((row) => row.includes(NERD.zoom))).toHaveLength(1);
99
+ setup.renderer.destroy();
100
+ });
101
+
102
+ test("zoom keeps a full cell at the right edge of a narrow split", async () => {
103
+ const first = makeGroup([changesTab()]);
104
+ const split = splitGroup(first, first.id, "right");
105
+
106
+ for (const width of [50, 51, 100]) {
107
+ for (const zoomed of [false, true]) {
108
+ const setup = await testRender(
109
+ <EditorGrid
110
+ tree={split.tree}
111
+ focusedGroupId={split.focusGroupId}
112
+ onFocusGroup={() => {}}
113
+ onActivateTab={() => {}}
114
+ onCloseTab={() => {}}
115
+ onSplit={() => {}}
116
+ onZoom={() => {}}
117
+ zoomed={zoomed}
118
+ renderTab={() => <text>body</text>}
119
+ />,
120
+ { width, height: 8 },
121
+ );
122
+
123
+ allowEventLoopUpdates();
124
+ await setup.waitForVisualIdle();
125
+ const header = setup.captureCharFrame().split("\n")[0]!;
126
+
127
+ expect(header.match(/⛶/g)).toHaveLength(1);
128
+ expect(width - header.indexOf(NERD.zoom)).toBe(2);
129
+ setup.renderer.destroy();
130
+ }
131
+ }
132
+ });
@@ -0,0 +1,16 @@
1
+ import React from "react";
2
+ import { DARK } from "../../appearance/theme";
3
+ import type { Story, StoryMeta } from "../../stories/story";
4
+ import { FileContentsView } from "./FileContentsView";
5
+
6
+ export const meta: StoryMeta = { title: "Surfaces/FileContentsView" };
7
+
8
+ const SAMPLE = `export function add(a: number, b: number): number {\n return a + b;\n}\n`;
9
+
10
+ export const Loaded: Story = {
11
+ render: () => (
12
+ <FileContentsView path="src/add.ts" loadContents={() => Promise.resolve(SAMPLE)} theme={DARK} />
13
+ ),
14
+ expectedColors: [DARK.textDim],
15
+ size: { width: 60, height: 12 },
16
+ };