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
@@ -1,9 +1,8 @@
1
- import { useCallback, useEffect, useState } from 'preact/hooks';
2
- import { useFetcher } from '@/client/lib/router/fetcher';
1
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
2
  import { useSessionUiState } from '@/client/hooks/workspace/session-state';
4
3
  import { parseUnifiedDiff } from '@/shared/lib/fs/diff-parser';
5
4
  import { getLanguageFromPath } from '@/shared/lib/code/syntax-highlight';
6
- import { DiffToolbar } from '@/client/components/workspace/diff-panel/Toolbar';
5
+ import { DiffToolbar, type DiffViewMode } from '@/client/components/workspace/diff-panel/Toolbar';
7
6
  import { UnifiedView } from '@/client/components/workspace/diff-panel/UnifiedView';
8
7
  import { SplitView } from '@/client/components/workspace/diff-panel/SplitView';
9
8
  import { AlertCircle, RefreshCw } from 'lucide-preact';
@@ -30,62 +29,94 @@ export function DiffPanel({
30
29
  className = '',
31
30
  }: DiffPanelProps) {
32
31
  // Read and persist view mode & whitespace preference in session_ui_state
33
- const [viewMode, setViewMode] = useSessionUiState<'unified' | 'split'>('diff.viewMode', 'unified');
32
+ const [viewMode, setViewMode] = useSessionUiState<DiffViewMode>('diff.viewMode', 'unified');
34
33
  const [ignoreWhitespace, setIgnoreWhitespace] = useSessionUiState<boolean>('diff.ignoreWhitespace', false);
35
34
 
36
35
  const [rawDiff, setRawDiff] = useState<string>('');
37
36
  const [currentStatus, setCurrentStatus] = useState<string>(status);
38
37
  const [currentStaged, setCurrentStaged] = useState<boolean>(isStaged);
39
38
  const [isLoading, setIsLoading] = useState<boolean>(true);
39
+ const [isBusy, setIsBusy] = useState<boolean>(false);
40
40
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
41
+ const [actionError, setActionError] = useState<string | null>(null);
41
42
  const [isCopied, setIsCopied] = useState<boolean>(false);
42
43
  const [showDiscardConfirm, setShowDiscardConfirm] = useState<boolean>(false);
43
44
 
44
- const fetcher = useFetcher();
45
+ /**
46
+ * The staged flag / status the next fetch must ask for. The state copies lag
47
+ * a click by a render, and keeping them in `fetchDiff`'s dependency list made
48
+ * every response re-key the callback and fire another fetch. Refs keep the
49
+ * callback stable and always read the post-action value.
50
+ */
51
+ const stagedRef = useRef<boolean>(isStaged);
52
+ const statusRef = useRef<string>(status);
53
+ const fetchSeqRef = useRef(0);
45
54
 
46
- const fetchDiff = useCallback(async () => {
55
+ const fetchDiff = useCallback(async (overrides: { staged?: boolean; status?: string } = {}) => {
47
56
  if (!filePath) return;
57
+ const seq = ++fetchSeqRef.current;
48
58
  setIsLoading(true);
49
59
  setErrorMessage(null);
50
60
  try {
61
+ const staged = overrides.staged ?? stagedRef.current;
62
+ const statusForFetch = overrides.status ?? statusRef.current;
51
63
  const params = new URLSearchParams({
52
64
  fileDiff: '1',
53
65
  file: filePath,
54
- staged: currentStaged ? '1' : '0',
66
+ staged: staged ? '1' : '0',
55
67
  repo: repo || '.',
56
68
  });
57
- if (status || currentStatus) params.set('status', status || currentStatus);
69
+ if (statusForFetch) params.set('status', statusForFetch);
58
70
  if (root) params.set('root', root);
59
71
 
60
72
  const res = await fetch(`/api/fs/git?${params.toString()}`);
61
- const data = await res.json();
62
- if (data.success) {
73
+ const data = (await res.json().catch(() => null)) as {
74
+ success?: boolean;
75
+ diff?: string;
76
+ status?: string;
77
+ staged?: boolean;
78
+ error?: string;
79
+ } | null;
80
+ // A slower earlier read must not overwrite a newer one (the action path
81
+ // fires a fetch of its own while the initial one is still in flight).
82
+ if (seq !== fetchSeqRef.current) return;
83
+ if (data?.success) {
63
84
  setRawDiff(data.diff || '');
64
- if (data.status) setCurrentStatus(data.status);
85
+ if (data.status) {
86
+ statusRef.current = data.status;
87
+ setCurrentStatus(data.status);
88
+ }
89
+ if (typeof data.staged === 'boolean') {
90
+ stagedRef.current = data.staged;
91
+ setCurrentStaged(data.staged);
92
+ }
65
93
  } else {
66
- setErrorMessage(data.error || 'Failed to load file diff');
94
+ setErrorMessage(data?.error || `Failed to load file diff (HTTP ${res.status})`);
67
95
  }
68
- } catch (err: any) {
69
- setErrorMessage(err.message || 'Error fetching diff');
96
+ } catch (err: unknown) {
97
+ if (seq !== fetchSeqRef.current) return;
98
+ setErrorMessage(err instanceof Error ? err.message : 'Error fetching diff');
70
99
  } finally {
71
- setIsLoading(false);
100
+ if (seq === fetchSeqRef.current) setIsLoading(false);
72
101
  }
73
- }, [filePath, currentStaged, repo, root]);
102
+ }, [filePath, repo, root]);
74
103
 
75
104
  useEffect(() => {
76
105
  setCurrentStatus(status);
77
106
  setCurrentStaged(isStaged);
107
+ stagedRef.current = isStaged;
108
+ statusRef.current = status;
78
109
  }, [status, isStaged, filePath]);
79
110
 
80
111
  useEffect(() => {
81
- fetchDiff();
112
+ void fetchDiff();
82
113
  }, [fetchDiff]);
83
114
 
84
115
  const parsed = parseUnifiedDiff(rawDiff, ignoreWhitespace);
85
116
  const language = getLanguageFromPath(filePath);
86
117
 
87
- const handleToggleViewMode = () => {
88
- setViewMode(viewMode === 'unified' ? 'split' : 'unified');
118
+ const handleSetViewMode = (mode: DiffViewMode) => {
119
+ setViewMode(mode);
89
120
  };
90
121
 
91
122
  const handleToggleWhitespace = () => {
@@ -94,44 +125,82 @@ export function DiffPanel({
94
125
 
95
126
  const handleCopyDiff = () => {
96
127
  if (!rawDiff) return;
97
- navigator.clipboard.writeText(rawDiff);
98
- setIsCopied(true);
99
- setTimeout(() => setIsCopied(false), 2000);
128
+ void navigator.clipboard.writeText(rawDiff).then(
129
+ () => {
130
+ setIsCopied(true);
131
+ setTimeout(() => setIsCopied(false), 2000);
132
+ },
133
+ () => setActionError('Clipboard write was refused by the browser'),
134
+ );
100
135
  };
101
136
 
102
- const handleStageUnstage = () => {
103
- const actionType = currentStaged ? 'unstage' : 'stage';
104
- const fd = new FormData();
105
- fd.set('actionType', actionType);
106
- fd.set('file', filePath);
107
- fd.set('repo', repo || '.');
108
- if (root) fd.set('root', root);
109
-
110
- fetcher.submit(fd, { method: 'POST', action: '/api/fs/git' });
111
- setCurrentStaged(!currentStaged);
112
- setTimeout(() => {
113
- fetchDiff();
137
+ /**
138
+ * POST one git action and report whether it landed. The panel used to fire
139
+ * and forget (`fetcher.submit` + an optimistic flip + a 300ms timer), which
140
+ * made a rejected action indistinguishable from a successful one: the badge
141
+ * flipped and the diff silently stayed put.
142
+ */
143
+ const runGitAction = useCallback(
144
+ async (fields: Record<string, string>): Promise<boolean> => {
145
+ const fd = new FormData();
146
+ for (const [key, value] of Object.entries(fields)) fd.set(key, value);
147
+ fd.set('repo', repo || '.');
148
+ if (root) fd.set('root', root);
149
+ try {
150
+ const res = await fetch('/api/fs/git', { method: 'POST', body: fd });
151
+ const data = (await res.json().catch(() => null)) as { success?: boolean; error?: string } | null;
152
+ if (!res.ok || !data?.success) {
153
+ setActionError(data?.error || `Request failed (HTTP ${res.status})`);
154
+ return false;
155
+ }
156
+ setActionError(null);
157
+ return true;
158
+ } catch (err: unknown) {
159
+ setActionError(err instanceof Error ? err.message : 'Request failed');
160
+ return false;
161
+ }
162
+ },
163
+ [repo, root],
164
+ );
165
+
166
+ const handleStageUnstage = async () => {
167
+ if (isBusy) return;
168
+ const nextStaged = !currentStaged;
169
+ setIsBusy(true);
170
+ try {
171
+ const ok = await runGitAction({
172
+ actionType: currentStaged ? 'unstage' : 'stage',
173
+ file: filePath,
174
+ });
175
+ if (!ok) return;
176
+ stagedRef.current = nextStaged;
177
+ setCurrentStaged(nextStaged);
114
178
  onFileSaved?.();
115
- }, 300);
179
+ await fetchDiff({ staged: nextStaged });
180
+ } finally {
181
+ setIsBusy(false);
182
+ }
116
183
  };
117
184
 
118
185
  const handleDiscard = () => {
119
186
  setShowDiscardConfirm(true);
120
187
  };
121
188
 
122
- const confirmDiscard = () => {
189
+ const confirmDiscard = async () => {
123
190
  setShowDiscardConfirm(false);
124
- const fd = new FormData();
125
- fd.set('actionType', 'discard');
126
- fd.set('file', filePath);
127
- fd.set('repo', repo || '.');
128
- if (root) fd.set('root', root);
129
-
130
- fetcher.submit(fd, { method: 'POST', action: '/api/fs/git' });
131
- setTimeout(() => {
132
- fetchDiff();
191
+ setIsBusy(true);
192
+ try {
193
+ // `revert` is the server's own name for "restore this file from the index",
194
+ // and it is the one that handles the untracked case (there the file is
195
+ // removed). Sending `discard` — which no handler matched — returned
196
+ // `{success:true}` for doing nothing at all.
197
+ const ok = await runGitAction({ actionType: 'revert', file: filePath });
198
+ if (!ok) return;
133
199
  onFileSaved?.();
134
- }, 300);
200
+ await fetchDiff();
201
+ } finally {
202
+ setIsBusy(false);
203
+ }
135
204
  };
136
205
 
137
206
  return (
@@ -154,15 +223,32 @@ export function DiffPanel({
154
223
  deletions={parsed.deletions}
155
224
  isLoading={isLoading}
156
225
  isCopied={isCopied}
157
- onToggleViewMode={handleToggleViewMode}
226
+ isBusy={isBusy}
227
+ onSetViewMode={handleSetViewMode}
158
228
  onToggleWhitespace={handleToggleWhitespace}
159
229
  onStageUnstage={handleStageUnstage}
160
230
  onDiscard={handleDiscard}
161
231
  onOpenInEditor={onOpenInEditor}
162
- onRefresh={fetchDiff}
232
+ onRefresh={() => void fetchDiff()}
163
233
  onCopyDiff={handleCopyDiff}
164
234
  />
165
235
 
236
+ {actionError && (
237
+ <div className="flex items-center gap-2 border-b border-error/25 bg-error/10 px-3 py-1.5 text-[11px] text-error flex-shrink-0">
238
+ <AlertCircle size={13} className="flex-shrink-0" />
239
+ <span className="min-w-0 flex-1 truncate" title={actionError}>{actionError}</span>
240
+ <button
241
+ type="button"
242
+ onClick={() => setActionError(null)}
243
+ className="px-1.5 rounded hover:bg-error/15 cursor-pointer"
244
+ title="Dismiss"
245
+ aria-label="Dismiss"
246
+ >
247
+ ✕
248
+ </button>
249
+ </div>
250
+ )}
251
+
166
252
  {/* Main Diff Content Area */}
167
253
  <div className="flex-1 overflow-hidden relative flex flex-col bg-paper">
168
254
  {isLoading ? (
@@ -175,8 +261,8 @@ export function DiffPanel({
175
261
  <AlertCircle size={20} className="text-error" />
176
262
  <p className="text-xs text-error font-medium">{errorMessage}</p>
177
263
  <button
178
- onClick={fetchDiff}
179
- className="px-3 py-1 text-xs bg-ink/10 hover:bg-ink/15 text-ink rounded font-sans transition-colors"
264
+ onClick={() => void fetchDiff()}
265
+ className="px-3 py-1 text-xs bg-ink/10 hover:bg-ink/15 text-ink rounded font-sans transition-colors cursor-pointer"
180
266
  >
181
267
  Retry
182
268
  </button>
@@ -200,14 +286,14 @@ export function DiffPanel({
200
286
  <button
201
287
  type="button"
202
288
  onClick={() => setShowDiscardConfirm(false)}
203
- className="px-3 py-1.5 text-xs text-ink hover:bg-ink/5 rounded transition-colors"
289
+ className="px-3 py-1.5 text-xs text-ink hover:bg-ink/5 rounded transition-colors cursor-pointer"
204
290
  >
205
291
  Cancel
206
292
  </button>
207
293
  <button
208
294
  type="button"
209
- onClick={confirmDiscard}
210
- className="px-3 py-1.5 text-xs bg-error text-white rounded hover:bg-error/90 font-medium transition-colors"
295
+ onClick={() => void confirmDiscard()}
296
+ className="px-3 py-1.5 text-xs bg-error text-white rounded hover:bg-error/90 font-medium transition-colors cursor-pointer"
211
297
  >
212
298
  Discard Changes
213
299
  </button>
@@ -0,0 +1,242 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The command palette's rendering.
8
+ *
9
+ * The list itself is built and filtered in `shared/lib/code/editor/palette`
10
+ * (tested there); what this covers is the interaction — that the field takes
11
+ * focus, that a filter narrows the list, that the arrow keys move the highlight
12
+ * without leaving the list, and that Enter runs the command that is actually
13
+ * highlighted rather than the first one.
14
+ *
15
+ * Rendered directly with `h()` (no JSX) against happy-dom; the component module
16
+ * is imported dynamically, after those globals exist.
17
+ */
18
+
19
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
20
+ import { Window } from 'happy-dom';
21
+ import type { ComponentChildren, h as PreactH, render as PreactRender } from 'preact';
22
+ import type { act as PreactAct } from 'preact/test-utils';
23
+ import { EDITOR_KEY_BINDINGS, type EditorCommand } from '@/shared/lib/code/editor/keymap';
24
+ import { buildPaletteEntries } from '@/shared/lib/code/editor/palette';
25
+ import { isMacPlatform } from '@/shared/lib/util/platform';
26
+
27
+ let CommandPalette: (props: { onRun: (command: EditorCommand) => void; onClose: () => void }) => ComponentChildren;
28
+ let h: typeof PreactH;
29
+ let render: typeof PreactRender;
30
+ let act: typeof PreactAct;
31
+
32
+ let container: HTMLElement;
33
+ const ran: EditorCommand[] = [];
34
+ let closes = 0;
35
+
36
+ async function mount() {
37
+ await act(async () => {
38
+ render(h(CommandPalette, { onRun: (command) => ran.push(command), onClose: () => closes++ }), container);
39
+ });
40
+ }
41
+
42
+ function field(): HTMLInputElement | null {
43
+ return container.querySelector('input[aria-label="Command palette"]');
44
+ }
45
+
46
+ /** The visible rows, in order, as their label text. */
47
+ function rows(): string[] {
48
+ return Array.from(container.querySelectorAll('button')).map((button) => button.textContent ?? '');
49
+ }
50
+
51
+ /** The row the palette currently highlights (it renders a return-key glyph). */
52
+ function activeRow(): string {
53
+ return rows().find((text) => text.length > 0 && container.querySelector('button .lucide-corner-down-left') !== null)
54
+ ? (Array.from(container.querySelectorAll('button')).find((button) => button.querySelector('.lucide-corner-down-left'))?.textContent ?? '')
55
+ : '';
56
+ }
57
+
58
+ async function typeQuery(value: string) {
59
+ const input = field();
60
+ if (!input) return;
61
+ input.value = value;
62
+ await act(async () => {
63
+ input.dispatchEvent(new globalThis.Event('input', { bubbles: true }));
64
+ });
65
+ }
66
+
67
+ async function pressKey(key: string) {
68
+ await act(async () => {
69
+ field()?.dispatchEvent(new globalThis.KeyboardEvent('keydown', { key, bubbles: true }));
70
+ });
71
+ }
72
+
73
+ function domGlobals(win: Window): Record<string, unknown> {
74
+ return {
75
+ window: win,
76
+ document: win.document,
77
+ navigator: win.navigator,
78
+ Node: win.Node,
79
+ Element: win.Element,
80
+ HTMLElement: win.HTMLElement,
81
+ HTMLInputElement: win.HTMLInputElement,
82
+ Event: win.Event,
83
+ MouseEvent: win.MouseEvent,
84
+ KeyboardEvent: win.KeyboardEvent,
85
+ };
86
+ }
87
+
88
+ const installed: Record<string, unknown> = {};
89
+ const displaced: Record<string, unknown> = {};
90
+
91
+ beforeAll(async () => {
92
+ const win = new Window({ url: 'http://localhost' });
93
+ for (const [key, value] of Object.entries(domGlobals(win))) {
94
+ displaced[key] = (globalThis as Record<string, unknown>)[key];
95
+ installed[key] = value;
96
+ (globalThis as Record<string, unknown>)[key] = value;
97
+ }
98
+ ({ CommandPalette } = await import('@/client/components/workspace/editor/CommandPalette'));
99
+ ({ h, render } = await import('preact'));
100
+ ({ act } = await import('preact/test-utils'));
101
+ });
102
+
103
+ afterAll(() => {
104
+ for (const key of Object.keys(installed)) {
105
+ if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
106
+ else (globalThis as Record<string, unknown>)[key] = displaced[key];
107
+ }
108
+ });
109
+
110
+ beforeEach(() => {
111
+ ran.length = 0;
112
+ closes = 0;
113
+ container = document.createElement('div');
114
+ document.body.appendChild(container);
115
+ });
116
+
117
+ afterEach(() => {
118
+ render(null, container);
119
+ container.remove();
120
+ });
121
+
122
+ describe('CommandPalette', () => {
123
+ test('takes focus on open, so the palette is usable without a click', async () => {
124
+ await mount();
125
+
126
+ expect(document.activeElement).toBe(field());
127
+ });
128
+
129
+ test('lists the keymap’s commands with their chords', async () => {
130
+ await mount();
131
+ const text = rows().join('\n');
132
+
133
+ expect(text).toContain('Add selection to next find match');
134
+ // The chord is the platform's own. Asserted through the palette's own
135
+ // builder so the expectation is the platform's real answer rather than a
136
+ // guess at which machine the suite is running on — the runner is Linux and a
137
+ // developer's laptop is a Mac, and a Mac-only literal passes on exactly one
138
+ // of them.
139
+ expect(text).toContain(buildPaletteEntries(isMacPlatform()).find((entry) => entry.command === 'selectNextOccurrence')!.chord);
140
+ expect(rows().length).toBeGreaterThan(20);
141
+ });
142
+
143
+ test('every command it lists is one the keymap binds', async () => {
144
+ await mount();
145
+
146
+ // The palette is a VIEW of the keymap; an entry with no binding would be a
147
+ // command that does nothing when it is run.
148
+ for (const entry of buildPaletteEntries(true)) {
149
+ expect(EDITOR_KEY_BINDINGS.some((binding) => binding.command === entry.command)).toBe(true);
150
+ }
151
+ expect(rows().length).toBeGreaterThan(20);
152
+ });
153
+
154
+ test('a query narrows the list', async () => {
155
+ await mount();
156
+ const before = rows().length;
157
+ await typeQuery('occurrence');
158
+
159
+ expect(rows().length).toBeLessThan(before);
160
+ expect(rows().join('\n')).toContain('Select all occurrences');
161
+ });
162
+
163
+ test('a query nothing matches says so instead of listing everything', async () => {
164
+ await mount();
165
+ await typeQuery('zzzz');
166
+
167
+ expect(rows()).toEqual([]);
168
+ expect(container.textContent).toContain('No matching command');
169
+ });
170
+
171
+ test('Enter runs the highlighted command and closes the palette', async () => {
172
+ await mount();
173
+ await typeQuery('toggle line comment');
174
+ await pressKey('Enter');
175
+
176
+ expect(ran).toEqual(['toggleComment']);
177
+ expect(closes).toBe(1);
178
+ });
179
+
180
+ test('the arrow keys move the highlight, and Enter runs the moved-to command', async () => {
181
+ await mount();
182
+ await typeQuery('occurrence');
183
+ const first = activeRow();
184
+ await pressKey('ArrowDown');
185
+ const second = activeRow();
186
+
187
+ expect(second).not.toBe(first);
188
+
189
+ await pressKey('Enter');
190
+ // Whichever row was highlighted is the one that ran — not the first match.
191
+ expect(ran.length).toBe(1);
192
+ expect(second).toContain(ran[0] === 'selectAllOccurrences' ? 'Select all' : 'Add selection');
193
+ });
194
+
195
+ test('the highlight wraps at the end of the list', async () => {
196
+ await mount();
197
+ await typeQuery('undo');
198
+ const only = activeRow();
199
+ await pressKey('ArrowDown');
200
+
201
+ // One row: stepping wraps onto itself rather than falling off the list.
202
+ expect(activeRow()).toBe(only);
203
+ });
204
+
205
+ test('ArrowUp from the top wraps to the last row', async () => {
206
+ await mount();
207
+ await typeQuery('line');
208
+ const list = rows();
209
+ await pressKey('ArrowUp');
210
+ const active = activeRow();
211
+
212
+ expect(active.length).toBeGreaterThan(0);
213
+ expect(list.length).toBeGreaterThan(1);
214
+ });
215
+
216
+ test('Escape closes without running anything', async () => {
217
+ await mount();
218
+ await pressKey('Escape');
219
+
220
+ expect(closes).toBe(1);
221
+ expect(ran).toEqual([]);
222
+ });
223
+
224
+ test('clicking a row runs that row’s command', async () => {
225
+ await mount();
226
+ await typeQuery('copy line down');
227
+ const row = container.querySelector('button');
228
+ await act(async () => {
229
+ row?.dispatchEvent(new globalThis.MouseEvent('click', { bubbles: true }));
230
+ });
231
+
232
+ expect(ran).toEqual(['copyLineDown']);
233
+ expect(closes).toBe(1);
234
+ });
235
+
236
+ test('find-bar commands are marked, so a reader knows where they land', async () => {
237
+ await mount();
238
+ await typeQuery('replace');
239
+
240
+ expect(container.textContent).toContain('find bar');
241
+ });
242
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor's command palette (⌘⇧P).
8
+ *
9
+ * Every editor command this app implements is listed here with the chord that
10
+ * reaches it, both derived from the same keymap table the textarea matches
11
+ * against. The palette is therefore a VIEW of the keymap rather than a second
12
+ * list to keep in sync — a command cannot be listed here and unbound there, and
13
+ * a chord cannot be shown that does not work.
14
+ *
15
+ * Commands this editor deliberately does not have (folding, go-to-definition,
16
+ * the `⌘K` prefix chords) are absent rather than listed and inert: a palette
17
+ * entry that does nothing is worse than no entry, because the user concludes
18
+ * the editor is broken rather than that the feature is missing.
19
+ */
20
+
21
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
22
+ import type { TargetedKeyboardEvent } from 'preact';
23
+ import { CornerDownLeft, Search } from 'lucide-preact';
24
+
25
+ import { isFindBarCommand, type EditorCommand } from '@/shared/lib/code/editor/keymap';
26
+ import { buildPaletteEntries, filterPaletteEntries, type PaletteEntry } from '@/shared/lib/code/editor/palette';
27
+ import { isMacPlatform } from '@/shared/lib/util/platform';
28
+
29
+ interface CommandPaletteProps {
30
+ onRun: (command: EditorCommand) => void;
31
+ onClose: () => void;
32
+ }
33
+
34
+ export function CommandPalette({ onRun, onClose }: CommandPaletteProps) {
35
+ const [query, setQuery] = useState('');
36
+ const [activeIndex, setActiveIndex] = useState(0);
37
+ const inputRef = useRef<HTMLInputElement | null>(null);
38
+ const listRef = useRef<HTMLDivElement | null>(null);
39
+
40
+ // Built once per mount from the keymap, so a filter can never offer something
41
+ // the keymap does not have.
42
+ const entries = useMemo(() => buildPaletteEntries(isMacPlatform()), []);
43
+ const matches = useMemo(() => filterPaletteEntries(entries, query), [entries, query]);
44
+
45
+ useEffect(() => {
46
+ inputRef.current?.focus();
47
+ }, []);
48
+
49
+ // Keep the highlighted row in view while the filter shrinks the list under it.
50
+ useEffect(() => {
51
+ const list = listRef.current;
52
+ const active = list?.children[activeIndex] as HTMLElement | undefined;
53
+ active?.scrollIntoView({ block: 'nearest' });
54
+ }, [activeIndex, matches.length]);
55
+
56
+ useEffect(() => {
57
+ setActiveIndex((previous) => Math.min(previous, Math.max(matches.length - 1, 0)));
58
+ }, [matches.length]);
59
+
60
+ const commit = (entry: PaletteEntry | undefined) => {
61
+ if (!entry) return;
62
+ onClose();
63
+ onRun(entry.command);
64
+ };
65
+
66
+ const handleKeyDown = (event: TargetedKeyboardEvent<HTMLInputElement>) => {
67
+ if (event.key === 'ArrowDown') {
68
+ event.preventDefault();
69
+ setActiveIndex((previous) => (matches.length === 0 ? 0 : (previous + 1) % matches.length));
70
+ return;
71
+ }
72
+ if (event.key === 'ArrowUp') {
73
+ event.preventDefault();
74
+ setActiveIndex((previous) => (matches.length === 0 ? 0 : (previous - 1 + matches.length) % matches.length));
75
+ return;
76
+ }
77
+ if (event.key === 'Enter') {
78
+ event.preventDefault();
79
+ commit(matches[activeIndex]);
80
+ return;
81
+ }
82
+ if (event.key === 'Escape') {
83
+ // Handled on the panel's capture phase, but stopping here too keeps the
84
+ // key from reaching the document if the palette is ever mounted alone.
85
+ event.preventDefault();
86
+ event.stopPropagation();
87
+ onClose();
88
+ }
89
+ };
90
+
91
+ return (
92
+ <div className="absolute inset-0 z-40 flex items-start justify-center pt-12 bg-ink/10" onClick={onClose}>
93
+ <div
94
+ className="w-[520px] max-w-[92%] bg-paper border border-ink/15 rounded-lg shadow-2xl flex flex-col overflow-hidden"
95
+ onClick={(e) => e.stopPropagation()}
96
+ >
97
+ <div className="flex items-center gap-2 px-3 py-2 border-b border-ink/10">
98
+ <Search size={14} className="text-ink/40 flex-shrink-0" />
99
+ <input
100
+ ref={inputRef}
101
+ type="text"
102
+ value={query}
103
+ onInput={(e) => setQuery(e.currentTarget.value)}
104
+ onKeyDown={handleKeyDown}
105
+ placeholder="Type a command or a shortcut"
106
+ aria-label="Command palette"
107
+ className="flex-1 min-w-0 bg-transparent text-xs font-sans text-ink focus:outline-none placeholder-ink/40"
108
+ />
109
+ </div>
110
+
111
+ <div ref={listRef} className="max-h-[320px] overflow-y-auto py-1">
112
+ {matches.length === 0 ? (
113
+ <div className="px-3 py-4 text-center text-xs text-ink/40 font-sans">No matching command</div>
114
+ ) : (
115
+ matches.map((entry, index) => (
116
+ <button
117
+ key={entry.command}
118
+ type="button"
119
+ onMouseEnter={() => setActiveIndex(index)}
120
+ onClick={() => commit(entry)}
121
+ className={`w-full flex items-center justify-between gap-3 px-3 py-1.5 text-left text-xs font-sans transition-colors ${
122
+ index === activeIndex ? 'bg-ink/5 text-ink' : 'text-ink/80'
123
+ }`}
124
+ >
125
+ <span className="flex items-center gap-2 min-w-0">
126
+ {index === activeIndex ? <CornerDownLeft size={12} className="text-ink/40 flex-shrink-0" /> : <span className="w-3" />}
127
+ <span className="truncate">{entry.label}</span>
128
+ {isFindBarCommand(entry.command) ? <span className="text-[10px] text-ink/40 flex-shrink-0">find bar</span> : null}
129
+ </span>
130
+ <span className="font-mono text-[10px] text-ink/50 flex-shrink-0">{entry.chord}</span>
131
+ </button>
132
+ ))
133
+ )}
134
+ </div>
135
+ </div>
136
+ </div>
137
+ );
138
+ }