@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,165 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor theme catalogue, and the one property that makes it trustworthy:
8
+ * every colour it ships is the INSTALLED package's own.
9
+ *
10
+ * The grid draws 65 cards without fetching a theme module, so its colours are
11
+ * data. Data generated from a dependency drifts the moment the dependency
12
+ * updates — silently, because the picker still renders, just with a palette the
13
+ * highlighter no longer has. These cases re-derive each row from
14
+ * `@shikijs/themes` itself, which is the only check that can fail on a version
15
+ * bump rather than on a typo.
16
+ *
17
+ * The remaining cases pin the two rules the surfaces depend on: `auto` resolves
18
+ * to no theme (so nothing changes for an install that never opens the picker),
19
+ * and an explicit theme names ITSELF in both Shiki slots — which is what makes
20
+ * the editor ignore the app's light/dark variant without a second CSS path.
21
+ */
22
+
23
+ import { describe, expect, test } from 'bun:test';
24
+ import { readdirSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+
27
+ import {
28
+ EDITOR_AUTO_THEME_ID,
29
+ EDITOR_DEFAULT_THEME_ID,
30
+ EDITOR_THEMES,
31
+ EDITOR_THEME_LOADERS,
32
+ editorShikiThemes,
33
+ editorThemeVars,
34
+ isEditorThemeId,
35
+ resolveEditorTheme,
36
+ } from '@/shared/lib/code/editor/themes';
37
+ import { SHIKI_THEMES } from '@/shared/lib/code/shiki-themes';
38
+
39
+ /** The installed package's `dist`, resolved through the package itself so a
40
+ * hoisted or linked install resolves the same way the app's own imports do. */
41
+ const THEMES_DIR = join(dirname(Bun.resolveSync('@shikijs/themes/package.json', import.meta.dir)), 'dist');
42
+
43
+ /** The ids the installed package actually ships, in the generator's order. */
44
+ function installedIds(): string[] {
45
+ return readdirSync(THEMES_DIR)
46
+ .filter((file) => file.endsWith('.mjs') && file !== 'index.mjs')
47
+ .map((file) => file.slice(0, -'.mjs'.length))
48
+ .sort();
49
+ }
50
+
51
+ describe('catalogue', () => {
52
+ test('one row per theme the installed package ships, in id order', () => {
53
+ expect(EDITOR_THEMES.map((theme) => theme.id)).toEqual(installedIds());
54
+ });
55
+
56
+ test('every row has a loader, and every loader has a row', () => {
57
+ expect(Object.keys(EDITOR_THEME_LOADERS).sort()).toEqual(EDITOR_THEMES.map((theme) => theme.id));
58
+ });
59
+
60
+ test('a label is never empty and never the raw id with dashes', () => {
61
+ for (const theme of EDITOR_THEMES) {
62
+ expect(theme.label.length).toBeGreaterThan(0);
63
+ expect(theme.variant === 'light' || theme.variant === 'dark').toBe(true);
64
+ }
65
+ });
66
+ });
67
+
68
+ describe('generated colours', () => {
69
+ test('the surface and the ink are the theme’s own', async () => {
70
+ for (const theme of EDITOR_THEMES) {
71
+ // Dynamic on purpose: the specifier is the row's own id, read at runtime
72
+ // from a 65-entry catalogue — a static import cannot name it.
73
+ const module = (await import(`@shikijs/themes/${theme.id}`)).default as {
74
+ colors?: Record<string, string>;
75
+ };
76
+ const colors = module.colors ?? {};
77
+ expect(theme.bg).toBe(colors['editor.background'] as string);
78
+ // Five themes ship no `editor.foreground`; the generator falls back to the
79
+ // theme's own `foreground`, which is still ITS colour and not a borrow.
80
+ const own =
81
+ colors['editor.foreground'] ?? colors['foreground'] ?? colors['terminal.foreground'] ?? theme.fg;
82
+ expect(theme.fg).toBe(own);
83
+ }
84
+ });
85
+
86
+ test('a chrome colour the theme omits is a mix of its own ink and surface', () => {
87
+ for (const theme of EDITOR_THEMES) {
88
+ for (const value of [theme.gutterFg, theme.selection, theme.find, theme.border]) {
89
+ expect(value.length).toBeGreaterThan(0);
90
+ // Either the theme's own hex, or a `color-mix` over its own two colours
91
+ // — never a bare colour borrowed from another theme.
92
+ const isOwnHex = /^#[0-9a-f]{3,8}$/i.test(value);
93
+ const isOwnMix = value.startsWith('color-mix(') && value.includes(theme.bg);
94
+ expect(isOwnHex || isOwnMix).toBe(true);
95
+ }
96
+ }
97
+ });
98
+
99
+ test('the three accents differ enough to tell two themes apart', () => {
100
+ for (const theme of EDITOR_THEMES) {
101
+ expect(theme.keyword).toMatch(/^#[0-9a-f]{3,8}$/i);
102
+ expect(theme.string).toMatch(/^#[0-9a-f]{3,8}$/i);
103
+ expect(theme.comment).toMatch(/^#[0-9a-f]{3,8}$/i);
104
+ }
105
+ });
106
+ });
107
+
108
+ describe('resolution', () => {
109
+ test('auto is the default and resolves to no theme', () => {
110
+ expect(EDITOR_DEFAULT_THEME_ID).toBe(EDITOR_AUTO_THEME_ID);
111
+ expect(resolveEditorTheme(EDITOR_AUTO_THEME_ID)).toBeNull();
112
+ expect(resolveEditorTheme(undefined)).toBeNull();
113
+ expect(resolveEditorTheme(null)).toBeNull();
114
+ });
115
+
116
+ test('an id this build does not ship falls back to auto rather than half a theme', () => {
117
+ expect(resolveEditorTheme('no-such-theme')).toBeNull();
118
+ expect(isEditorThemeId('no-such-theme')).toBe(false);
119
+ });
120
+
121
+ test('a shipped id resolves to its own row', () => {
122
+ const dracula = resolveEditorTheme('dracula');
123
+ expect(dracula?.id).toBe('dracula');
124
+ expect(isEditorThemeId('dracula')).toBe(true);
125
+ });
126
+ });
127
+
128
+ describe('shiki themes', () => {
129
+ test('auto keeps the app’s dual pair, so an app theme flip repaints', () => {
130
+ expect(editorShikiThemes(null)).toEqual(SHIKI_THEMES);
131
+ expect(editorShikiThemes(EDITOR_AUTO_THEME_ID)).toEqual(SHIKI_THEMES);
132
+ });
133
+
134
+ test('an explicit theme names itself in BOTH slots', () => {
135
+ // Both slots carrying one theme is what makes the app's variant attribute
136
+ // harmless: `--shiki-light` and `--shiki-dark` hold the same value, so
137
+ // either selector paints the chosen theme.
138
+ expect(editorShikiThemes('dracula')).toEqual({ light: 'dracula', dark: 'dracula' });
139
+ });
140
+
141
+ test('an unknown id keeps the app pair', () => {
142
+ expect(editorShikiThemes('no-such-theme')).toEqual(SHIKI_THEMES);
143
+ });
144
+ });
145
+
146
+ describe('css variables', () => {
147
+ test('auto writes nothing, so every declaration keeps its palette fallback', () => {
148
+ expect(editorThemeVars(null)).toEqual({});
149
+ });
150
+
151
+ test('an explicit theme writes the surface, the gutter and the diff bands', () => {
152
+ const theme = resolveEditorTheme('nord');
153
+ expect(theme).not.toBeNull();
154
+ const vars = editorThemeVars(theme);
155
+ expect(vars['--editor-bg']).toBe(theme!.bg);
156
+ expect(vars['--editor-fg']).toBe(theme!.fg);
157
+ expect(vars['--editor-gutter-fg']).toBe(theme!.gutterFg);
158
+ expect(vars['--editor-selection']).toBe(theme!.selection);
159
+ expect(vars['--editor-find-current']).toBe(theme!.findCurrent);
160
+ expect(vars['--editor-added']).toBe(theme!.added);
161
+ expect(vars['--editor-removed-gutter']).toBe(theme!.removedGutter);
162
+ // Every key is a custom property, never a plain one.
163
+ for (const key of Object.keys(vars)) expect(key.startsWith('--editor-')).toBe(true);
164
+ });
165
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The EDITOR's colour themes, independent of the app palette.
8
+ *
9
+ * CodeMirror can wear any of the 65 TextMate themes `@shikijs/themes` ships, and
10
+ * a reader who works in Dracula or Nord wants the code surface to say so while
11
+ * the console around it stays in the app's own palette. That separation is the
12
+ * whole feature: `SettingsState.editorTheme` is a second axis beside `theme`,
13
+ * and the editor paints its own surface, gutter, selection and tokens from it.
14
+ *
15
+ * Three rules keep it honest:
16
+ *
17
+ * - **`auto` is the default and means "the app's own palette".** Nothing changes
18
+ * for an install that never opens the picker: the surface stays transparent
19
+ * over the panel, the chrome reads `--theme-*`, and the tokenizer keeps the
20
+ * dual `one-light`/`one-dark-pro` pair the app variant selects.
21
+ * - **The data is generated, never typed.** `themes.generated.ts` is written by
22
+ * `scripts/editor-themes.mjs` from the installed package, so the picker's
23
+ * swatches are the theme's OWN colours and a `@shikijs/themes` bump cannot
24
+ * leave the grid showing a palette that no longer exists.
25
+ * - **A theme module is only fetched when it is selected.** The rows carry
26
+ * enough colour to draw the grid; `EDITOR_THEME_LOADERS` is what the
27
+ * highlighter calls, and 65 modules nobody picks are never downloaded.
28
+ *
29
+ * The picker's swatch shows the editor surface, the ink and three syntax
30
+ * accents — the five colours that actually tell two themes apart at 120px.
31
+ */
32
+
33
+ import { EDITOR_THEME_LOADERS, EDITOR_THEME_ROWS } from '@/shared/lib/code/editor/themes.generated';
34
+ import { SHIKI_THEMES } from '@/shared/lib/code/shiki-themes';
35
+
36
+ /** The setting value meaning "follow the app palette". */
37
+ export const EDITOR_AUTO_THEME_ID = 'auto';
38
+
39
+ /** What a settings row that predates this option holds. */
40
+ export const EDITOR_DEFAULT_THEME_ID = EDITOR_AUTO_THEME_ID;
41
+
42
+ export type EditorThemeVariant = 'light' | 'dark';
43
+
44
+ export interface EditorTheme {
45
+ /** Shiki's theme id, which is also the stored setting value. */
46
+ id: string;
47
+ label: string;
48
+ /** The theme's OWN light/dark verdict — never the app's. */
49
+ variant: EditorThemeVariant;
50
+ /** The editor surface and its ink. */
51
+ bg: string;
52
+ fg: string;
53
+ /** Line-number band. */
54
+ gutterFg: string;
55
+ /** Selection band, caret, and the two find-match bands. */
56
+ selection: string;
57
+ cursor: string;
58
+ find: string;
59
+ findCurrent: string;
60
+ /** Divider between the gutter and the text. */
61
+ border: string;
62
+ /** The two diff bands and their gutter markers. */
63
+ added: string;
64
+ removed: string;
65
+ addedGutter: string;
66
+ removedGutter: string;
67
+ /** Three syntax accents, for the picker's swatch only. */
68
+ keyword: string;
69
+ string: string;
70
+ comment: string;
71
+ }
72
+
73
+ /**
74
+ * The tuple's field order, stated once so the generated rows and this mapping
75
+ * cannot drift: a column added to the generator without a field here is a type
76
+ * error rather than a silently shifted colour.
77
+ */
78
+ type EditorThemeRow = (typeof EDITOR_THEME_ROWS)[number];
79
+
80
+ const FIELD_ORDER = [
81
+ 'id',
82
+ 'label',
83
+ 'variant',
84
+ 'bg',
85
+ 'fg',
86
+ 'gutterFg',
87
+ 'selection',
88
+ 'cursor',
89
+ 'find',
90
+ 'findCurrent',
91
+ 'border',
92
+ 'added',
93
+ 'removed',
94
+ 'addedGutter',
95
+ 'removedGutter',
96
+ 'keyword',
97
+ 'string',
98
+ 'comment',
99
+ ] as const satisfies ReadonlyArray<keyof EditorTheme>;
100
+
101
+ function toTheme(row: EditorThemeRow): EditorTheme {
102
+ const theme = {} as EditorTheme;
103
+ FIELD_ORDER.forEach((field, index) => {
104
+ // The tuple and the field list are asserted equal by the types, so this
105
+ // cast is the only place the two meet.
106
+ (theme as unknown as Record<string, string>)[field] = row[index];
107
+ });
108
+ return theme;
109
+ }
110
+
111
+ /** Every theme the installed Shiki ships, in id order (the generator's order). */
112
+ export const EDITOR_THEMES: readonly EditorTheme[] = EDITOR_THEME_ROWS.map(toTheme);
113
+
114
+ const BY_ID = new Map(EDITOR_THEMES.map((theme) => [theme.id, theme]));
115
+
116
+ /**
117
+ * The theme a stored setting names, or `null` for `auto` — and for an id this
118
+ * build no longer ships (a downgrade, a hand-edited row), which must render the
119
+ * app palette rather than a half-applied theme.
120
+ */
121
+ export function resolveEditorTheme(id: string | null | undefined): EditorTheme | null {
122
+ if (!id || id === EDITOR_AUTO_THEME_ID) return null;
123
+ return BY_ID.get(id) ?? null;
124
+ }
125
+
126
+ /** True when `id` is a theme this build can paint. */
127
+ export function isEditorThemeId(id: string): boolean {
128
+ return BY_ID.has(id);
129
+ }
130
+
131
+ /**
132
+ * The two theme names Shiki tokenizes with.
133
+ *
134
+ * `auto` keeps the app's dual pair, so the variant attribute picks the palette
135
+ * exactly as it does for every other surface. An explicit theme names ITSELF
136
+ * for both slots — one theme, both custom properties, identical values — which
137
+ * is what makes the editor ignore the app variant without a second CSS path.
138
+ */
139
+ export function editorShikiThemes(id: string | null | undefined): { light: string; dark: string } {
140
+ const theme = resolveEditorTheme(id);
141
+ if (!theme) return SHIKI_THEMES;
142
+ return { light: theme.id, dark: theme.id };
143
+ }
144
+
145
+ /** The CSS custom properties an explicit theme writes, keyed by variable name. */
146
+ export function editorThemeVars(theme: EditorTheme | null): Record<string, string> {
147
+ if (!theme) return {};
148
+ return {
149
+ '--editor-bg': theme.bg,
150
+ '--editor-fg': theme.fg,
151
+ '--editor-gutter-fg': theme.gutterFg,
152
+ '--editor-selection': theme.selection,
153
+ '--editor-cursor': theme.cursor,
154
+ '--editor-find': theme.find,
155
+ '--editor-find-current': theme.findCurrent,
156
+ '--editor-border': theme.border,
157
+ '--editor-added': theme.added,
158
+ '--editor-removed': theme.removed,
159
+ '--editor-added-gutter': theme.addedGutter,
160
+ '--editor-removed-gutter': theme.removedGutter,
161
+ };
162
+ }
163
+
164
+ /** The lazy module loaders, so the highlighter can fetch one theme on demand. */
165
+ export { EDITOR_THEME_LOADERS };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The block memo in front of the tokenizer.
8
+ *
9
+ * Three properties matter and each was a decision:
10
+ *
11
+ * 1. **A repeat of the same `(language, code)` returns the same markup** — that
12
+ * is the whole point (a card closed and reopened re-tokenized a 1000-line
13
+ * read, measured as the dominant cost of its expand).
14
+ * 2. **A block whose grammar is not ready is NOT cached.** `highlightCode`
15
+ * answers escaped text in that state, and caching it would pin the plain
16
+ * fallback for the life of the entry.
17
+ * 3. **A find-in-progress bypasses the memo**, because its markup depends on the
18
+ * match ranges rather than on the code alone.
19
+ */
20
+
21
+ import { beforeAll, beforeEach, describe, expect, test } from 'bun:test';
22
+
23
+ import { bootSyntax, onLanguageReady, requestLanguage } from '@/shared/lib/code/highlighter';
24
+ import {
25
+ clearHighlightMemo,
26
+ highlightBlock,
27
+ highlightBlockLines,
28
+ highlightMemoSize,
29
+ } from '@/shared/lib/code/highlight-memo';
30
+
31
+ const TOKEN = '--shiki-light:';
32
+
33
+ beforeAll(async () => {
34
+ Object.assign(globalThis, { window: globalThis });
35
+ await bootSyntax();
36
+ Reflect.deleteProperty(globalThis, 'window');
37
+ const { promise, resolve } = Promise.withResolvers<void>();
38
+ const settle = () => {
39
+ if (requestLanguage('json') && requestLanguage('typescript')) {
40
+ off();
41
+ resolve();
42
+ }
43
+ };
44
+ const off = onLanguageReady(settle);
45
+ settle();
46
+ await promise;
47
+ });
48
+
49
+ beforeEach(() => clearHighlightMemo());
50
+
51
+ describe('highlightBlock', () => {
52
+ test('a repeated block returns the same markup and is held once', () => {
53
+ const code = 'const value = 1;\n';
54
+ const first = highlightBlock(code, 'typescript');
55
+ const second = highlightBlock(code, 'typescript');
56
+
57
+ expect(first).toBe(second);
58
+ expect(first).toContain(TOKEN);
59
+ expect(highlightMemoSize()).toBe(1);
60
+ });
61
+
62
+ test('the same code in another language is a different entry', () => {
63
+ highlightBlock('{"a":1}', 'json');
64
+ highlightBlock('{"a":1}', 'typescript');
65
+ expect(highlightMemoSize()).toBe(2);
66
+ });
67
+
68
+ test('a grammar that is not ready is never cached', () => {
69
+ const html = highlightBlock('const a = 1;', 'not-a-real-grammar');
70
+ // It still renders something — escaped text, via the javascript fallback —
71
+ // but caching that would freeze the fallback for good.
72
+ expect(html).toContain('const');
73
+ expect(highlightMemoSize()).toBe(0);
74
+ });
75
+
76
+ test('a find-in-progress bypasses the memo', () => {
77
+ highlightBlock('const a = 1;', 'typescript', { marks: [{ start: 0, end: 5 }] });
78
+ expect(highlightMemoSize()).toBe(0);
79
+ });
80
+
81
+ test('empty input is not an entry', () => {
82
+ expect(highlightBlock('', 'typescript')).toBe('');
83
+ expect(highlightMemoSize()).toBe(0);
84
+ });
85
+
86
+ test('the per-language bound holds', () => {
87
+ for (let index = 0; index < 30; index += 1) {
88
+ highlightBlock(`const value${index} = ${index};`, 'typescript');
89
+ }
90
+ expect(highlightMemoSize()).toBeLessThanOrEqual(24);
91
+ });
92
+ });
93
+
94
+ describe('highlightBlockLines', () => {
95
+ test('lines are cached and round-trip through the same key', () => {
96
+ const lines = highlightBlockLines('const a = 1;\nconst b = 2;', 'typescript');
97
+ expect(lines).toHaveLength(2);
98
+ expect(highlightMemoSize()).toBe(1);
99
+
100
+ const again = highlightBlockLines('const a = 1;\nconst b = 2;', 'typescript');
101
+ expect(again).toEqual(lines);
102
+ });
103
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A bounded memo in front of the syntax highlighter, for BLOCK highlights — the
8
+ * code a tool card draws when it opens.
9
+ *
10
+ * Shiki tokenization is the expensive half of rendering a tool card: profiled on
11
+ * a throttled phone, a `read` expand spent 75% of its long task in
12
+ * `findNextMatchSync`/the grammar engine, and the same card re-expanded in 33 ms
13
+ * once its grammar was warm. Two consequences follow, and this module is the
14
+ * second half of both (the first is `idle.ts`, which moves the work off the tap):
15
+ *
16
+ * 1. A card that is closed and reopened is the same code and the same language,
17
+ * so its markup is the same string. Tokenizing it again is pure waste, and a
18
+ * reader paging through a transcript does exactly that repeatedly.
19
+ * 2. `Edit` asked for three highlights per render (old, new, preview) when at
20
+ * most one of them is ever drawn. Caching per `(language, code)` makes the
21
+ * duplicates free even before the call sites are gated.
22
+ *
23
+ * The key is a two-level map — language, then the code string itself — rather
24
+ * than one composite string, because the composite would COPY the whole file on
25
+ * every lookup, which is exactly the per-frame allocation this exists to remove.
26
+ * Insertion order is the LRU order.
27
+ *
28
+ * Nothing is cached while the language's grammar has not landed: `highlightCode`
29
+ * answers escaped text in that state, and caching it would pin the plain
30
+ * fallback for the life of the entry (the trap `highlight-cache.ts` documents
31
+ * for search rows). `useSyntaxReady` re-runs the caller when a grammar arrives,
32
+ * which is when the entry finally becomes cacheable.
33
+ */
34
+
35
+ import { isLanguageReady } from '@/shared/lib/code/highlighter-lazy';
36
+ import { highlightCode, highlightLines, type HighlightLinesOptions } from '@/shared/lib/code/syntax-highlight';
37
+
38
+ /** Per language: a transcript page's worth of distinct blocks. */
39
+ const MAX_BLOCKS_PER_LANGUAGE = 24;
40
+ /** Across every language, so a long session cannot grow without bound. */
41
+ const MAX_CACHED_CHARS = 512 * 1024;
42
+
43
+ const blocks = new Map<string, Map<string, string>>();
44
+ let cachedChars = 0;
45
+
46
+ function bucket(language: string): Map<string, string> {
47
+ const existing = blocks.get(language);
48
+ if (existing) return existing;
49
+ const created = new Map<string, string>();
50
+ blocks.set(language, created);
51
+ return created;
52
+ }
53
+
54
+ /** Drop the oldest entry of the first language that has one. */
55
+ function evictOne(): boolean {
56
+ for (const entries of blocks.values()) {
57
+ const oldest = entries.keys().next();
58
+ if (oldest.done) continue;
59
+ const value = entries.get(oldest.value) ?? '';
60
+ entries.delete(oldest.value);
61
+ cachedChars -= value.length;
62
+ return true;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ function remember(language: string, code: string, html: string): void {
68
+ const entries = bucket(language);
69
+ const previous = entries.get(code);
70
+ if (previous !== undefined) {
71
+ entries.delete(code);
72
+ cachedChars -= previous.length;
73
+ }
74
+ entries.set(code, html);
75
+ cachedChars += html.length;
76
+ while (entries.size > MAX_BLOCKS_PER_LANGUAGE) {
77
+ const oldest = entries.keys().next();
78
+ if (oldest.done) break;
79
+ const value = entries.get(oldest.value) ?? '';
80
+ entries.delete(oldest.value);
81
+ cachedChars -= value.length;
82
+ }
83
+ while (cachedChars > MAX_CACHED_CHARS && evictOne()) {
84
+ // Keep dropping until the budget holds or nothing is left to drop.
85
+ }
86
+ }
87
+
88
+ /** The cached markup for `(language, code)`, or `undefined`. */
89
+ export function cachedHighlightBlock(code: string, language: string): string | undefined {
90
+ const entries = blocks.get(language);
91
+ if (!entries) return undefined;
92
+ const hit = entries.get(code);
93
+ if (hit === undefined) return undefined;
94
+ // Reading is a use: move it to the most-recently-used end.
95
+ entries.delete(code);
96
+ entries.set(code, hit);
97
+ return hit;
98
+ }
99
+
100
+ /** The cached per-line markup, or `undefined`. */
101
+ export function cachedHighlightBlockLines(code: string, language: string): string[] | undefined {
102
+ const hit = cachedHighlightBlock(code, language);
103
+ return hit === undefined ? undefined : hit.split('\n');
104
+ }
105
+
106
+ function cacheable(language: string, options: HighlightLinesOptions): boolean {
107
+ // A find-in-progress needs the match markup, which only the line path emits —
108
+ // and which changes per query, so those results are never shared.
109
+ if (options.marks && options.marks.length > 0) return false;
110
+ return isLanguageReady(language);
111
+ }
112
+
113
+ /** `highlightCode` for one block, memoized per `(language, code)`. */
114
+ export function highlightBlock(code: string, language: string, options: HighlightLinesOptions = {}): string {
115
+ if (!code) return '';
116
+ const cached = options.marks?.length ? undefined : cachedHighlightBlock(code, language);
117
+ if (cached !== undefined) return cached;
118
+ const html = highlightCode(code, language, options);
119
+ if (cacheable(language, options)) remember(language, code, html);
120
+ return html;
121
+ }
122
+
123
+ /** `highlightLines` for one block, memoized per `(language, code)`. */
124
+ export function highlightBlockLines(
125
+ code: string,
126
+ language: string,
127
+ options: HighlightLinesOptions = {},
128
+ ): string[] {
129
+ if (!code) return [''];
130
+ const cached = options.marks?.length ? undefined : cachedHighlightBlockLines(code, language);
131
+ if (cached !== undefined) return cached;
132
+ const lines = highlightLines(code, language, options);
133
+ if (cacheable(language, options)) remember(language, code, lines.join('\n'));
134
+ return lines;
135
+ }
136
+
137
+ /** Drop every entry. For tests and for a highlighter restart (grammar reload). */
138
+ export function clearHighlightMemo(): void {
139
+ blocks.clear();
140
+ cachedChars = 0;
141
+ }
142
+
143
+ /** How many blocks are held — a hit and a recompute return equal strings, so
144
+ * the map is the only observable. */
145
+ export function highlightMemoSize(): number {
146
+ let total = 0;
147
+ for (const entries of blocks.values()) total += entries.size;
148
+ return total;
149
+ }