ompchamber 2.0.2 → 3.1.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 (419) hide show
  1. package/README.md +31 -11
  2. package/bunfig.toml +11 -0
  3. package/dist/client/index.html +16 -5
  4. package/dist/client/index.js +1393 -0
  5. package/dist/client/static/js/index-8t70jhyj.js +5149 -0
  6. package/dist/client/static/js/index-n4wkqj7t.css +1 -0
  7. package/index.html +54 -0
  8. package/package.json +9 -10
  9. package/public/apple-touch-icon.png +0 -0
  10. package/public/icon-192x192.png +0 -0
  11. package/public/icon.svg +60 -0
  12. package/public/manifest.webmanifest +38 -0
  13. package/src/cli/lib/commands/serve.js +9 -5
  14. package/src/cli/lib/runtime.js +18 -7
  15. package/src/client/components/common/code-editor/handler.test.ts +274 -0
  16. package/src/client/components/common/code-editor/handler.ts +175 -0
  17. package/src/client/components/common/code-editor/index.tsx +185 -220
  18. package/src/client/components/common/code-surface/index.tsx +109 -12
  19. package/src/client/components/common/lazy-panels.tsx +5 -1
  20. package/src/client/components/common/provider-icon/glyphs.ts +118 -0
  21. package/src/client/components/common/provider-icon/index.tsx +85 -0
  22. package/src/client/components/common/viewport.ts +5 -3
  23. package/src/client/components/layout/RightActivityBar.tsx +2 -1
  24. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
  25. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +4 -0
  26. package/src/client/components/layout/desktop-layout/index.tsx +26 -4
  27. package/src/client/components/mobile/LayoutWrapper.tsx +16 -0
  28. package/src/client/components/mobile/RightSidebar.tsx +11 -4
  29. package/src/client/components/mobile/SessionSidebar.tsx +10 -0
  30. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +0 -1
  31. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -1
  32. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +3 -0
  33. package/src/client/components/settings/categories/ProviderSettings.tsx +45 -4
  34. package/src/client/components/settings/categories/UsageSettings.tsx +22 -7
  35. package/src/client/components/settings/categories/provider-settings/AddModelModal.tsx +322 -0
  36. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +152 -56
  37. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +52 -1
  38. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +133 -0
  39. package/src/client/components/settings/categories/provider-settings/DialectFields.tsx +136 -0
  40. package/src/client/components/settings/categories/provider-settings/EffortLadder.tsx +89 -0
  41. package/src/client/components/settings/categories/provider-settings/Header.tsx +2 -2
  42. package/src/client/components/settings/categories/provider-settings/LoginModal.tsx +2 -2
  43. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +22 -2
  44. package/src/client/components/settings/categories/provider-settings/PresetPicker.tsx +82 -0
  45. package/src/client/components/settings/categories/provider-settings/PriceFields.tsx +107 -0
  46. package/src/client/components/settings/categories/provider-settings/ReconnectModal.tsx +43 -22
  47. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +2 -2
  48. package/src/client/components/settings/categories/usage-settings/LimitsSection.tsx +115 -0
  49. package/src/client/components/settings/categories/usage-settings/ProviderCard.tsx +19 -26
  50. package/src/client/components/settings/categories/usage-settings/ProviderDetail.tsx +29 -22
  51. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +60 -48
  52. package/src/client/components/settings/categories/usage-settings/format.ts +36 -0
  53. package/src/client/components/settings/categories/usage-settings/providers.ts +52 -28
  54. package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
  55. package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
  56. package/src/client/components/workspace/browser-panel/index.tsx +6 -6
  57. package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
  58. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +1 -1
  59. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +1 -1
  60. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
  62. package/src/client/components/workspace/diff-panel/Toolbar.tsx +71 -38
  63. package/src/client/components/workspace/diff-panel/index.tsx +139 -53
  64. package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
  65. package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
  66. package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
  67. package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
  68. package/src/client/components/workspace/editor/Tabs.tsx +2 -2
  69. package/src/client/components/workspace/editor/Toolbar.tsx +129 -26
  70. package/src/client/components/workspace/editor/index.tsx +180 -62
  71. package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
  72. package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
  73. package/src/client/components/workspace/model-dropdown/Item.tsx +13 -23
  74. package/src/client/components/workspace/model-dropdown/Panel.tsx +1 -1
  75. package/src/client/components/workspace/model-dropdown/Section.tsx +13 -4
  76. package/src/client/components/workspace/model-dropdown/index.tsx +7 -3
  77. package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
  78. package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
  79. package/src/client/components/workspace/todo-panel/index.tsx +161 -0
  80. package/src/client/components/workspace/usage-panel/ProviderSelect.tsx +10 -5
  81. package/src/client/components/workspace/usage-panel/index.tsx +36 -18
  82. package/src/client/data/samples/dialogue-session.ts +1 -1
  83. package/src/client/data/samples/sample-tools/other.ts +1 -1
  84. package/src/client/data/settings/provider.ts +141 -9
  85. package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
  86. package/src/client/hooks/editor/scroll-container.test.ts +102 -0
  87. package/src/client/hooks/editor/scroll-container.ts +26 -0
  88. package/src/client/hooks/editor/use-code-window.ts +2 -20
  89. package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
  90. package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
  91. package/src/client/hooks/editor/use-editor-find.ts +321 -0
  92. package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
  93. package/src/client/hooks/editor/use-editor-history.ts +134 -0
  94. package/src/client/hooks/editor/use-file-editor.ts +24 -27
  95. package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
  96. package/src/client/hooks/editor/use-occurrences.ts +98 -0
  97. package/src/client/hooks/models/use-provider-names.ts +1 -1
  98. package/src/client/hooks/settings/providers/actions.ts +337 -0
  99. package/src/client/hooks/settings/providers/api.ts +69 -0
  100. package/src/client/hooks/settings/providers/index.ts +138 -0
  101. package/src/client/hooks/settings/providers/messages.ts +77 -0
  102. package/src/client/hooks/settings/useUsageReport.ts +8 -3
  103. package/src/client/hooks/ui/theme.ts +3 -3
  104. package/src/client/hooks/workspace/file-tabs.ts +62 -11
  105. package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
  106. package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
  107. package/src/client/hooks/workspace/session-todos.ts +102 -0
  108. package/src/client/stubs/elkjs.ts +6 -1
  109. package/src/client/tailwind.css +30 -0
  110. package/src/server/index.ts +20 -2
  111. package/src/server/lib/assets/font-css.server.ts +86 -0
  112. package/src/server/lib/assets/fonts.server.ts +117 -0
  113. package/src/server/lib/bundler/css.ts +116 -0
  114. package/src/server/lib/lifecycle/shell-route.ts +17 -0
  115. package/src/server/lib/models/provider-listing.server.ts +257 -0
  116. package/src/server/lib/models/provider-registry.server.test.ts +122 -0
  117. package/src/server/lib/models/provider-registry.server.ts +76 -25
  118. package/src/server/lib/omp/config/models-config.ts +11 -0
  119. package/src/server/lib/omp/config/models-validation.test.ts +137 -0
  120. package/src/server/lib/omp/config/models-validation.ts +111 -2
  121. package/src/server/lib/omp/config/provider-seeds.test.ts +167 -0
  122. package/src/server/lib/omp/config/provider-seeds.ts +176 -0
  123. package/src/server/lib/omp/config/providers.ts +94 -100
  124. package/src/server/lib/omp/core/auth-credentials.ts +45 -0
  125. package/src/server/lib/omp/session/todos.ts +100 -0
  126. package/src/server/lib/omp/session/{usage.ts → usage/aggregate.ts} +1 -118
  127. package/src/server/lib/omp/session/usage/shaping.ts +130 -0
  128. package/src/server/lib/usage/deepseek.ts +4 -5
  129. package/src/server/lib/usage/kenari.ts +7 -6
  130. package/src/server/lib/usage/omp-usage.server.test.ts +126 -0
  131. package/src/server/lib/usage/omp-usage.server.ts +194 -0
  132. package/src/server/lib/usage/provider-key.ts +81 -3
  133. package/src/server/lib/usage/providers.server.test.ts +47 -0
  134. package/src/server/lib/usage/providers.server.ts +208 -0
  135. package/src/server/lib/usage/vendor-balance.server.test.ts +104 -0
  136. package/src/server/lib/usage/vendor-balance.server.ts +284 -0
  137. package/src/server/plugins/shell.server.ts +56 -0
  138. package/src/server/plugins/ssr.ts +24 -36
  139. package/src/server/plugins/static.test.ts +26 -29
  140. package/src/server/plugins/static.ts +40 -19
  141. package/src/server/routes/fs/action-create.test.ts +90 -0
  142. package/src/server/routes/fs/action.ts +23 -0
  143. package/src/server/routes/omp/index.ts +2 -0
  144. package/src/server/routes/omp/session-todos.ts +80 -0
  145. package/src/server/routes/settings/index.ts +2 -0
  146. package/src/server/routes/settings/provider-model.ts +107 -0
  147. package/src/server/routes/settings/provider-models.ts +65 -202
  148. package/src/server/routes/settings/providers.ts +22 -7
  149. package/src/server/routes/settings/usage.ts +125 -45
  150. package/src/server/routes/telemetry/tokens.ts +2 -1
  151. package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
  152. package/src/shared/lib/chat/todo/progress.ts +97 -0
  153. package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
  154. package/src/shared/lib/chat/todo/snapshot.ts +215 -0
  155. package/src/shared/lib/code/editor/commands.test.ts +155 -0
  156. package/src/shared/lib/code/editor/commands.ts +260 -0
  157. package/src/shared/lib/code/editor/edits.test.ts +100 -0
  158. package/src/shared/lib/code/editor/edits.ts +122 -0
  159. package/src/shared/lib/code/editor/find-status.test.ts +50 -0
  160. package/src/shared/lib/code/editor/find-status.ts +36 -0
  161. package/src/shared/lib/code/editor/find.test.ts +203 -0
  162. package/src/shared/lib/code/editor/find.ts +224 -0
  163. package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
  164. package/src/shared/lib/code/editor/keymap.test.ts +109 -0
  165. package/src/shared/lib/code/editor/keymap.ts +151 -0
  166. package/src/shared/lib/code/editor/layers.test.ts +77 -0
  167. package/src/shared/lib/code/editor/layers.ts +67 -0
  168. package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
  169. package/src/shared/lib/code/editor/line-edits.ts +159 -0
  170. package/src/shared/lib/code/editor/lines.test.ts +81 -0
  171. package/src/shared/lib/code/editor/lines.ts +83 -0
  172. package/src/shared/lib/code/editor/marks.test.ts +86 -0
  173. package/src/shared/lib/code/editor/marks.ts +106 -0
  174. package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
  175. package/src/shared/lib/code/editor/occurrences.ts +111 -0
  176. package/src/shared/lib/code/editor/palette.test.ts +91 -0
  177. package/src/shared/lib/code/editor/palette.ts +92 -0
  178. package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
  179. package/src/shared/lib/code/editor/reset-css.ts +32 -0
  180. package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
  181. package/src/shared/lib/code/editor/text-commands.ts +154 -0
  182. package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
  183. package/src/shared/lib/code/syntax-highlight.ts +29 -17
  184. package/src/shared/lib/code/windowed-highlight.ts +52 -1
  185. package/src/shared/lib/fs/diff-parser.test.ts +116 -0
  186. package/src/shared/lib/fs/diff-parser.ts +86 -33
  187. package/src/shared/lib/models/{provider-connection.ts → provider/connection.ts} +37 -0
  188. package/src/shared/lib/models/provider/dialect.ts +179 -0
  189. package/src/shared/lib/models/provider/glyph.test.ts +116 -0
  190. package/src/shared/lib/models/provider/glyph.ts +200 -0
  191. package/src/shared/lib/models/{provider-models.ts → provider/models.ts} +91 -6
  192. package/src/shared/lib/models/provider/presets.test.ts +172 -0
  193. package/src/shared/lib/models/provider/presets.ts +83 -0
  194. package/src/shared/lib/ui/key-binding.test.ts +115 -0
  195. package/src/shared/lib/ui/key-binding.ts +143 -0
  196. package/src/shared/lib/util/platform.test.ts +87 -0
  197. package/src/shared/lib/util/platform.ts +30 -0
  198. package/src/shared/lib/workspace/file-tab-id.ts +29 -0
  199. package/src/shared/lib/workspace/keymap.test.ts +82 -0
  200. package/src/shared/lib/workspace/keymap.ts +30 -0
  201. package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
  202. package/src/shared/lib/workspace/right-panels.ts +8 -0
  203. package/src/shared/lib/workspace/session-state/store.ts +0 -1
  204. package/src/shared/types/index.ts +1 -0
  205. package/src/shared/types/settings/provider.ts +81 -0
  206. package/src/shared/types/settings/usage.ts +88 -14
  207. package/src/shared/types/todo.ts +108 -0
  208. package/tsconfig.json +3 -1
  209. package/dist/client/static/css/index.5f630421b7.css +0 -2
  210. package/dist/client/static/font/KaTeX_AMS-Regular.a79f1c3119.woff2 +0 -0
  211. package/dist/client/static/font/KaTeX_Caligraphic-Bold.ec17d13264.woff2 +0 -0
  212. package/dist/client/static/font/KaTeX_Caligraphic-Regular.55fac25845.woff2 +0 -0
  213. package/dist/client/static/font/KaTeX_Fraktur-Bold.d42a5579b0.woff2 +0 -0
  214. package/dist/client/static/font/KaTeX_Fraktur-Regular.d3c882a649.woff2 +0 -0
  215. package/dist/client/static/font/KaTeX_Main-Bold.c3fb5ac22f.woff2 +0 -0
  216. package/dist/client/static/font/KaTeX_Main-BoldItalic.6f2bb1dff2.woff2 +0 -0
  217. package/dist/client/static/font/KaTeX_Main-Italic.8916142bec.woff2 +0 -0
  218. package/dist/client/static/font/KaTeX_Main-Regular.0462f03bdf.woff2 +0 -0
  219. package/dist/client/static/font/KaTeX_Math-BoldItalic.572d331f69.woff2 +0 -0
  220. package/dist/client/static/font/KaTeX_Math-Italic.f28c23acad.woff2 +0 -0
  221. package/dist/client/static/font/KaTeX_SansSerif-Bold.8c5b5494b6.woff2 +0 -0
  222. package/dist/client/static/font/KaTeX_SansSerif-Italic.3b1e59b3ba.woff2 +0 -0
  223. package/dist/client/static/font/KaTeX_SansSerif-Regular.ba21ed5f84.woff2 +0 -0
  224. package/dist/client/static/font/KaTeX_Script-Regular.03e9641d6f.woff2 +0 -0
  225. package/dist/client/static/font/KaTeX_Size1-Regular.eae34984b3.woff2 +0 -0
  226. package/dist/client/static/font/KaTeX_Size2-Regular.5916a24fa3.woff2 +0 -0
  227. package/dist/client/static/font/KaTeX_Size4-Regular.10d95fd3a2.woff2 +0 -0
  228. package/dist/client/static/font/KaTeX_Typewriter-Regular.a8709e3622.woff2 +0 -0
  229. package/dist/client/static/font/fira-code-latin-400-normal.2655b10c55.woff2 +0 -0
  230. package/dist/client/static/font/fira-code-latin-500-normal.92a991f204.woff2 +0 -0
  231. package/dist/client/static/font/fira-code-latin-600-normal.43788eb7f1.woff2 +0 -0
  232. package/dist/client/static/font/fira-code-latin-700-normal.2667215d33.woff2 +0 -0
  233. package/dist/client/static/js/77.0d81c74cf0.js +0 -139
  234. package/dist/client/static/js/77.0d81c74cf0.js.LICENSE.txt +0 -8
  235. package/dist/client/static/js/async/0.8e59f2eb5b.js +0 -1
  236. package/dist/client/static/js/async/0k.5852e6b4fa.js +0 -1
  237. package/dist/client/static/js/async/0w.9ed36d5860.js +0 -1
  238. package/dist/client/static/js/async/1.f387c12b63.js +0 -1
  239. package/dist/client/static/js/async/12.d091f91c99.js +0 -1
  240. package/dist/client/static/js/async/13.8f810a8a6c.js +0 -1
  241. package/dist/client/static/js/async/19.6fe909d55a.js +0 -160
  242. package/dist/client/static/js/async/1b.23f1141a38.js +0 -1
  243. package/dist/client/static/js/async/1k.973e8e21fc.js +0 -2
  244. package/dist/client/static/js/async/1k.973e8e21fc.js.LICENSE.txt +0 -11
  245. package/dist/client/static/js/async/1p.0d5cddda96.js +0 -1
  246. package/dist/client/static/js/async/2.b589f1733b.js +0 -1
  247. package/dist/client/static/js/async/2v.96165101de.js +0 -1
  248. package/dist/client/static/js/async/3.0cacc08423.js +0 -1
  249. package/dist/client/static/js/async/37.b28b2f61cd.js +0 -1
  250. package/dist/client/static/js/async/3d.5085fb913f.js +0 -1
  251. package/dist/client/static/js/async/3j.357c80ff38.js +0 -2
  252. package/dist/client/static/js/async/3j.357c80ff38.js.LICENSE.txt +0 -11
  253. package/dist/client/static/js/async/3p.bcf32a0983.js +0 -1
  254. package/dist/client/static/js/async/3r.3a69e98ee0.js +0 -64
  255. package/dist/client/static/js/async/3u.1228460e2a.js +0 -1
  256. package/dist/client/static/js/async/3y.3530b824ad.js +0 -198
  257. package/dist/client/static/js/async/3y.3530b824ad.js.LICENSE.txt +0 -5
  258. package/dist/client/static/js/async/4.9442eeb472.js +0 -1
  259. package/dist/client/static/js/async/4e.678285ca55.js +0 -1
  260. package/dist/client/static/js/async/4k.33a0c7eeb4.js +0 -1
  261. package/dist/client/static/js/async/4m.928a1dde85.js +0 -31
  262. package/dist/client/static/js/async/4m.928a1dde85.js.LICENSE.txt +0 -11
  263. package/dist/client/static/js/async/4q.aad20dab25.js +0 -1
  264. package/dist/client/static/js/async/5.ebaf8b89cc.js +0 -36
  265. package/dist/client/static/js/async/51.95bcae98af.js +0 -2
  266. package/dist/client/static/js/async/51.95bcae98af.js.LICENSE.txt +0 -6
  267. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  268. package/dist/client/static/js/async/5j.d6407c4248.js +0 -287
  269. package/dist/client/static/js/async/5o.88316d4460.js +0 -62
  270. package/dist/client/static/js/async/6.f034c9b528.js +0 -1
  271. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  272. package/dist/client/static/js/async/6o.5b1a06a382.js +0 -1
  273. package/dist/client/static/js/async/7.e4a9288dd0.js +0 -1
  274. package/dist/client/static/js/async/73.308dff4da8.js +0 -1
  275. package/dist/client/static/js/async/7e.a1cc9bfd88.js +0 -1
  276. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  277. package/dist/client/static/js/async/7l.fadbbc0583.js +0 -1
  278. package/dist/client/static/js/async/8.5067439d8c.js +0 -1
  279. package/dist/client/static/js/async/8j.ea110663fb.js +0 -1
  280. package/dist/client/static/js/async/a.bce4542392.js +0 -1
  281. package/dist/client/static/js/async/aa.65d3852576.js +0 -1
  282. package/dist/client/static/js/async/ar.9303e5f2e8.js +0 -2
  283. package/dist/client/static/js/async/ar.9303e5f2e8.js.LICENSE.txt +0 -11
  284. package/dist/client/static/js/async/az.d656062b0f.js +0 -1
  285. package/dist/client/static/js/async/b.b4f6f06515.js +0 -1
  286. package/dist/client/static/js/async/bi.09688ba75c.js +0 -1
  287. package/dist/client/static/js/async/bq.586bc738d1.js +0 -1
  288. package/dist/client/static/js/async/c.797d933882.js +0 -1
  289. package/dist/client/static/js/async/c0.0af1e640d3.js +0 -24
  290. package/dist/client/static/js/async/ca.8a52cfd447.js +0 -1
  291. package/dist/client/static/js/async/cf.d34fff5d67.js +0 -1
  292. package/dist/client/static/js/async/ct.b74928f7a3.js +0 -1
  293. package/dist/client/static/js/async/cx.3cb0242a3a.js +0 -226
  294. package/dist/client/static/js/async/d.a5fc8fe1b3.js +0 -1
  295. package/dist/client/static/js/async/da.f62c70f6c8.js +0 -1
  296. package/dist/client/static/js/async/dp.1725d6432c.js +0 -1
  297. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  298. package/dist/client/static/js/async/f.ab50467719.js +0 -19
  299. package/dist/client/static/js/async/f.ab50467719.js.LICENSE.txt +0 -12
  300. package/dist/client/static/js/async/f7.46971e9d6d.js +0 -2
  301. package/dist/client/static/js/async/f7.46971e9d6d.js.LICENSE.txt +0 -6
  302. package/dist/client/static/js/async/f8.bbf0dbae70.js +0 -1
  303. package/dist/client/static/js/async/g.cb81980498.js +0 -2
  304. package/dist/client/static/js/async/g.cb81980498.js.LICENSE.txt +0 -11
  305. package/dist/client/static/js/async/h.4e790471e4.js +0 -27
  306. package/dist/client/static/js/async/h1.393f066d09.js +0 -1
  307. package/dist/client/static/js/async/h3.216e9798ab.js +0 -148
  308. package/dist/client/static/js/async/hd.0da931b622.js +0 -1
  309. package/dist/client/static/js/async/hk.ba7fb04d41.js +0 -1
  310. package/dist/client/static/js/async/hn.b645041e00.js +0 -1
  311. package/dist/client/static/js/async/ho.475a5e1ae4.js +0 -1
  312. package/dist/client/static/js/async/hp.e9fe2e395a.js +0 -2
  313. package/dist/client/static/js/async/hp.e9fe2e395a.js.LICENSE.txt +0 -17
  314. package/dist/client/static/js/async/hs.e7c59d8090.js +0 -41
  315. package/dist/client/static/js/async/i.7c148e8440.js +0 -1
  316. package/dist/client/static/js/async/id.573df20f29.js +0 -1
  317. package/dist/client/static/js/async/j.ab925882d8.js +0 -1
  318. package/dist/client/static/js/async/jd.63886a1f09.js +0 -1
  319. package/dist/client/static/js/async/jg.d86ebd4013.js +0 -86
  320. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  321. package/dist/client/static/js/async/jll.11313b455a.js +0 -1
  322. package/dist/client/static/js/async/js.67182759ef.js +0 -1
  323. package/dist/client/static/js/async/jsc.23b70d6f40.js +0 -1
  324. package/dist/client/static/js/async/jse.334588ff5d.js +0 -1
  325. package/dist/client/static/js/async/jz.72eb929106.js +0 -77
  326. package/dist/client/static/js/async/k.3210286401.js +0 -217
  327. package/dist/client/static/js/async/ka.c1990ecaa3.js +0 -115
  328. package/dist/client/static/js/async/katex-vendor.de6a87c308.js +0 -1
  329. package/dist/client/static/js/async/ku.29c8a59d0c.js +0 -1
  330. package/dist/client/static/js/async/ky.8dab1dade0.js +0 -1
  331. package/dist/client/static/js/async/l.12dd34e1be.js +0 -1
  332. package/dist/client/static/js/async/l9.949710adaf.js +0 -83
  333. package/dist/client/static/js/async/l9.949710adaf.js.LICENSE.txt +0 -13
  334. package/dist/client/static/js/async/ls.9d9a01026c.js +0 -104
  335. package/dist/client/static/js/async/m.eb2e6752e2.js +0 -28
  336. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +0 -11
  337. package/dist/client/static/js/async/m3.d2b05f9604.js +0 -2
  338. package/dist/client/static/js/async/m3.d2b05f9604.js.LICENSE.txt +0 -6
  339. package/dist/client/static/js/async/ma.2c261b04dd.js +0 -2
  340. package/dist/client/static/js/async/ma.2c261b04dd.js.LICENSE.txt +0 -6
  341. package/dist/client/static/js/async/me.8ace414f80.js +0 -23
  342. package/dist/client/static/js/async/ms.98960af4eb.js +0 -67
  343. package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +0 -12
  344. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  345. package/dist/client/static/js/async/n.a9f1c51701.js +0 -1
  346. package/dist/client/static/js/async/na.a36cd532d6.js +0 -1
  347. package/dist/client/static/js/async/nk.fc7e58325e.js +0 -1
  348. package/dist/client/static/js/async/o.36353dfb88.js +0 -1
  349. package/dist/client/static/js/async/oc.b73042e8bd.js +0 -8
  350. package/dist/client/static/js/async/og.8eb577893a.js +0 -193
  351. package/dist/client/static/js/async/p.48ecfcd14e.js +0 -1
  352. package/dist/client/static/js/async/px.fc6877924b.js +0 -1
  353. package/dist/client/static/js/async/q.22588845a9.js +0 -21
  354. package/dist/client/static/js/async/q.22588845a9.js.LICENSE.txt +0 -6
  355. package/dist/client/static/js/async/q5.97dc1481b6.js +0 -286
  356. package/dist/client/static/js/async/qs.2a9ddb3c72.js +0 -1
  357. package/dist/client/static/js/async/r.51010ad87c.js +0 -1
  358. package/dist/client/static/js/async/rd.cb6bf495bb.js +0 -2
  359. package/dist/client/static/js/async/rd.cb6bf495bb.js.LICENSE.txt +0 -4
  360. package/dist/client/static/js/async/s.0f395cdda5.js +0 -94
  361. package/dist/client/static/js/async/t.4dca0e2ba8.js +0 -1
  362. package/dist/client/static/js/async/t9.d44f57d518.js +0 -1
  363. package/dist/client/static/js/async/ta.973534bbec.js +0 -1
  364. package/dist/client/static/js/async/tr.e878658140.js +0 -309
  365. package/dist/client/static/js/async/u.0871ac5b22.js +0 -1
  366. package/dist/client/static/js/async/ud.3065bfee31.js +0 -2
  367. package/dist/client/static/js/async/ud.3065bfee31.js.LICENSE.txt +0 -4
  368. package/dist/client/static/js/async/up.ea990da0ff.js +0 -32
  369. package/dist/client/static/js/async/uw.8890abbabc.js +0 -1
  370. package/dist/client/static/js/async/v.7d28d3c241.js +0 -34
  371. package/dist/client/static/js/async/vp.9b13bf8610.js +0 -1
  372. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  373. package/dist/client/static/js/async/w.08ac210f60.js +0 -1
  374. package/dist/client/static/js/async/wt.ad92393a90.js +0 -1
  375. package/dist/client/static/js/async/x.563f72ca6b.js +0 -1
  376. package/dist/client/static/js/async/x0.475516418f.js +0 -1
  377. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  378. package/dist/client/static/js/async/xe.da8dcc2015.js +0 -2
  379. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +0 -11
  380. package/dist/client/static/js/async/xl.a3c562a58c.js +0 -1
  381. package/dist/client/static/js/async/xn.6510c0abe2.js +0 -2
  382. package/dist/client/static/js/async/xn.6510c0abe2.js.LICENSE.txt +0 -6
  383. package/dist/client/static/js/async/xn1.83a7784fe1.js +0 -10
  384. package/dist/client/static/js/async/xn1.83a7784fe1.js.LICENSE.txt +0 -11
  385. package/dist/client/static/js/async/xnl.feb0021e50.js +0 -2
  386. package/dist/client/static/js/async/xnl.feb0021e50.js.LICENSE.txt +0 -12
  387. package/dist/client/static/js/async/y.1a9ed7a5d8.js +0 -1
  388. package/dist/client/static/js/async/y6.43c57defe4.js +0 -1
  389. package/dist/client/static/js/async/yb.8c1342401d.js +0 -1
  390. package/dist/client/static/js/async/ybq.4491f2f2dc.js +0 -1
  391. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  392. package/dist/client/static/js/async/yq.917ddd7ba2.js +0 -1
  393. package/dist/client/static/js/async/ys.00687cf82d.js +0 -38
  394. package/dist/client/static/js/async/yv.d99d829136.js +0 -1
  395. package/dist/client/static/js/async/z.56f61a4374.js +0 -1
  396. package/dist/client/static/js/async/zi.5fdb07b86a.js +0 -24
  397. package/dist/client/static/js/async/zj.654f37e8ff.js +0 -1
  398. package/dist/client/static/js/async/zn.966d6f1cc6.js +0 -1
  399. package/dist/client/static/js/async/zo.26213b40c0.js +0 -1
  400. package/dist/client/static/js/async/zx.e9f4105e00.js +0 -1
  401. package/dist/client/static/js/index.e6a257402a.js +0 -38
  402. package/dist/client/static/js/index.e6a257402a.js.LICENSE.txt +0 -4
  403. package/dist/client/static/js/markdown-vendor.71e43278b3.js +0 -144
  404. package/dist/client/static/js/shiki-vendor.cb8452ddcd.js +0 -1
  405. package/src/client/components/settings/categories/provider-settings/Icons.tsx +0 -35
  406. package/src/client/hooks/settings/providers.ts +0 -326
  407. package/src/server/plugins/dev-assets.ts +0 -49
  408. package/src/shared/lib/code/editor-utils.ts +0 -16
  409. package/src/shared/lib/models/provider-presets.ts +0 -29
  410. /package/dist/client/{apple-touch-icon.png → static/apple-touch-icon-dskx5sr3.png} +0 -0
  411. /package/dist/client/{icon-192x192.png → static/icon-192x192-6gn5cr2f.png} +0 -0
  412. /package/dist/client/{icon.svg → static/icon-d07z9dtz.svg} +0 -0
  413. /package/dist/client/{manifest.webmanifest → static/manifest-bnyy75kt.webmanifest} +0 -0
  414. /package/{dist/client → public}/icon-512x512.png +0 -0
  415. /package/{dist/client → public}/icon-maskable-512x512.png +0 -0
  416. /package/{dist/client → public}/sw.js +0 -0
  417. /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
  418. /package/src/shared/lib/models/{provider-cleanup.ts → provider/cleanup.ts} +0 -0
  419. /package/src/shared/lib/models/{provider-label.ts → provider/label.ts} +0 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Line arithmetic over a buffer.
8
+ *
9
+ * Extracted from the find module because the editor's line commands (delete,
10
+ * move, duplicate, comment) and the highlighter need the same two facts — which
11
+ * line an offset is on, and where that line begins — and neither of them is a
12
+ * find concern. Keeping one implementation is what stops a line command from
13
+ * disagreeing with the gutter about where line 12 starts.
14
+ */
15
+
16
+ /** Half-open `[start, end)` offset range in a document. */
17
+ export interface TextRange {
18
+ start: number;
19
+ end: number;
20
+ }
21
+
22
+ /** Index of the line containing `offset` (0-based). */
23
+ export function lineIndexAt(text: string, offset: number): number {
24
+ const limit = Math.min(Math.max(offset, 0), text.length);
25
+ let line = 0;
26
+ for (let index = text.indexOf('\n'); index !== -1 && index < limit; index = text.indexOf('\n', index + 1)) {
27
+ line++;
28
+ }
29
+ return line;
30
+ }
31
+
32
+ /** Offset of the first character of line `lineIndex` (0-based). */
33
+ export function lineStartOffset(text: string, lineIndex: number): number {
34
+ let offset = 0;
35
+ for (let i = 0; i < lineIndex; i++) {
36
+ const next = text.indexOf('\n', offset);
37
+ if (next === -1) return text.length;
38
+ offset = next + 1;
39
+ }
40
+ return offset;
41
+ }
42
+
43
+ /** How many lines `text` holds; a trailing newline opens one more (empty) line. */
44
+ export function countLines(text: string): number {
45
+ let count = 1;
46
+ for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) count++;
47
+ return count;
48
+ }
49
+
50
+ /**
51
+ * The whole line containing `offset`, INCLUDING its trailing newline.
52
+ *
53
+ * Including the newline is what makes the range directly usable by the line
54
+ * commands: deleting it removes the line rather than blanking it, and moving it
55
+ * carries the line's own separator with it. A caret on the empty line a
56
+ * trailing newline opens (`"a\n"` at offset 2) yields an empty range there,
57
+ * which the commands treat as "the newline above" — the honest reading of a
58
+ * position that has no characters of its own.
59
+ */
60
+ export function lineRangeAt(text: string, offset: number): TextRange {
61
+ const lineStart = lineStartOffset(text, lineIndexAt(text, offset));
62
+ const lineEnd = text.indexOf('\n', lineStart);
63
+ if (lineEnd === -1) return { start: lineStart, end: text.length };
64
+ return { start: lineStart, end: lineEnd + 1 };
65
+ }
66
+
67
+ /**
68
+ * The ranges of every line a selection touches, in document order.
69
+ *
70
+ * A selection ending exactly at a line start does not touch that line — the
71
+ * same rule VS Code applies, and the one that keeps ⌘⇧L on a whole-line
72
+ * selection from splitting off an empty trailing entry.
73
+ */
74
+ export function lineRangesInSelection(text: string, selection: TextRange): TextRange[] {
75
+ const startLine = lineIndexAt(text, selection.start);
76
+ const lastOffset = selection.end > selection.start && text[selection.end - 1] === '\n' ? selection.end - 1 : selection.end;
77
+ const endLine = lineIndexAt(text, lastOffset);
78
+ const ranges: TextRange[] = [];
79
+ for (let line = startLine; line <= endLine; line++) {
80
+ ranges.push(lineRangeAt(text, lineStartOffset(text, line)));
81
+ }
82
+ return ranges;
83
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Find marks spliced into the highlighter's token markup.
8
+ *
9
+ * The cases that matter are the ones a naive implementation gets wrong: a match
10
+ * that spans several tokens (`const alpha` is three), a token that holds two
11
+ * matches, and text that has to be escaped on the way in. Every assertion is on
12
+ * the TEXT of the marks, because a match broken across tokens is several
13
+ * fragments by construction.
14
+ */
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+
18
+ import { tokenMarkup, MARK_CURRENT_OPEN, MARK_OPEN } from '@/shared/lib/code/editor/marks';
19
+
20
+ /** The text of every mark in `html`, in order. */
21
+ function marked(html: string): string[] {
22
+ return Array.from(html.matchAll(/<mark class="find-match"[^>]*>(.*?)<\/mark>/g), (match) => match[1]);
23
+ }
24
+
25
+ describe('tokenMarkup', () => {
26
+ test('returns escaped content unchanged when there are no marks', () => {
27
+ expect(tokenMarkup('a < b', 0, undefined, -1)).toBe('a &lt; b');
28
+ expect(tokenMarkup('a < b', 0, [], -1)).toBe('a &lt; b');
29
+ });
30
+
31
+ test('wraps the part of the token the match covers', () => {
32
+ expect(tokenMarkup('alpha', 0, [{ start: 0, end: 5 }], 0)).toBe(`${MARK_CURRENT_OPEN}alpha</mark>`);
33
+ });
34
+
35
+ test('splits a token that holds two matches', () => {
36
+ const html = tokenMarkup('a a', 0, [{ start: 0, end: 1 }, { start: 2, end: 3 }], -1);
37
+
38
+ expect(marked(html)).toEqual(['a', 'a']);
39
+ expect(html.match(/<mark/g)?.length).toBe(2);
40
+ });
41
+
42
+ test('clips a match that starts before the token', () => {
43
+ // `const alpha` spans three tokens; the fragment inside `alpha` is the tail
44
+ // of one match, and the markup must not repeat the part already emitted.
45
+ const html = tokenMarkup('alpha', 6, [{ start: 0, end: 11 }], 0);
46
+
47
+ expect(marked(html)).toEqual(['alpha']);
48
+ });
49
+
50
+ test('clips a match that runs past the token', () => {
51
+ const html = tokenMarkup('const', 0, [{ start: 0, end: 11 }], -1);
52
+
53
+ expect(marked(html)).toEqual(['const']);
54
+ });
55
+
56
+ test('escapes the matched text it wraps', () => {
57
+ expect(tokenMarkup('<', 0, [{ start: 0, end: 1 }], -1)).toContain('&lt;');
58
+ });
59
+
60
+ test('the current match and an extra selection are painted differently', () => {
61
+ // The caret can only be in the primary range, so the two must not look
62
+ // alike — the user has to see where their keystrokes will land.
63
+ expect(tokenMarkup('a', 0, [{ start: 0, end: 1 }], 0)).toContain('data-find-current');
64
+ expect(tokenMarkup('a', 0, [{ start: 0, end: 1 }], -1, 0)).toContain('data-occurrence');
65
+ expect(tokenMarkup('a', 0, [{ start: 0, end: 1 }], -1, 0)).not.toContain('data-find-current');
66
+ });
67
+
68
+ test('a plain match is neither current nor an occurrence', () => {
69
+ const html = tokenMarkup('a', 0, [{ start: 0, end: 1 }], -1);
70
+
71
+ expect(html).toContain(MARK_OPEN);
72
+ expect(html).not.toContain('data-');
73
+ });
74
+
75
+ test('marks are read in document order, so a match before the token is skipped', () => {
76
+ // The loop stops at the first mark that begins after the token: anything
77
+ // earlier cannot intersect it.
78
+ const html = tokenMarkup('beta', 6, [{ start: 0, end: 5 }, { start: 6, end: 10 }], -1);
79
+
80
+ expect(marked(html)).toEqual(['beta']);
81
+ });
82
+
83
+ test('a zero-width mark paints nothing', () => {
84
+ expect(marked(tokenMarkup('alpha', 0, [{ start: 2, end: 2 }], -1))).toEqual([]);
85
+ });
86
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Find matches spliced into the highlighter's own markup.
8
+ *
9
+ * The find widget's highlights ride on the same layer the tokens do, so a mark
10
+ * can never drift from the text it belongs to. That means walking each token's
11
+ * content and inserting `<mark>` around the parts of it a match covers — a
12
+ * match routinely spans several tokens (`const alpha` is three) and a token
13
+ * routinely holds several matches.
14
+ *
15
+ * `<mark>` is not decoration: it is an INLINE element with a background and no
16
+ * padding or border, which is what keeps the transparent `<textarea>` above the
17
+ * layer aligned with the glyphs underneath. Anything that changes the box —
18
+ * a block display, a border, an outline with an offset — moves the text under
19
+ * the caret and breaks editing.
20
+ *
21
+ * Kept out of `syntax-highlight.ts` so the tokenizer module stays under the
22
+ * repository's line ceiling and because this is the find feature's concern
23
+ * inside the highlight pipeline, not the highlighter's.
24
+ */
25
+
26
+ import { escapeCode } from '@/shared/lib/code/highlighter';
27
+ import type { FindMatch } from '@/shared/lib/code/editor/find';
28
+
29
+ /**
30
+ * How `highlightLines` paints its marks. Declared here rather than in the
31
+ * highlighter because the marks ARE this module's concern; the highlighter
32
+ * imports the type.
33
+ */
34
+ export interface HighlightLinesOptions {
35
+ /**
36
+ * Lines at or above this length are emitted as plain text instead of being
37
+ * tokenized — the guard a minified bundle (one line of hundreds of KB) needs.
38
+ */
39
+ maxLineLength?: number;
40
+ /**
41
+ * Match ranges (absolute offsets in `code`) to wrap in `<mark class="find-match">`.
42
+ * The find widget's highlights ride on the same markup the tokens do, so a
43
+ * match cannot drift from the text it belongs to — and an inline `<mark>`
44
+ * paints a background without moving the glyphs, which is what keeps the
45
+ * transparent `<textarea>` above aligned with this layer.
46
+ */
47
+ marks?: readonly FindMatch[];
48
+ /** Index into `marks` of the current match, marked `data-find-current`. */
49
+ currentMark?: number;
50
+ /**
51
+ * Marks from this index on are extra ⌘D SELECTIONS rather than find matches,
52
+ * and are painted as such. The caller appends them after the matches, so one
53
+ * index is all the split needs.
54
+ */
55
+ occurrenceFrom?: number;
56
+ }
57
+
58
+ export const MARK_OPEN = '<mark class="find-match">';
59
+ /** The current match is the one the caret/next-step is aimed at; the CSS paints it darker. */
60
+ export const MARK_CURRENT_OPEN = '<mark class="find-match" data-find-current="">';
61
+ /**
62
+ * An extra ⌘D range. It is not a find match — it is a second SELECTION, and it
63
+ * has to read as one: the caret can only be in the primary range, so painting
64
+ * them alike would leave the user unable to tell which one their keystrokes go
65
+ * to. The two differ only in colour; the geometry is shared, which is what
66
+ * keeps the layers aligned.
67
+ */
68
+ export const MARK_OCCURRENCE_OPEN = '<mark class="find-match" data-occurrence="">';
69
+
70
+ /**
71
+ * `content` with every intersecting mark spliced in, HTML-escaped.
72
+ *
73
+ * `tokenStart` is the token's absolute offset in the document; `marks` are in
74
+ * document order, which is what lets the loop stop at the first mark that
75
+ * begins after the token rather than scanning the whole list per token.
76
+ */
77
+ export function tokenMarkup(
78
+ content: string,
79
+ tokenStart: number,
80
+ marks: readonly FindMatch[] | undefined,
81
+ currentMark: number,
82
+ occurrenceFrom = -1,
83
+ ): string {
84
+ const tokenEnd = tokenStart + content.length;
85
+ if (!marks || marks.length === 0) return escapeCode(content);
86
+ let html = '';
87
+ let cursor = 0;
88
+ for (let index = 0; index < marks.length; index++) {
89
+ const mark = marks[index];
90
+ // Marks and tokens are both in document order: anything that ended before
91
+ // this token cannot intersect it.
92
+ if (mark.end <= tokenStart) continue;
93
+ if (mark.start >= tokenEnd) break;
94
+ // A zero-width range has no text to paint; emitting one produced an empty
95
+ // `<mark></mark>`, which is invisible but still a DOM node per occurrence.
96
+ if (mark.end <= mark.start) continue;
97
+ const from = Math.max(mark.start, tokenStart) - tokenStart;
98
+ const to = Math.min(mark.end, tokenEnd) - tokenStart;
99
+ if (to <= cursor) continue;
100
+ html += escapeCode(content.slice(cursor, from));
101
+ const open = index === currentMark ? MARK_CURRENT_OPEN : index >= occurrenceFrom && occurrenceFrom >= 0 ? MARK_OCCURRENCE_OPEN : MARK_OPEN;
102
+ html += open + escapeCode(content.slice(from, to)) + '</mark>';
103
+ cursor = to;
104
+ }
105
+ return html + escapeCode(content.slice(cursor));
106
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The multi-selection rules.
8
+ *
9
+ * ⌘D's two-stage behaviour is the one users notice immediately: the first press
10
+ * selects the word, the second adds the next occurrence. Getting that wrong
11
+ * leaves the word selected once and its neighbour selected twice, which reads
12
+ * as a duplicated cursor.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import {
18
+ growToNextOccurrence,
19
+ offsetBelow,
20
+ replicateAtRanges,
21
+ selectAllOccurrences,
22
+ } from '@/shared/lib/code/editor/occurrences';
23
+
24
+ describe('growToNextOccurrence', () => {
25
+ const text = 'alpha beta alpha gamma alpha';
26
+
27
+ test('the first press on a bare caret selects the word and adds nothing', () => {
28
+ expect(growToNextOccurrence(text, { start: 2, end: 2 }, [])).toEqual({
29
+ primary: { start: 0, end: 5 },
30
+ extras: [],
31
+ });
32
+ });
33
+
34
+ test('the second press adds the next occurrence', () => {
35
+ const grown = growToNextOccurrence(text, { start: 0, end: 5 }, []);
36
+ expect(grown?.primary).toEqual({ start: 0, end: 5 });
37
+ expect(grown?.extras).toEqual([{ start: 11, end: 16 }]);
38
+ });
39
+
40
+ test('each further press adds the one after it, never a duplicate', () => {
41
+ const grown = growToNextOccurrence(text, { start: 0, end: 5 }, [{ start: 11, end: 16 }]);
42
+ expect(grown?.extras).toEqual([
43
+ { start: 11, end: 16 },
44
+ { start: 23, end: 28 },
45
+ ]);
46
+ });
47
+
48
+ test('reports nothing when every occurrence is already selected', () => {
49
+ const extras = [
50
+ { start: 11, end: 16 },
51
+ { start: 23, end: 28 },
52
+ ];
53
+ expect(growToNextOccurrence(text, { start: 0, end: 5 }, extras)).toBeNull();
54
+ });
55
+
56
+ test('reports nothing on whitespace with no word beside it', () => {
57
+ expect(growToNextOccurrence(' ', { start: 1, end: 1 }, [])).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe('selectAllOccurrences', () => {
62
+ test('the first occurrence is the primary, the rest are extras', () => {
63
+ const all = selectAllOccurrences('alpha beta alpha', { start: 0, end: 5 });
64
+
65
+ expect(all?.primary).toEqual({ start: 0, end: 5 });
66
+ expect(all?.extras).toEqual([{ start: 11, end: 16 }]);
67
+ });
68
+
69
+ test('a bare caret takes the word under it', () => {
70
+ const all = selectAllOccurrences('alpha beta alpha', { start: 2, end: 2 });
71
+ expect(all?.primary).toEqual({ start: 0, end: 5 });
72
+ });
73
+
74
+ test('a one-off word yields the selection alone', () => {
75
+ const all = selectAllOccurrences('alpha beta', { start: 0, end: 5 });
76
+ expect(all?.primary).toEqual({ start: 0, end: 5 });
77
+ expect(all?.extras).toEqual([]);
78
+ });
79
+ });
80
+
81
+ describe('offsetBelow', () => {
82
+ test('keeps the column', () => {
83
+ expect(offsetBelow('one\ntwo\nthree', 1)).toBe(5);
84
+ });
85
+
86
+ test('clamps to the end of a shorter next line', () => {
87
+ // Column 6 does not exist on `ab`; the caret lands at its end rather than
88
+ // on a character the line never had.
89
+ expect(offsetBelow('longer line\nab\nnext', 6)).toBe(14);
90
+ });
91
+
92
+ test('reports nothing on the last line', () => {
93
+ expect(offsetBelow('one\ntwo', 5)).toBeNull();
94
+ });
95
+ });
96
+
97
+ describe('replicateAtRanges', () => {
98
+ test('reports nothing when there are no extra ranges', () => {
99
+ expect(replicateAtRanges('abc', 'abXc', { start: 2, end: 2 }, [])).toBeNull();
100
+ });
101
+
102
+ test('replays one insertion at every range and collapses them to carets', () => {
103
+ const applied = replicateAtRanges('alpha alpha', 'Xalpha alpha', { start: 0, end: 0 }, [{ start: 6, end: 6 }]);
104
+
105
+ expect(applied?.value).toBe('Xalpha Xalpha');
106
+ expect(applied?.caret).toBe(1);
107
+ expect(applied?.rest).toEqual([{ start: 8, end: 8 }]);
108
+ });
109
+
110
+ test('replays a replacement over the selected text', () => {
111
+ const applied = replicateAtRanges('alpha beta alpha', 'Z beta alpha', { start: 0, end: 5 }, [{ start: 11, end: 16 }]);
112
+
113
+ expect(applied?.value).toBe('Z beta Z');
114
+ // The caret sits after the inserted `Z`, which now ends the buffer.
115
+ expect(applied?.rest).toEqual([{ start: 8, end: 8 }]);
116
+ });
117
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The multi-selection rules: what ⌘D, ⌘⇧L and ⌘⇧D do to a buffer, and how one
8
+ * browser edit is replayed at every range.
9
+ *
10
+ * Pure on purpose. The hook that owns the ranges is a ref and a notify call;
11
+ * every decision about WHERE a range goes and what the buffer becomes lives
12
+ * here, so it is testable without a DOM and cannot drift between the keyboard
13
+ * path and the palette path (both call the same functions).
14
+ *
15
+ * The replication rule is the load-bearing one. The browser applies a keystroke
16
+ * at the primary range and reports the whole new buffer; diffing that against
17
+ * the old one recovers "what was typed", and the same replacement is replayed
18
+ * at every other range in one pass. Typing, pasting, deleting and IME
19
+ * composition therefore need no branch per case.
20
+ */
21
+
22
+ import {
23
+ diffEdit,
24
+ nextOccurrence,
25
+ occurrenceRanges,
26
+ replicateEdit,
27
+ wordRangeAt,
28
+ type TextRange,
29
+ } from '@/shared/lib/code/editor/commands';
30
+
31
+ /** What a selection command did: the primary range plus the extra ones. */
32
+ export interface OccurrenceSelection {
33
+ primary: TextRange;
34
+ extras: TextRange[];
35
+ }
36
+
37
+ /**
38
+ * ⌘D. With a bare caret the first press selects the word under it and stops —
39
+ * "select this word" and "and the next one too" are two different asks, and
40
+ * adding a range on the first press would leave the word selected once and its
41
+ * neighbour selected twice. With a selection, the next unselected occurrence is
42
+ * added.
43
+ *
44
+ * Null when there is no word and no selection, or when every occurrence is
45
+ * already selected: the caller then leaves the selection alone rather than
46
+ * cycling silently.
47
+ */
48
+ export function growToNextOccurrence(
49
+ text: string,
50
+ primary: TextRange,
51
+ extras: readonly TextRange[],
52
+ ): OccurrenceSelection | null {
53
+ const hasSelection = primary.end > primary.start;
54
+ const seed = hasSelection ? primary : wordRangeAt(text, primary.start);
55
+ if (!seed) return null;
56
+ const word = text.slice(seed.start, seed.end);
57
+ if (!word) return null;
58
+
59
+ if (!hasSelection) return { primary: seed, extras: [...extras] };
60
+
61
+ const next = nextOccurrence(text, word, [primary, ...extras]);
62
+ if (!next) return null;
63
+ return { primary, extras: [...extras, next] };
64
+ }
65
+
66
+ /**
67
+ * ⌘⇧L. Every occurrence becomes a range: the first is the primary (the textarea
68
+ * can only hold one selection) and the rest are extras. Null when there is
69
+ * nothing to select.
70
+ */
71
+ export function selectAllOccurrences(text: string, primary: TextRange): OccurrenceSelection | null {
72
+ const seed = primary.end > primary.start ? primary : wordRangeAt(text, primary.start);
73
+ if (!seed) return null;
74
+ const word = text.slice(seed.start, seed.end);
75
+ if (!word) return null;
76
+ const [first, ...rest] = occurrenceRanges(text, word);
77
+ if (!first) return null;
78
+ return { primary: first, extras: rest };
79
+ }
80
+
81
+ /**
82
+ * ⌘⇧D. The offset directly below `offset`, keeping the column — and clamped to
83
+ * that line's end, so a caret at the end of a short line lands at the end of the
84
+ * next one rather than on a character it never had. Null on the last line.
85
+ */
86
+ export function offsetBelow(text: string, offset: number): number | null {
87
+ const lineEnd = text.indexOf('\n', offset);
88
+ if (lineEnd === -1) return null;
89
+ const column = offset - (text.lastIndexOf('\n', offset - 1) + 1);
90
+ const nextLineStart = lineEnd + 1;
91
+ const nextLineEnd = text.indexOf('\n', nextLineStart);
92
+ return Math.min(nextLineStart + column, nextLineEnd === -1 ? text.length : nextLineEnd);
93
+ }
94
+
95
+ /**
96
+ * Replay the browser's edit (`before` → `after`, applied at `primaryBefore`) at
97
+ * every extra range. Null when there are no extras, which is the caller's cue to
98
+ * keep the ordinary single-selection path.
99
+ */
100
+ export function replicateAtRanges(
101
+ before: string,
102
+ after: string,
103
+ primaryBefore: TextRange,
104
+ extras: readonly TextRange[],
105
+ ): { value: string; caret: number; rest: TextRange[] } | null {
106
+ if (extras.length === 0) return null;
107
+ const diff = diffEdit(before, after, primaryBefore);
108
+ const applied = replicateEdit(before, [{ start: diff.start, end: diff.end }, ...extras], diff.inserted);
109
+ const [caret, ...rest] = applied.carets;
110
+ return { value: applied.value, caret, rest: rest.map((offset) => ({ start: offset, end: offset })) };
111
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The command palette's list.
8
+ *
9
+ * The palette is a VIEW of the keymap, so the property that matters is that it
10
+ * cannot list something the keymap does not bind — an entry that does nothing
11
+ * reads as a broken editor rather than a missing feature. The filter is the
12
+ * other half: a reader looks for a command by its name OR by the shortcut they
13
+ * already know, and both have to find it.
14
+ */
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+
18
+ import { buildPaletteEntries, filterPaletteEntries } from '@/shared/lib/code/editor/palette';
19
+ import { EDITOR_KEY_BINDINGS } from '@/shared/lib/code/editor/keymap';
20
+
21
+ describe('buildPaletteEntries', () => {
22
+ test('every entry is a bound command, labelled with the keymap’s own words', () => {
23
+ const entries = buildPaletteEntries(true);
24
+
25
+ expect(entries.length).toBeGreaterThan(0);
26
+ for (const entry of entries) {
27
+ const binding = EDITOR_KEY_BINDINGS.find((candidate) => candidate.command === entry.command);
28
+ expect(binding).toBeDefined();
29
+ expect(entry.label).toBe(binding!.label);
30
+ // The chord is derived from the same stroke the handler matches, so a
31
+ // listed shortcut is one that actually fires.
32
+ expect(entry.chord.length).toBeGreaterThan(0);
33
+ }
34
+ });
35
+
36
+ test('the chord shown is the platform’s own', () => {
37
+ const mac = buildPaletteEntries(true).find((entry) => entry.command === 'selectNextOccurrence');
38
+ const other = buildPaletteEntries(false).find((entry) => entry.command === 'selectNextOccurrence');
39
+
40
+ expect(mac?.chord).toBe('⌘D');
41
+ expect(other?.chord).toBe('Ctrl+D');
42
+ });
43
+
44
+ test('no command is listed twice', () => {
45
+ const commands = buildPaletteEntries(true).map((entry) => entry.command);
46
+ expect(new Set(commands).size).toBe(commands.length);
47
+ });
48
+
49
+ test('a command the keymap does not bind is not listed', () => {
50
+ // `selectWord` has no binding by design; listing it would promise a chord.
51
+ const commands = buildPaletteEntries(true).map((entry) => entry.command);
52
+ expect(commands).not.toContain('selectWord');
53
+ expect(commands).not.toContain('escape');
54
+ });
55
+ });
56
+
57
+ describe('filterPaletteEntries', () => {
58
+ const entries = buildPaletteEntries(true);
59
+
60
+ test('an empty query lists everything, in the palette’s own order', () => {
61
+ expect(filterPaletteEntries(entries, '')).toBe(entries);
62
+ expect(filterPaletteEntries(entries, ' ')).toBe(entries);
63
+ });
64
+
65
+ test('matches on the label', () => {
66
+ const found = filterPaletteEntries(entries, 'occurrence').map((entry) => entry.command);
67
+ expect(found).toContain('selectNextOccurrence');
68
+ expect(found).toContain('selectAllOccurrences');
69
+ });
70
+
71
+ test('matches on the shortcut, which is how a reader who knows the chord looks', () => {
72
+ const found = filterPaletteEntries(entries, '⌘D').map((entry) => entry.command);
73
+ expect(found).toContain('selectNextOccurrence');
74
+ });
75
+
76
+ test('every word has to match, so a two-word query narrows', () => {
77
+ const broad = filterPaletteEntries(entries, 'line');
78
+ const narrow = filterPaletteEntries(entries, 'line up');
79
+
80
+ expect(narrow.length).toBeLessThan(broad.length);
81
+ expect(narrow.map((entry) => entry.command)).toContain('moveLineUp');
82
+ });
83
+
84
+ test('a query nothing matches yields an empty list rather than everything', () => {
85
+ expect(filterPaletteEntries(entries, 'zzzz')).toEqual([]);
86
+ });
87
+
88
+ test('matching ignores case', () => {
89
+ expect(filterPaletteEntries(entries, 'UNDO').map((entry) => entry.command)).toEqual(['undo']);
90
+ });
91
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The command palette's list, as data.
8
+ *
9
+ * The palette is a VIEW of the keymap rather than a second list to maintain, so
10
+ * the rules live here — pure, testable, and shared with the component. The
11
+ * property that matters most is negative: an entry the keymap does not bind must
12
+ * never appear, because a palette entry that does nothing reads as a broken
13
+ * editor rather than a missing feature.
14
+ */
15
+
16
+ import { EDITOR_KEY_BINDINGS, type EditorCommand } from '@/shared/lib/code/editor/keymap';
17
+ import { describeBinding } from '@/shared/lib/ui/key-binding';
18
+
19
+ export interface PaletteEntry {
20
+ command: EditorCommand;
21
+ label: string;
22
+ chord: string;
23
+ }
24
+
25
+ /**
26
+ * The palette's running order: what a reader reaches for most, first. The
27
+ * commands are named here rather than derived from the keymap's own order, which
28
+ * groups chords by family for readability rather than by frequency of use.
29
+ */
30
+ export const PALETTE_ORDER: readonly EditorCommand[] = [
31
+ 'find',
32
+ 'replace',
33
+ 'selectNextOccurrence',
34
+ 'selectAllOccurrences',
35
+ 'insertCursorBelow',
36
+ 'toggleComment',
37
+ 'moveLineUp',
38
+ 'moveLineDown',
39
+ 'copyLineUp',
40
+ 'copyLineDown',
41
+ 'deleteLine',
42
+ 'insertLineBelow',
43
+ 'insertLineAbove',
44
+ 'indent',
45
+ 'outdent',
46
+ 'toggleWordWrap',
47
+ 'toggleCaseSensitive',
48
+ 'toggleWholeWord',
49
+ 'toggleRegex',
50
+ 'undo',
51
+ 'redo',
52
+ 'lineStart',
53
+ 'lineEnd',
54
+ 'documentStart',
55
+ 'documentEnd',
56
+ 'selectToLineStart',
57
+ 'selectToLineEnd',
58
+ 'selectToDocumentStart',
59
+ 'selectToDocumentEnd',
60
+ 'nextMatch',
61
+ 'previousMatch',
62
+ 'replaceOne',
63
+ 'replaceAll',
64
+ ];
65
+
66
+ /**
67
+ * The entries, built from the keymap so a command cannot be listed and unbound.
68
+ * A command in `PALETTE_ORDER` that the keymap dropped is skipped rather than
69
+ * rendered with an empty chord.
70
+ */
71
+ export function buildPaletteEntries(isMac: boolean): PaletteEntry[] {
72
+ return PALETTE_ORDER.flatMap((command) => {
73
+ const binding = EDITOR_KEY_BINDINGS.find((entry) => entry.command === command);
74
+ if (!binding) return [];
75
+ return [{ command, label: binding.label, chord: describeBinding(binding, isMac) }];
76
+ });
77
+ }
78
+
79
+ /**
80
+ * The entries a query matches. Matched on the label, the chord and the command
81
+ * name: typing `⌘d` finds the same command as typing `occurrence`, which is how
82
+ * a reader who already knows the shortcut looks for it.
83
+ */
84
+ export function filterPaletteEntries(entries: readonly PaletteEntry[], query: string): readonly PaletteEntry[] {
85
+ const needle = query.trim().toLowerCase();
86
+ if (!needle) return entries;
87
+ const parts = needle.split(/\s+/);
88
+ return entries.filter((entry) => {
89
+ const haystack = `${entry.label} ${entry.chord} ${entry.command}`.toLowerCase();
90
+ return parts.every((part) => haystack.includes(part));
91
+ });
92
+ }