@kabmo-labs/kabmo 0.7.0 → 0.9.0

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 (351) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +403 -384
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +10 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/header-controls/index.tsx +24 -1
  24. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  25. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  26. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  27. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  28. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  29. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  30. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  31. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  32. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  33. package/src/client/components/layout/desktop-layout/index.tsx +55 -69
  34. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  35. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  36. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  37. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  38. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  40. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  41. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  42. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  43. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  44. package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
  45. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  46. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  47. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  48. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  49. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  50. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  51. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  52. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  53. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  54. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  55. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  56. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  57. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  58. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  59. package/src/client/components/mobile/worktree-session.ts +51 -0
  60. package/src/client/components/settings/Modal.tsx +8 -0
  61. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  62. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  63. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  64. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  65. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
  66. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  67. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  68. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
  69. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  70. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  71. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  72. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  73. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  74. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  75. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  76. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  77. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  78. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  79. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  80. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  81. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  82. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  83. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
  84. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  85. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  86. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  87. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  88. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  89. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  90. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  91. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  92. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  93. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  94. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  95. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  96. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  97. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  98. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  99. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  100. package/src/client/components/workspace/editor/Body.tsx +4 -7
  101. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  102. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  103. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  104. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  105. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  106. package/src/client/components/workspace/editor/index.tsx +7 -9
  107. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  108. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  109. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  110. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  111. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  112. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  113. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  114. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  115. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  116. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  117. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  118. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  119. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  120. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  121. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  122. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  123. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  124. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  125. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  126. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  127. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  128. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  129. package/src/client/hooks/chat/timeline/index.ts +27 -28
  130. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  131. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  132. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  133. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  134. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  135. package/src/client/hooks/chat/timeline/send.ts +11 -1
  136. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  137. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  138. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  139. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  140. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  141. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  142. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  143. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  144. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  145. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  146. package/src/client/hooks/editor/find-state.ts +1 -1
  147. package/src/client/hooks/editor/theme.test.ts +206 -0
  148. package/src/client/hooks/editor/theme.ts +82 -0
  149. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  150. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  151. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  152. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  153. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  154. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  155. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  156. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  157. package/src/client/hooks/ui/long-press.test.ts +174 -0
  158. package/src/client/hooks/ui/long-press.ts +112 -0
  159. package/src/client/hooks/ui/settings-modal.ts +56 -0
  160. package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
  161. package/src/client/hooks/ui/updates/shell-download.ts +153 -28
  162. package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
  163. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  164. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  165. package/src/client/hooks/workspace/diff-file.ts +29 -10
  166. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  167. package/src/client/hooks/workspace/editor-view.ts +61 -0
  168. package/src/client/hooks/workspace/file-create.ts +41 -11
  169. package/src/client/hooks/workspace/file-listing.ts +56 -1
  170. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  171. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  172. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  173. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  174. package/src/client/hooks/workspace/worktree/index.ts +165 -0
  175. package/src/client/lib/desktop/bridge.ts +45 -0
  176. package/src/client/lib/search-reveal.ts +1 -1
  177. package/src/client/main.tsx +5 -1
  178. package/src/client/tailwind.css +56 -8
  179. package/src/server/index.ts +7 -2
  180. package/src/server/lib/auth/guard.test.ts +28 -0
  181. package/src/server/lib/auth/guard.ts +10 -0
  182. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  183. package/src/server/lib/db/schema.ts +4 -0
  184. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  185. package/src/server/lib/fs/canonical.ts +52 -0
  186. package/src/server/lib/fs/relative-path.ts +48 -0
  187. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  188. package/src/server/lib/fs/root.ts +39 -10
  189. package/src/server/lib/fs/worktree/carry.ts +134 -0
  190. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  191. package/src/server/lib/fs/worktree/create.ts +262 -0
  192. package/src/server/lib/fs/worktree/errors.ts +43 -0
  193. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  194. package/src/server/lib/fs/worktree/list.ts +142 -0
  195. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  196. package/src/server/lib/fs/worktree/location.ts +114 -0
  197. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  198. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  199. package/src/server/lib/fs/worktree/remove.ts +152 -0
  200. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  201. package/src/server/lib/fs/worktree/topology.ts +142 -0
  202. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  203. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  204. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  205. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  206. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  207. package/src/server/lib/lifecycle/probe.ts +121 -13
  208. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  209. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  210. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  211. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  212. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  213. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  214. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  215. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  216. package/src/server/lib/panels/local-edits.server.ts +33 -0
  217. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  218. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  219. package/src/server/lib/panels/sync.server.test.ts +125 -147
  220. package/src/server/lib/panels/sync.server.ts +153 -48
  221. package/src/server/lib/realtime/signals.server.ts +10 -0
  222. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  223. package/src/server/lib/realtime/topics.server.ts +16 -0
  224. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  225. package/src/server/lib/updates/shell-assets.ts +200 -0
  226. package/src/server/lib/updates/shell-cache.ts +105 -0
  227. package/src/server/lib/updates/shell-pages.test.ts +187 -0
  228. package/src/server/lib/updates/shell-pages.ts +157 -0
  229. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  230. package/src/server/lib/updates/shell-release.test.ts +59 -71
  231. package/src/server/lib/updates/shell-release.ts +178 -198
  232. package/src/server/routes/fs/action-create.test.ts +18 -13
  233. package/src/server/routes/fs/action-nested.test.ts +184 -0
  234. package/src/server/routes/fs/action.ts +47 -16
  235. package/src/server/routes/fs/git-merge.test.ts +11 -1
  236. package/src/server/routes/fs/read.test.ts +24 -7
  237. package/src/server/routes/fs/scope.test.ts +15 -10
  238. package/src/server/routes/fs/scope.ts +3 -14
  239. package/src/server/routes/index.ts +2 -0
  240. package/src/server/routes/updates/android.ts +6 -1
  241. package/src/server/routes/updates/desktop.ts +59 -0
  242. package/src/server/routes/updates/index.ts +2 -0
  243. package/src/server/routes/updates/ios.ts +5 -1
  244. package/src/server/routes/worktrees/create.ts +138 -0
  245. package/src/server/routes/worktrees/index.ts +25 -0
  246. package/src/server/routes/worktrees/list.ts +134 -0
  247. package/src/server/routes/worktrees/remove.ts +102 -0
  248. package/src/server/routes/worktrees/routes.test.ts +326 -0
  249. package/src/server/routes/worktrees/shared.ts +90 -0
  250. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  251. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  252. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  253. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  254. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  255. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  256. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  257. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  258. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  259. package/src/shared/lib/code/editor/keymap.ts +3 -3
  260. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  261. package/src/shared/lib/code/editor/lines.ts +8 -48
  262. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  263. package/src/shared/lib/code/editor/marks.ts +13 -25
  264. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  265. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  266. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  267. package/src/shared/lib/code/editor/themes.ts +165 -0
  268. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  269. package/src/shared/lib/code/highlight-memo.ts +149 -0
  270. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  271. package/src/shared/lib/code/highlighter.ts +3 -1
  272. package/src/shared/lib/code/idle.ts +37 -0
  273. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  274. package/src/shared/lib/code/outline/rows.ts +94 -0
  275. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  276. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  277. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  278. package/src/shared/lib/fs/file-kind.ts +47 -13
  279. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  280. package/src/shared/lib/fs/file-tree.ts +159 -0
  281. package/src/shared/lib/fs/file-url.test.ts +52 -0
  282. package/src/shared/lib/fs/file-url.ts +37 -0
  283. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  284. package/src/shared/lib/fs/patch-sides.ts +139 -0
  285. package/src/shared/lib/fs/paths.test.ts +24 -1
  286. package/src/shared/lib/fs/paths.ts +17 -0
  287. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  288. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  289. package/src/shared/lib/realtime/protocol.ts +16 -0
  290. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  291. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  292. package/src/shared/lib/updates/shell-arch.ts +54 -0
  293. package/src/shared/lib/updates/shell-ua.ts +24 -4
  294. package/src/shared/lib/workspace/active-project.ts +23 -0
  295. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  296. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  297. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  298. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  299. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  300. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  301. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  302. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  303. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  304. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  305. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  306. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  307. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  308. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  309. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  310. package/src/shared/types/fs.ts +10 -0
  311. package/src/shared/types/realtime.ts +29 -0
  312. package/src/shared/types/settings/state.ts +10 -0
  313. package/src/shared/types/workspace.ts +14 -0
  314. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  315. package/src/test-support/file-explorer.test-util.ts +71 -40
  316. package/src/test-support/loopback.ts +183 -0
  317. package/dist/client/static/js/index-nzyjf92m.css +0 -1
  318. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  319. package/src/client/components/common/code-editor/handler.ts +0 -175
  320. package/src/client/components/common/code-editor/index.tsx +0 -314
  321. package/src/client/components/common/code-surface/measure.ts +0 -148
  322. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  323. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  324. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  325. package/src/client/hooks/editor/scroll-container.ts +0 -26
  326. package/src/client/hooks/editor/use-code-window.ts +0 -100
  327. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  328. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  329. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  330. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  331. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  332. package/src/shared/lib/code/editor/commands.ts +0 -260
  333. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  334. package/src/shared/lib/code/editor/edits.ts +0 -122
  335. package/src/shared/lib/code/editor/history.test.ts +0 -118
  336. package/src/shared/lib/code/editor/history.ts +0 -101
  337. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  338. package/src/shared/lib/code/editor/layers.ts +0 -67
  339. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  340. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  341. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  342. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  343. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  344. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  345. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  346. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  347. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  348. package/src/shared/lib/code/lazy-window.ts +0 -156
  349. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  350. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  351. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,182 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The deferred-highlight hook, and the idle scheduler under it.
8
+ *
9
+ * The property that matters is the one the user feels: the FIRST render after a
10
+ * tap must not run the tokenizer. A card that appeared only after 220–300 ms of
11
+ * grammar work (measured at 4× CPU; 730–1484 ms at 20×) is the delay this
12
+ * exists to remove, and a hook that highlighted synchronously "sometimes" would
13
+ * pass a markup assertion and fail the point.
14
+ *
15
+ * So the scheduler is injectable here — the same seam the warm-up and the
16
+ * grammar loader use — and the assertions are about WHEN the compute ran, not
17
+ * only about what it produced. No wall-clock waits: the fallback path is driven
18
+ * by fake timers.
19
+ */
20
+
21
+ import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'bun:test';
22
+ import { Window } from 'happy-dom';
23
+ import { h, render } from 'preact';
24
+ import { act } from 'preact/test-utils';
25
+
26
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
27
+ import { scheduleIdle } from '@/shared/lib/code/idle';
28
+ import { clearHighlightMemo, highlightMemoSize } from '@/shared/lib/code/highlight-memo';
29
+ import { resetTokenizerWarmup, warmSyntaxTokenizers } from '@/shared/lib/code/highlighter-warmup';
30
+
31
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'MouseEvent'] as const;
32
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
33
+ let container: HTMLElement;
34
+
35
+ beforeAll(() => {
36
+ const win = new Window({ url: 'http://localhost' });
37
+ const target = globalThis as unknown as Record<string, unknown>;
38
+ for (const key of DOM_GLOBALS) {
39
+ nativeGlobals[key] = target[key];
40
+ target[key] = (win as unknown as Record<string, unknown>)[key];
41
+ }
42
+ container = document.createElement('div');
43
+ document.body.appendChild(container);
44
+ });
45
+
46
+ afterAll(() => {
47
+ const target = globalThis as unknown as Record<string, unknown>;
48
+ for (const key of DOM_GLOBALS) {
49
+ if (nativeGlobals[key] === undefined) delete target[key];
50
+ else target[key] = nativeGlobals[key];
51
+ }
52
+ });
53
+
54
+ afterEach(() => {
55
+ clearHighlightMemo();
56
+ resetTokenizerWarmup();
57
+ });
58
+
59
+ describe('scheduleIdle', () => {
60
+ test('a runtime without requestIdleCallback still runs the work', () => {
61
+ const host = globalThis as unknown as Record<string, unknown>;
62
+ const saved = host.requestIdleCallback;
63
+ delete host.requestIdleCallback;
64
+ vi.useFakeTimers();
65
+ try {
66
+ let runs = 0;
67
+ scheduleIdle(() => { runs += 1; });
68
+ vi.advanceTimersByTime(1);
69
+ expect(runs).toBe(1);
70
+ } finally {
71
+ vi.useRealTimers();
72
+ if (saved !== undefined) host.requestIdleCallback = saved;
73
+ }
74
+ });
75
+
76
+ test('cancelling before the slice fires skips the work', () => {
77
+ const host = globalThis as unknown as Record<string, unknown>;
78
+ const saved = host.requestIdleCallback;
79
+ delete host.requestIdleCallback;
80
+ vi.useFakeTimers();
81
+ try {
82
+ let runs = 0;
83
+ const cancel = scheduleIdle(() => { runs += 1; });
84
+ cancel();
85
+ vi.advanceTimersByTime(1);
86
+ expect(runs).toBe(0);
87
+ } finally {
88
+ vi.useRealTimers();
89
+ if (saved !== undefined) host.requestIdleCallback = saved;
90
+ }
91
+ });
92
+ });
93
+
94
+ describe('warmSyntaxTokenizers', () => {
95
+ test('tokenizes one sample per language, one slice at a time', () => {
96
+ const calls: Array<{ language: string; code: string }> = [];
97
+ const queue: Array<() => void> = [];
98
+ warmSyntaxTokenizers({
99
+ languages: ['typescript', 'bash'],
100
+ ready: () => true,
101
+ highlight: (code, language) => calls.push({ language, code }),
102
+ schedule: (run) => {
103
+ queue.push(run);
104
+ return () => {};
105
+ },
106
+ });
107
+
108
+ // One slice is armed, not the whole set: a slow grammar must not become the
109
+ // long task this exists to prevent.
110
+ expect(queue).toHaveLength(1);
111
+ act(() => queue.shift()!());
112
+ expect(calls.map((c) => c.language)).toEqual(['typescript']);
113
+ expect(queue).toHaveLength(1);
114
+ act(() => queue.shift()!());
115
+ expect(calls.map((c) => c.language)).toEqual(['typescript', 'bash']);
116
+ });
117
+
118
+ test('is a no-op the second time, so a re-evaluated entry cannot re-warm', () => {
119
+ let calls = 0;
120
+ const options = {
121
+ languages: ['typescript'],
122
+ ready: () => true,
123
+ highlight: () => { calls += 1; },
124
+ schedule: (run: () => void) => { run(); return () => {}; },
125
+ };
126
+ warmSyntaxTokenizers(options);
127
+ warmSyntaxTokenizers(options);
128
+ expect(calls).toBe(1);
129
+ });
130
+
131
+ test('a grammar that has not landed is retried rather than skipped', () => {
132
+ const calls: string[] = [];
133
+ let ready = false;
134
+ const queue: Array<() => void> = [];
135
+ warmSyntaxTokenizers({
136
+ languages: ['typescript'],
137
+ ready: () => ready,
138
+ highlight: (_code, language) => calls.push(language),
139
+ schedule: (run) => {
140
+ queue.push(run);
141
+ return () => {};
142
+ },
143
+ });
144
+
145
+ act(() => queue.shift()!());
146
+ expect(calls).toEqual([]);
147
+ ready = true;
148
+ act(() => queue.shift()!());
149
+ expect(calls).toEqual(['typescript']);
150
+ });
151
+ });
152
+
153
+ describe('the deferred render', () => {
154
+ test('draws the escaped source without tokenizing on the mounting tick', async () => {
155
+ let renders = 0;
156
+ function Card({ code }: { code: string }) {
157
+ const html = useDeferredHighlight(code, 'typescript');
158
+ renders += 1;
159
+ return h('pre', { dangerouslySetInnerHTML: { __html: html } });
160
+ }
161
+
162
+ await act(async () => {
163
+ render(h(Card, { code: 'const value = 1;' }), container);
164
+ });
165
+
166
+ // Nothing has been tokenized on this tick — the tap's own task is clean.
167
+ expect(highlightMemoSize()).toBe(0);
168
+ expect(renders).toBeGreaterThan(0);
169
+ // What it DID draw is the escaped source, not an empty box.
170
+ expect(container.textContent).toContain('const value = 1;');
171
+ });
172
+
173
+ test('empty input renders nothing and caches nothing', async () => {
174
+ function Card() {
175
+ return h('pre', { dangerouslySetInnerHTML: { __html: useDeferredHighlight('', 'typescript') } });
176
+ }
177
+ await act(async () => {
178
+ render(h(Card, {}), container);
179
+ });
180
+ expect(highlightMemoSize()).toBe(0);
181
+ });
182
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Syntax-highlighted markup that never blocks the interaction that asked for it.
8
+ *
9
+ * A tool card renders its code the moment it is expanded, and the tokenizer used
10
+ * to run inside that render: measured on a throttled phone (4× CPU), a `read`
11
+ * card's expand was a 220–300 ms long task of which ~75% was the Shiki grammar
12
+ * engine, and at 20× the same card blocked for 730–1.484 ms. The card was on
13
+ * screen the whole time — it just could not paint until the last line had been
14
+ * tokenized.
15
+ *
16
+ * So the first render draws the text, escaped and correct, and the tokenized
17
+ * markup replaces it once the main thread is free (`scheduleIdle`). The two
18
+ * carry the same characters, so the swap costs colour and no layout.
19
+ *
20
+ * Three properties make that honest:
21
+ *
22
+ * - **A cached block renders highlighted on the FIRST paint.** `highlight-memo`
23
+ * is checked during render, so reopening a card the reader already opened —
24
+ * the common case while paging a transcript — has no plain-text frame at all.
25
+ * - **A stale block is never shown.** The value is tagged with the input it was
26
+ * computed from; a filter keystroke in the search panel therefore shows the
27
+ * new text, never the previous query's markup.
28
+ * - **Nothing is computed for empty input.** Call sites that gate a branch pass
29
+ * `''`, which is how `Edit` stops tokenizing the two panels it is not drawing.
30
+ */
31
+
32
+ import { useEffect, useMemo, useState } from 'preact/hooks';
33
+ import { escapeCode } from '@/shared/lib/code/highlighter';
34
+ import {
35
+ cachedHighlightBlock,
36
+ cachedHighlightBlockLines,
37
+ highlightBlock,
38
+ highlightBlockLines,
39
+ } from '@/shared/lib/code/highlight-memo';
40
+ import { scheduleIdle } from '@/shared/lib/code/idle';
41
+ import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
42
+
43
+ interface Deferred<T> {
44
+ /** Identity of the input; a value computed for another input is not shown. */
45
+ key: object;
46
+ hasContent: boolean;
47
+ cached: T | undefined;
48
+ plain: T;
49
+ compute: () => T;
50
+ }
51
+
52
+ function useDeferred<T>(spec: Deferred<T>): T {
53
+ // A grammar arriving is what turns a plain fallback into a real highlight, so
54
+ // the effect re-runs on it even though the input did not move.
55
+ const syntaxReady = useSyntaxReady();
56
+ const [state, setState] = useState<{ key: object; value: T }>(() => ({
57
+ key: spec.key,
58
+ value: spec.cached ?? spec.plain,
59
+ }));
60
+
61
+ /** Adopt a value only when it is a change, so a cached block does not cost an
62
+ * extra render on every mount. */
63
+ const adopt = (key: object, value: T): void => {
64
+ setState((previous) => (previous.key === key && previous.value === value ? previous : { key, value }));
65
+ };
66
+
67
+ useEffect(() => {
68
+ if (!spec.hasContent) {
69
+ adopt(spec.key, spec.plain);
70
+ return;
71
+ }
72
+ if (spec.cached !== undefined) {
73
+ adopt(spec.key, spec.cached);
74
+ return;
75
+ }
76
+ let cancelled = false;
77
+ const cancel = scheduleIdle(() => {
78
+ if (cancelled) return;
79
+ let value: T;
80
+ try {
81
+ value = spec.compute();
82
+ } catch {
83
+ // A grammar that cannot tokenize this input leaves the escaped text,
84
+ // which is what the first paint already drew.
85
+ value = spec.plain;
86
+ }
87
+ if (!cancelled) adopt(spec.key, value);
88
+ });
89
+ return () => {
90
+ cancelled = true;
91
+ cancel();
92
+ };
93
+ // `spec` is rebuilt every render; its KEY (the input) and the grammar's
94
+ // readiness are what decide the work.
95
+ // eslint-disable-next-line react-hooks/exhaustive-deps
96
+ }, [spec.key, syntaxReady]);
97
+
98
+ if (state.key === spec.key) return state.value;
99
+ return spec.cached ?? spec.plain;
100
+ }
101
+
102
+ /** Tokenized markup for one block, deferred off the render that asked for it. */
103
+ export function useDeferredHighlight(code: string, language: string): string {
104
+ const key = useMemo(() => ({ code, language }), [code, language]);
105
+ const plain = useMemo(() => escapeCode(code), [code]);
106
+ return useDeferred<string>({
107
+ key,
108
+ hasContent: Boolean(code),
109
+ cached: code ? cachedHighlightBlock(code, language) : undefined,
110
+ plain,
111
+ compute: () => highlightBlock(code, language),
112
+ });
113
+ }
114
+
115
+ /** Tokenized markup per line, deferred the same way. */
116
+ export function useDeferredHighlightLines(code: string, language: string): string[] {
117
+ const key = useMemo(() => ({ code, language }), [code, language]);
118
+ const plain = useMemo(
119
+ () => (code ? code.split('\n').map(escapeCode) : []),
120
+ [code],
121
+ );
122
+ return useDeferred<string[]>({
123
+ key,
124
+ hasContent: Boolean(code),
125
+ cached: code ? cachedHighlightBlockLines(code, language) : undefined,
126
+ plain,
127
+ compute: () => highlightBlockLines(code, language),
128
+ });
129
+ }
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { EditorCommand } from '@/shared/lib/code/editor/keymap';
17
- import type { FindOptions } from '@/shared/lib/code/editor/find';
17
+ import type { FindOptions } from '@/shared/lib/code/editor/find-scan';
18
18
 
19
19
  export interface UseEditorFindOptions {
20
20
  /** The buffer on screen. */
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor-theme setting, and the two rules that make it safe to offer.
8
+ *
9
+ * A theme is a MODULE that is fetched on selection, so there are two failure
10
+ * modes a unit test can pin without a browser:
11
+ *
12
+ * 1. **The module has to be registered before Shiki can tokenize with it.**
13
+ * `loadTheme` is idempotent, and a second surface selecting the same theme
14
+ * must share the first fetch rather than start a second one.
15
+ * 2. **`auto` must not register anything**, and an id this build does not ship
16
+ * must resolve to no theme rather than leave the editor half-painted.
17
+ *
18
+ * Driven through happy-dom because the hook subscribes to a window event — the
19
+ * same shape the editor FONT uses, and the reason an already-open editor hears
20
+ * about a change at all (`appSettings` is a boot-time prop).
21
+ */
22
+
23
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
24
+ import { Window } from 'happy-dom';
25
+ import { h, render } from 'preact';
26
+ import { act } from 'preact/test-utils';
27
+
28
+ import {
29
+ EDITOR_THEME_CHANGED_EVENT,
30
+ currentEditorThemeId,
31
+ useEditorTheme,
32
+ } from '@/client/hooks/editor/theme';
33
+ import {
34
+ isEditorThemeReady,
35
+ loadEditorTheme,
36
+ onEditorThemeReady,
37
+ resetEditorThemeLoader,
38
+ } from '@/shared/lib/code/editor/theme-loader';
39
+ import { EDITOR_AUTO_THEME_ID, resolveEditorTheme } from '@/shared/lib/code/editor/themes';
40
+ import { bootSyntax, onLanguageReady, requestLanguage } from '@/shared/lib/code/highlighter';
41
+ import { primeKabmoSettings } from '@/shared/lib/settings/client';
42
+
43
+ const DOM_GLOBALS = [
44
+ 'window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'CustomEvent',
45
+ 'getComputedStyle', 'MutationObserver', 'requestAnimationFrame', 'cancelAnimationFrame',
46
+ ] as const;
47
+
48
+ /** The runner's own globals, put back once this file's DOM work is done. */
49
+ const native: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
50
+
51
+ /** happy-dom's `CustomEvent`, so a dispatch is an instance its EventTarget accepts. */
52
+ let Custom: typeof CustomEvent;
53
+
54
+ async function bootTokenizer(): Promise<void> {
55
+ // The highlighter only boots where `window` exists, and the Bun runner has
56
+ // none. Stub it for the boot alone, then hand the global to happy-dom — a
57
+ // `window` left pointing at `globalThis` would make the hook's
58
+ // `dispatchEvent` the runner's, which rejects a happy-dom CustomEvent.
59
+ const target = globalThis as unknown as Record<string, unknown>;
60
+ const had = 'window' in target ? target.window : undefined;
61
+ target.window = globalThis;
62
+ await bootSyntax();
63
+ if (had === undefined) delete target.window;
64
+ else target.window = had;
65
+ const { promise, resolve } = Promise.withResolvers<void>();
66
+ const settle = () => {
67
+ if (requestLanguage('typescript')) {
68
+ off();
69
+ resolve();
70
+ }
71
+ };
72
+ const off = onLanguageReady(settle);
73
+ settle();
74
+ await promise;
75
+ }
76
+
77
+ beforeAll(async () => {
78
+ const win = new Window({ url: 'http://localhost' });
79
+ const target = globalThis as unknown as Record<string, unknown>;
80
+ for (const key of DOM_GLOBALS) {
81
+ if (!(key in native)) native[key] = target[key];
82
+ const value = (win as unknown as Record<string, unknown>)[key];
83
+ if (value !== undefined) target[key] = value;
84
+ }
85
+ Custom = win.CustomEvent as unknown as typeof CustomEvent;
86
+ await bootTokenizer();
87
+ resetEditorThemeLoader();
88
+ });
89
+
90
+ afterEach(() => {
91
+ primeKabmoSettings({});
92
+ document.body.innerHTML = '';
93
+ });
94
+
95
+ afterAll(() => {
96
+ const target = globalThis as unknown as Record<string, unknown>;
97
+ for (const key of DOM_GLOBALS) {
98
+ if (native[key] === undefined) delete target[key];
99
+ else target[key] = native[key];
100
+ }
101
+ });
102
+
103
+ /** A probe that renders exactly what the hook says the surface should paint. */
104
+ function ThemeProbe() {
105
+ const theme = useEditorTheme();
106
+ return h(
107
+ 'div',
108
+ { id: 'probe' },
109
+ `${theme.theme?.id ?? 'auto'}|${theme.vars['--editor-bg'] ?? 'none'}|${theme.themes.light}`,
110
+ );
111
+ }
112
+
113
+ async function mountProbe(): Promise<HTMLElement> {
114
+ const host = document.createElement('div');
115
+ document.body.appendChild(host);
116
+ await act(async () => {
117
+ render(h(ThemeProbe, {}), host);
118
+ });
119
+ return host.querySelector('#probe') as HTMLElement;
120
+ }
121
+
122
+ describe('currentEditorThemeId', () => {
123
+ test('reads the stored value and falls back to auto', () => {
124
+ primeKabmoSettings({});
125
+ expect(currentEditorThemeId()).toBe(EDITOR_AUTO_THEME_ID);
126
+
127
+ primeKabmoSettings({ kabmo_settings: { editorTheme: 'nord' } });
128
+ expect(currentEditorThemeId()).toBe('nord');
129
+ });
130
+ });
131
+
132
+ describe('theme module loading', () => {
133
+ test('auto needs no module, and an unknown id is a no-op', async () => {
134
+ resetEditorThemeLoader();
135
+ await loadEditorTheme(EDITOR_AUTO_THEME_ID);
136
+ expect(isEditorThemeReady(EDITOR_AUTO_THEME_ID)).toBe(true);
137
+
138
+ await loadEditorTheme('no-such-theme');
139
+ expect(isEditorThemeReady('no-such-theme')).toBe(false);
140
+ });
141
+
142
+ test('a shipped theme registers with the highlighter and announces itself', async () => {
143
+ resetEditorThemeLoader();
144
+ const seen: string[] = [];
145
+ const off = onEditorThemeReady((id) => seen.push(id));
146
+ try {
147
+ expect(isEditorThemeReady('nord')).toBe(false);
148
+ await loadEditorTheme('nord');
149
+ expect(isEditorThemeReady('nord')).toBe(true);
150
+ expect(seen).toEqual(['nord']);
151
+ } finally {
152
+ off();
153
+ }
154
+ });
155
+
156
+ test('two requests for one theme share a single registration', async () => {
157
+ resetEditorThemeLoader();
158
+ let announcements = 0;
159
+ const off = onEditorThemeReady(() => announcements++);
160
+ try {
161
+ await Promise.all([loadEditorTheme('dracula'), loadEditorTheme('dracula')]);
162
+ expect(announcements).toBe(1);
163
+ // Already registered: a third call resolves without announcing again.
164
+ await loadEditorTheme('dracula');
165
+ expect(announcements).toBe(1);
166
+ } finally {
167
+ off();
168
+ }
169
+ });
170
+ });
171
+
172
+ describe('useEditorTheme', () => {
173
+ test('auto renders no variables and the app’s dual pair', async () => {
174
+ primeKabmoSettings({});
175
+ const probe = await mountProbe();
176
+ expect(probe.textContent).toBe('auto|none|one-light');
177
+ });
178
+
179
+ test('an explicit theme renders its own surface and names itself in both slots', async () => {
180
+ primeKabmoSettings({ kabmo_settings: { editorTheme: 'dracula' } });
181
+ const probe = await mountProbe();
182
+ const theme = resolveEditorTheme('dracula');
183
+ expect(theme).not.toBeNull();
184
+ expect(probe.textContent).toBe(`dracula|${theme!.bg}|dracula`);
185
+ });
186
+
187
+ test('a write reaches an ALREADY-MOUNTED editor without a reload', async () => {
188
+ primeKabmoSettings({});
189
+ const probe = await mountProbe();
190
+ expect(probe.textContent).toBe('auto|none|one-light');
191
+
192
+ // Exactly what the settings modal dispatches on a change.
193
+ await act(async () => {
194
+ window.dispatchEvent(new Custom(EDITOR_THEME_CHANGED_EVENT, { detail: 'nord' }));
195
+ });
196
+ const nord = resolveEditorTheme('nord');
197
+ expect(probe.textContent).toBe(`nord|${nord!.bg}|nord`);
198
+
199
+ // And back to auto, which must clear the variables again rather than leave
200
+ // the previous theme's surface behind.
201
+ await act(async () => {
202
+ window.dispatchEvent(new Custom(EDITOR_THEME_CHANGED_EVENT, { detail: EDITOR_AUTO_THEME_ID }));
203
+ });
204
+ expect(probe.textContent).toBe('auto|none|one-light');
205
+ });
206
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Which editor theme is live, and how a change reaches an open editor.
8
+ *
9
+ * The setting is visual, so it has to apply to the editor on screen — not on the
10
+ * next reload. `appSettings` is a prop captured at boot, so reading it once
11
+ * would leave an already-mounted editor on the previous palette; this follows
12
+ * the editor FONT's shape (`hooks/editor/font.ts`): the picker writes the
13
+ * setting and announces it, and every editor subscribes.
14
+ *
15
+ * A theme's module is fetched the first time it is SELECTED (`theme-loader.ts`),
16
+ * so an install that never opens the picker downloads none of them.
17
+ */
18
+
19
+ import { useEffect, useState } from 'preact/hooks';
20
+ import { readKabmoSetting } from '@/shared/lib/settings/client';
21
+ import {
22
+ EDITOR_AUTO_THEME_ID,
23
+ EDITOR_DEFAULT_THEME_ID,
24
+ editorShikiThemes,
25
+ editorThemeVars,
26
+ resolveEditorTheme,
27
+ type EditorTheme,
28
+ } from '@/shared/lib/code/editor/themes';
29
+ import { loadEditorTheme } from '@/shared/lib/code/editor/theme-loader';
30
+
31
+ /** Broadcast on every editor-theme write. Detail is the new theme id. */
32
+ export const EDITOR_THEME_CHANGED_EVENT = 'omp:editor-theme-changed';
33
+
34
+ /** The theme id currently stored, falling back to `auto`. */
35
+ export function currentEditorThemeId(): string {
36
+ if (typeof window === 'undefined') return EDITOR_DEFAULT_THEME_ID;
37
+ return readKabmoSetting<string>('editorTheme') ?? EDITOR_DEFAULT_THEME_ID;
38
+ }
39
+
40
+ /** Everything a code surface needs to paint with the live editor theme. */
41
+ export interface EditorThemeState {
42
+ /** The theme itself, or `null` for `auto` (the app palette). */
43
+ theme: EditorTheme | null;
44
+ /** CSS custom properties for the surface host; empty for `auto`. */
45
+ vars: Record<string, string>;
46
+ /** The two Shiki theme names to tokenize with. */
47
+ themes: { light: string; dark: string };
48
+ }
49
+
50
+ const AUTO_STATE: EditorThemeState = { theme: null, vars: {}, themes: editorShikiThemes(null) };
51
+
52
+ /**
53
+ * The live editor theme, kept in step with writes.
54
+ *
55
+ * One hook rather than three, because a surface needs all three values and
56
+ * three subscriptions to one event would be three re-render paths that can
57
+ * disagree for a frame.
58
+ */
59
+ export function useEditorTheme(): EditorThemeState {
60
+ const [id, setId] = useState<string>(currentEditorThemeId);
61
+
62
+ useEffect(() => {
63
+ const onChange = (event: Event) => {
64
+ const detail = (event as CustomEvent<string>).detail;
65
+ setId(detail || currentEditorThemeId());
66
+ };
67
+ window.addEventListener(EDITOR_THEME_CHANGED_EVENT, onChange);
68
+ return () => window.removeEventListener(EDITOR_THEME_CHANGED_EVENT, onChange);
69
+ }, []);
70
+
71
+ // Fetch the module the first time this theme is live, so the tokenizer can
72
+ // colour with it. A no-op for `auto` and for an id already registered.
73
+ useEffect(() => {
74
+ void loadEditorTheme(id);
75
+ }, [id]);
76
+
77
+ // `auto` is the shipped state, and it allocates nothing new per render.
78
+ if (id === EDITOR_AUTO_THEME_ID) return AUTO_STATE;
79
+
80
+ const theme = resolveEditorTheme(id);
81
+ return { theme, vars: editorThemeVars(theme), themes: editorShikiThemes(id) };
82
+ }