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,216 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor's undo stack.
8
+ *
9
+ * The rule under test is the one that was wrong: the re-seed exists for a value
10
+ * replaced from the OUTSIDE (a file switch, a refresh), but it also fired for
11
+ * the component's own edits — every keystroke pushed a second, identical entry,
12
+ * so the first ⌘Z restored the buffer to the state it was already in and undo
13
+ * looked broken until the second press. `markApplied` is what separates the two,
14
+ * and these assertions are what keeps it separating them.
15
+ *
16
+ * Runs against a real DOM (happy-dom) because it is a Preact hook holding a
17
+ * `<textarea>`; the modules below are imported dynamically, after those globals
18
+ * exist (Preact binds its environment at evaluation time).
19
+ */
20
+
21
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
22
+ import { Window } from 'happy-dom';
23
+ import type { h as PreactH, render as PreactRender } from 'preact';
24
+ import type { act as PreactAct } from 'preact/test-utils';
25
+ import type { EditorHistory, useEditorHistory as UseEditorHistoryHook } from '@/client/hooks/editor/use-editor-history';
26
+
27
+ let useEditorHistory: typeof UseEditorHistoryHook;
28
+ let h: typeof PreactH;
29
+ let render: typeof PreactRender;
30
+ let act: typeof PreactAct;
31
+
32
+ let api: EditorHistory | null = null;
33
+ let container: HTMLElement;
34
+ let textarea: HTMLTextAreaElement;
35
+
36
+ /** The buffer the hook is told is on screen, changed from the outside by a test. */
37
+ let externalValue = 'one';
38
+
39
+ function Harness() {
40
+ api = useEditorHistory({
41
+ value: externalValue,
42
+ inputRef: { current: textarea },
43
+ onValueChange: (next) => {
44
+ textarea.value = next;
45
+ },
46
+ });
47
+ return null;
48
+ }
49
+
50
+ /** Flush the effect that watches `value`, with no timers: it resolves on a render. */
51
+ async function flush() {
52
+ for (let i = 0; i < 3; i++) {
53
+ await act(async () => {
54
+ for (let turn = 0; turn < 4; turn++) await Promise.resolve();
55
+ });
56
+ }
57
+ }
58
+
59
+ function domGlobals(win: Window): Record<string, unknown> {
60
+ return {
61
+ window: win,
62
+ document: win.document,
63
+ navigator: win.navigator,
64
+ Node: win.Node,
65
+ Element: win.Element,
66
+ HTMLElement: win.HTMLElement,
67
+ HTMLTextAreaElement: win.HTMLTextAreaElement,
68
+ };
69
+ }
70
+
71
+ const installed: Record<string, unknown> = {};
72
+ const displaced: Record<string, unknown> = {};
73
+
74
+ beforeAll(async () => {
75
+ const win = new Window({ url: 'http://localhost' });
76
+ for (const [key, value] of Object.entries(domGlobals(win))) {
77
+ displaced[key] = (globalThis as Record<string, unknown>)[key];
78
+ installed[key] = value;
79
+ (globalThis as Record<string, unknown>)[key] = value;
80
+ }
81
+ ({ useEditorHistory } = await import('@/client/hooks/editor/use-editor-history'));
82
+ ({ h, render } = await import('preact'));
83
+ ({ act } = await import('preact/test-utils'));
84
+ });
85
+
86
+ afterAll(() => {
87
+ for (const key of Object.keys(installed)) {
88
+ if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
89
+ else (globalThis as Record<string, unknown>)[key] = displaced[key];
90
+ }
91
+ });
92
+
93
+ beforeEach(async () => {
94
+ api = null;
95
+ externalValue = 'one';
96
+ container = document.createElement('div');
97
+ document.body.appendChild(container);
98
+ textarea = document.createElement('textarea');
99
+ textarea.value = 'one';
100
+ container.appendChild(textarea);
101
+ await act(async () => {
102
+ render(h(Harness, {}), container);
103
+ });
104
+ });
105
+
106
+ afterEach(() => {
107
+ render(null, container);
108
+ container.remove();
109
+ });
110
+
111
+ describe('useEditorHistory', () => {
112
+ test('commit writes the buffer, the caret and the caller’s state', async () => {
113
+ await act(async () => {
114
+ api?.commit({ value: 'two', selectionStart: 2, selectionEnd: 2 });
115
+ });
116
+
117
+ expect(textarea.value).toBe('two');
118
+ expect(textarea.selectionStart).toBe(2);
119
+ });
120
+
121
+ test('an external value replacement is recorded, so undo reaches the old file', async () => {
122
+ // A file switch: the hook did not write this, so it becomes a history entry
123
+ // rather than silently replacing the stack.
124
+ externalValue = 'switched';
125
+ await act(async () => {
126
+ render(h(Harness, {}), container);
127
+ });
128
+ await flush();
129
+
130
+ await act(async () => {
131
+ api?.step('undo');
132
+ });
133
+
134
+ expect(textarea.value).toBe('one');
135
+ });
136
+
137
+ test('a value the hook itself wrote is NOT re-recorded', async () => {
138
+ // The bug this guards: an extra entry per keystroke made the first ⌘Z a
139
+ // no-op, because it restored the buffer to what was already on screen.
140
+ await act(async () => {
141
+ api?.commit({ value: 'two', selectionStart: 2, selectionEnd: 2 });
142
+ });
143
+ externalValue = 'two';
144
+ await act(async () => {
145
+ render(h(Harness, {}), container);
146
+ });
147
+ await flush();
148
+
149
+ await act(async () => {
150
+ api?.step('undo');
151
+ });
152
+
153
+ // One press reaches the ORIGINAL buffer; a duplicate entry would have
154
+ // restored 'two' again.
155
+ expect(textarea.value).toBe('one');
156
+ });
157
+
158
+ test('markApplied declares a browser edit as already recorded', async () => {
159
+ await act(async () => {
160
+ api?.markApplied('typed');
161
+ });
162
+ externalValue = 'typed';
163
+ await act(async () => {
164
+ render(h(Harness, {}), container);
165
+ });
166
+ await flush();
167
+
168
+ await act(async () => {
169
+ api?.step('undo');
170
+ });
171
+
172
+ // Nothing was pushed for `typed`, so there is nothing to step back to: the
173
+ // stack still holds only the initial record.
174
+ expect(textarea.value).toBe('one');
175
+ });
176
+
177
+ test('step at the ends of the stack is a no-op', async () => {
178
+ await act(async () => {
179
+ api?.step('undo');
180
+ });
181
+ const afterUndo = textarea.value;
182
+ await act(async () => {
183
+ api?.step('redo');
184
+ });
185
+
186
+ expect(afterUndo).toBe('one');
187
+ expect(textarea.value).toBe('one');
188
+ });
189
+
190
+ test('commitEdit supersedes the top entry, keeping its caret for the undo', async () => {
191
+ // A line command replaces a whole range at once; undo must return the caret
192
+ // to the selection the command replaced, not to where the edit ended.
193
+ textarea.selectionStart = 1;
194
+ textarea.selectionEnd = 3;
195
+ await act(async () => {
196
+ api?.commitEdit({ value: 'one-changed', selectionStart: 11, selectionEnd: 11 });
197
+ });
198
+ await act(async () => {
199
+ api?.step('undo');
200
+ });
201
+
202
+ expect(textarea.value).toBe('one');
203
+ expect(textarea.selectionStart).toBe(1);
204
+ expect(textarea.selectionEnd).toBe(3);
205
+ });
206
+
207
+ test('the exposed buffer is never a render behind', async () => {
208
+ // `handleChange` diffs against this; a value that lagged a keystroke read a
209
+ // one-character edit as a whole-word replacement.
210
+ await act(async () => {
211
+ api?.commit({ value: 'fresh', selectionStart: 5, selectionEnd: 5 });
212
+ });
213
+
214
+ expect(api?.buffer).toBe('fresh');
215
+ });
216
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor's undo stack, wired to the DOM node.
8
+ *
9
+ * `history.ts` is the pure bookkeeping (push, merge, step, cap); this is the
10
+ * part that has to touch the `<textarea>`: committing an edit as one entry,
11
+ * restoring a stepped record, and re-seeding the stack when the value is
12
+ * replaced from the OUTSIDE.
13
+ *
14
+ * The re-seed is the subtle one. It exists for a file switch or a refresh, but
15
+ * a naive `[value]` effect also fires for the component's own edits — every
16
+ * keystroke then pushed a second, identical entry, so the first ⌘Z restored the
17
+ * buffer to the state it was already in and undo looked broken until the second
18
+ * press (and the no-op `onValueChange` truncated the redo stack). The `applied`
19
+ * ref is what distinguishes "someone else replaced the value" from "we did".
20
+ */
21
+
22
+ import { useEffect, useRef } from 'preact/hooks';
23
+
24
+ import { createHistory, pushEdit, pushRecord, stepHistory, type History, type HistoryRecord } from '@/shared/lib/code/editor/history';
25
+
26
+ /** The fields every write path needs: a buffer and where the caret goes. */
27
+ export type HistoryEdit = Pick<HistoryRecord, 'value' | 'selectionStart' | 'selectionEnd'>;
28
+
29
+ export interface EditorHistory {
30
+ /** The stack itself, for the typing-burst merge in the change handler. */
31
+ history: History;
32
+ /**
33
+ * The buffer as this component last knew it — its own writes included, and
34
+ * never a render behind. Diffing a browser edit has to start from here: the
35
+ * `value` prop lags a keystroke, and a stale `before` reads a one-character
36
+ * edit as a whole-word replacement.
37
+ */
38
+ readonly buffer: string;
39
+ /** Commit an edit as ONE undoable entry. */
40
+ commit: (edit: HistoryEdit) => void;
41
+ /**
42
+ * Commit an edit that SUPERSEDES the entry on top, keeping that entry's
43
+ * caret so undo returns to the selection the edit replaced.
44
+ */
45
+ commitEdit: (edit: HistoryEdit) => void;
46
+ /** Step the stack and restore the record; no-op at either end. */
47
+ step: (direction: 'undo' | 'redo') => void;
48
+ /**
49
+ * Declare a value the BROWSER produced as already applied, so the re-seed
50
+ * effect below does not record it a second time. The change handler calls
51
+ * this for every keystroke and every replicated multi-cursor edit.
52
+ */
53
+ markApplied: (value: string) => void;
54
+ }
55
+
56
+ export interface UseEditorHistoryOptions {
57
+ /** The controlled buffer, used to detect an external replacement. */
58
+ value: string;
59
+ /** The live `<textarea>`, so an external replacement records its own caret. */
60
+ inputRef: { current: HTMLTextAreaElement | null };
61
+ /** Push the committed buffer to the caller. */
62
+ onValueChange: (value: string) => void;
63
+ }
64
+
65
+ export function useEditorHistory({ value, inputRef, onValueChange }: UseEditorHistoryOptions): EditorHistory {
66
+ const historyRef = useRef<History>(createHistory());
67
+ /** The value this component itself last wrote — see the module note. */
68
+ const appliedRef = useRef<string | null>(null);
69
+
70
+ // Re-record when the controlled value is replaced from the outside
71
+ // (file switch, refresh) so undo does not jump between documents.
72
+ useEffect(() => {
73
+ const input = inputRef.current;
74
+ if (!input || appliedRef.current === value) return;
75
+ appliedRef.current = value;
76
+ pushRecord(historyRef.current, {
77
+ value: input.value,
78
+ selectionStart: input.selectionStart,
79
+ selectionEnd: input.selectionEnd,
80
+ timestamp: Date.now(),
81
+ });
82
+ // eslint-disable-next-line react-hooks/exhaustive-deps
83
+ }, [value]);
84
+
85
+ /** Put a buffer on screen without touching the stack. */
86
+ const write = (edit: HistoryEdit) => {
87
+ appliedRef.current = edit.value;
88
+ onValueChange(edit.value);
89
+ const input = inputRef.current;
90
+ if (!input) return;
91
+ input.value = edit.value;
92
+ input.selectionStart = edit.selectionStart;
93
+ input.selectionEnd = edit.selectionEnd;
94
+ };
95
+
96
+ return {
97
+ history: historyRef.current,
98
+
99
+ get buffer() {
100
+ return appliedRef.current ?? value;
101
+ },
102
+
103
+ commit: (edit) => {
104
+ pushEdit(historyRef.current, { ...edit, timestamp: Date.now() });
105
+ write(edit);
106
+ },
107
+
108
+ commitEdit: (edit) => {
109
+ const history = historyRef.current;
110
+ const last = history.stack[history.offset];
111
+ const input = inputRef.current;
112
+ if (last && input) {
113
+ // The entry on top is about to be superseded: keep where its caret was,
114
+ // so undo returns to the selection this edit replaced.
115
+ history.stack[history.offset] = {
116
+ ...last,
117
+ selectionStart: input.selectionStart,
118
+ selectionEnd: input.selectionEnd,
119
+ };
120
+ }
121
+ pushEdit(history, { ...edit, timestamp: Date.now() });
122
+ write(edit);
123
+ },
124
+
125
+ step: (direction) => {
126
+ const record = stepHistory(historyRef.current, direction);
127
+ if (record) write(record);
128
+ },
129
+
130
+ markApplied: (next) => {
131
+ appliedRef.current = next;
132
+ },
133
+ };
134
+ }
@@ -19,10 +19,6 @@ export interface FileEditorTarget {
19
19
  }
20
20
 
21
21
  export interface UseFileEditorOptions {
22
- /** Content substituted when a read fails (desktop). Omit to report the failure (mobile). */
23
- fallback?: (name: string) => string;
24
- /** Surface read failures through `loadError` instead of silently substituting `fallback`. */
25
- reportLoadError?: boolean;
26
22
  /** Flush a pending debounced write on unmount so closing the editor cannot drop an edit. */
27
23
  flushOnUnmount?: boolean;
28
24
  /** Mark a failed write as `'error'`; otherwise the status returns to `'idle'`. */
@@ -74,8 +70,6 @@ export function useFileEditor(
74
70
  options: UseFileEditorOptions = {},
75
71
  ): UseFileEditorResult {
76
72
  const {
77
- fallback,
78
- reportLoadError = false,
79
73
  flushOnUnmount = false,
80
74
  reportSaveError = false,
81
75
  downloadMimeType = 'text/plain',
@@ -184,11 +178,9 @@ export function useFileEditor(
184
178
  }
185
179
 
186
180
  if (!target.path) {
187
- if (reportLoadError) {
188
- setLoadError('No file path supplied for this attachment.');
189
- return;
190
- }
191
- remember(fileKey, fallback ? fallback(target.name) : '');
181
+ // An attachment chip with no path has nothing to read and nothing to
182
+ // save; the message is the honest outcome.
183
+ setLoadError('No file path supplied for this attachment.');
192
184
  return;
193
185
  }
194
186
 
@@ -203,14 +195,11 @@ export function useFileEditor(
203
195
  if (target.repo && target.repo !== '.') params.set('repo', target.repo);
204
196
  fetch(`/api/fs/read?${params.toString()}`)
205
197
  .then(async (res) => {
206
- const data = await res.json().catch(() => null);
207
- if (reportLoadError) {
208
- if (!res.ok || !data || typeof data.content !== 'string') {
209
- throw new Error((data as { error?: string } | null)?.error || `HTTP ${res.status}`);
210
- }
211
- return data.content as string;
198
+ const data = (await res.json().catch(() => null)) as { content?: unknown; error?: string } | null;
199
+ if (!res.ok || !data || typeof data.content !== 'string') {
200
+ throw new Error(data?.error || `HTTP ${res.status}`);
212
201
  }
213
- return typeof data?.content === 'string' ? (data.content as string) : null;
202
+ return data.content;
214
203
  })
215
204
  .then((text) => {
216
205
  if (cancelled) return;
@@ -222,18 +211,16 @@ export function useFileEditor(
222
211
  if (typed !== undefined && typed !== baselinesRef.current[fileKey]) return;
223
212
  const queued = pendingRef.current;
224
213
  if (queued && targetKey(queued.file) === fileKey) return;
225
- remember(fileKey, text ?? (fallback ? fallback(target.name) : ''));
214
+ remember(fileKey, text);
226
215
  })
227
216
  .catch((err: unknown) => {
228
217
  if (cancelled) return;
229
218
  // A failed re-read keeps what is on screen; only a first load reports.
230
219
  if (!firstLoad) return;
231
- if (reportLoadError) {
232
- remember(fileKey, '');
233
- setLoadError(err instanceof Error ? err.message : 'Failed to read file');
234
- } else {
235
- remember(fileKey, fallback ? fallback(target.name) : '');
236
- }
220
+ // An empty buffer, never a substitute: the next save would write the
221
+ // substitute over the real file.
222
+ remember(fileKey, '');
223
+ setLoadError(err instanceof Error ? err.message : 'Failed to read file');
237
224
  })
238
225
  .finally(() => {
239
226
  if (!cancelled) setIsLoading(false);
@@ -242,7 +229,7 @@ export function useFileEditor(
242
229
  return () => {
243
230
  cancelled = true;
244
231
  };
245
- }, [key, target, fallback, reportLoadError, remember, revision, isImage]);
232
+ }, [key, target, remember, revision, isImage]);
246
233
 
247
234
  useEffect(() => {
248
235
  if (saveStatus !== 'saved') return;
@@ -250,6 +237,13 @@ export function useFileEditor(
250
237
  return () => window.clearTimeout(timer);
251
238
  }, [saveStatus]);
252
239
 
240
+ // The save status describes the file that was written, so it must not outlive
241
+ // that tab: opening another file used to keep showing the previous file's
242
+ // "Save failed" (and its error icon) against a file that was never saved.
243
+ useEffect(() => {
244
+ setSaveStatus('idle');
245
+ }, [key]);
246
+
253
247
  const onChange = useCallback(
254
248
  (next: string) => {
255
249
  if (!target || isImage) return;
@@ -268,6 +262,9 @@ export function useFileEditor(
268
262
 
269
263
  const saveNow = useCallback(() => {
270
264
  if (!target || isImage) return;
265
+ // A file whose read failed has no trustworthy buffer: saving it would write
266
+ // the empty placeholder over the real file. The user must reopen it first.
267
+ if (loadError) return;
271
268
  const text = contents[targetKey(target)];
272
269
  if (text === undefined) return;
273
270
  if (pendingRef.current) {
@@ -275,7 +272,7 @@ export function useFileEditor(
275
272
  pendingRef.current = null;
276
273
  }
277
274
  void persistRef.current(target, text);
278
- }, [target, contents, isImage]);
275
+ }, [target, contents, isImage, loadError]);
279
276
 
280
277
  const { copied, copy, download } = useFileTransfer({
281
278
  target,
@@ -0,0 +1,236 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `useOccurrences` — the ranges behind ⌘D, ⌘⇧L and ⌘⇧D.
8
+ *
9
+ * The rules live in `shared/lib/code/editor/occurrences` and are tested there;
10
+ * what this file covers is the hook's own contract: the caller's copy wins
11
+ * (clearing the ranges in the panel must clear them here), every change is
12
+ * announced so the surface repaints, and the textarea is read rather than
13
+ * assumed.
14
+ *
15
+ * Runs against a real DOM (happy-dom) because it is a Preact hook; the modules
16
+ * below are 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 { h as PreactH, render as PreactRender } from 'preact';
22
+ import type { act as PreactAct } from 'preact/test-utils';
23
+ import type { OccurrencesApi, useOccurrences as UseOccurrencesHook } from '@/client/hooks/editor/use-occurrences';
24
+ import type { TextRange } from '@/shared/lib/code/editor/commands';
25
+
26
+ let useOccurrences: typeof UseOccurrencesHook;
27
+ let h: typeof PreactH;
28
+ let render: typeof PreactRender;
29
+ let act: typeof PreactAct;
30
+
31
+ let api: OccurrencesApi | null = null;
32
+ let container: HTMLElement;
33
+ let textarea: HTMLTextAreaElement;
34
+ /** What the caller passes in as its own copy, changed per test. */
35
+ let controlled: TextRange[] | undefined;
36
+ const announced: Array<readonly TextRange[]> = [];
37
+ const selections: Array<[number, number]> = [];
38
+
39
+ function Harness() {
40
+ api = useOccurrences({
41
+ controlled,
42
+ onChange: (ranges) => announced.push(ranges),
43
+ });
44
+ return null;
45
+ }
46
+
47
+ async function flush() {
48
+ for (let i = 0; i < 3; i++) {
49
+ await act(async () => {
50
+ for (let turn = 0; turn < 4; turn++) await Promise.resolve();
51
+ });
52
+ }
53
+ }
54
+
55
+ async function rerender() {
56
+ await act(async () => {
57
+ render(h(Harness, {}), container);
58
+ });
59
+ await flush();
60
+ }
61
+
62
+ function select(start: number, end = start) {
63
+ textarea.selectionStart = start;
64
+ textarea.selectionEnd = end;
65
+ }
66
+
67
+ /**
68
+ * Stands in for the editor's own `select`: it MOVES the textarea's selection and
69
+ * records it. The hook reads the textarea, so a callback that only recorded the
70
+ * range would leave every later press looking at the original caret.
71
+ */
72
+ function selectingSurface() {
73
+ return (start: number, end: number) => {
74
+ selections.push([start, end]);
75
+ select(start, end);
76
+ };
77
+ }
78
+
79
+ function domGlobals(win: Window): Record<string, unknown> {
80
+ return {
81
+ window: win,
82
+ document: win.document,
83
+ navigator: win.navigator,
84
+ Node: win.Node,
85
+ Element: win.Element,
86
+ HTMLElement: win.HTMLElement,
87
+ HTMLTextAreaElement: win.HTMLTextAreaElement,
88
+ };
89
+ }
90
+
91
+ const installed: Record<string, unknown> = {};
92
+ const displaced: Record<string, unknown> = {};
93
+
94
+ beforeAll(async () => {
95
+ const win = new Window({ url: 'http://localhost' });
96
+ for (const [key, value] of Object.entries(domGlobals(win))) {
97
+ displaced[key] = (globalThis as Record<string, unknown>)[key];
98
+ installed[key] = value;
99
+ (globalThis as Record<string, unknown>)[key] = value;
100
+ }
101
+ ({ useOccurrences } = await import('@/client/hooks/editor/use-occurrences'));
102
+ ({ h, render } = await import('preact'));
103
+ ({ act } = await import('preact/test-utils'));
104
+ });
105
+
106
+ afterAll(() => {
107
+ for (const key of Object.keys(installed)) {
108
+ if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
109
+ else (globalThis as Record<string, unknown>)[key] = displaced[key];
110
+ }
111
+ });
112
+
113
+ beforeEach(async () => {
114
+ api = null;
115
+ controlled = undefined;
116
+ announced.length = 0;
117
+ selections.length = 0;
118
+ container = document.createElement('div');
119
+ document.body.appendChild(container);
120
+ textarea = document.createElement('textarea');
121
+ textarea.value = 'alpha beta alpha gamma alpha';
122
+ container.appendChild(textarea);
123
+ await rerender();
124
+ });
125
+
126
+ afterEach(() => {
127
+ render(null, container);
128
+ container.remove();
129
+ });
130
+
131
+ describe('useOccurrences', () => {
132
+ test('starts with no extra ranges', () => {
133
+ expect(api?.ranges).toEqual([]);
134
+ });
135
+
136
+ test('⌘D selects the word first, then grows one occurrence per press', async () => {
137
+ select(2);
138
+ await act(async () => {
139
+ api?.selectNext(textarea, selectingSurface());
140
+ });
141
+ expect(selections.at(-1)).toEqual([0, 5]);
142
+ expect(api?.ranges).toEqual([]);
143
+
144
+ await act(async () => {
145
+ api?.selectNext(textarea, selectingSurface());
146
+ });
147
+ expect(api?.ranges).toEqual([{ start: 11, end: 16 }]);
148
+ });
149
+
150
+ test('⌘⇧L takes every occurrence, primary first', async () => {
151
+ select(0, 5);
152
+ await act(async () => {
153
+ api?.selectAll(textarea, selectingSurface());
154
+ });
155
+
156
+ expect(selections.at(-1)).toEqual([0, 5]);
157
+ expect(api?.ranges).toEqual([
158
+ { start: 11, end: 16 },
159
+ { start: 23, end: 28 },
160
+ ]);
161
+ });
162
+
163
+ test('every change is announced so the surface can repaint', async () => {
164
+ select(0, 5);
165
+ await act(async () => {
166
+ api?.selectAll(textarea, () => {});
167
+ });
168
+
169
+ expect(announced.at(-1)?.length).toBe(2);
170
+ });
171
+
172
+ test('clear drops every range and says so', async () => {
173
+ select(0, 5);
174
+ await act(async () => {
175
+ api?.selectAll(textarea, () => {});
176
+ });
177
+ await act(async () => {
178
+ api?.clear();
179
+ });
180
+
181
+ expect(api?.ranges).toEqual([]);
182
+ expect(announced.at(-1)).toEqual([]);
183
+ });
184
+
185
+ test('the caller’s copy is authoritative', async () => {
186
+ // The panel can clear the ranges without the editor asking (a file switch,
187
+ // a save); the hook has to follow, not keep its own stale list.
188
+ controlled = [{ start: 1, end: 3 }];
189
+ await rerender();
190
+
191
+ expect(api?.ranges).toEqual([{ start: 1, end: 3 }]);
192
+
193
+ controlled = [];
194
+ await rerender();
195
+
196
+ expect(api?.ranges).toEqual([]);
197
+ });
198
+
199
+ test('⌘⇧D adds a caret below on the same column', async () => {
200
+ textarea.value = 'one\ntwo\nthree';
201
+ select(1);
202
+ await act(async () => {
203
+ api?.insertCursorBelow(textarea);
204
+ });
205
+
206
+ expect(api?.ranges).toEqual([{ start: 5, end: 5 }]);
207
+ });
208
+
209
+ test('⌘⇧D on the last line adds nothing', async () => {
210
+ textarea.value = 'one\ntwo';
211
+ select(5);
212
+ await act(async () => {
213
+ api?.insertCursorBelow(textarea);
214
+ });
215
+
216
+ expect(api?.ranges).toEqual([]);
217
+ });
218
+
219
+ test('replicate reports nothing without extra ranges', () => {
220
+ expect(api?.replicate('abc', 'abXc', { start: 2, end: 2 })).toBeNull();
221
+ });
222
+
223
+ test('replicate replays the browser’s edit at every extra range', async () => {
224
+ select(0, 5);
225
+ await act(async () => {
226
+ api?.selectAll(textarea, () => {});
227
+ });
228
+
229
+ // The browser replaced `alpha` at 0..5 with `Z`; the other two follow.
230
+ const applied = api?.replicate('alpha beta alpha gamma alpha', 'Z beta alpha gamma alpha', { start: 0, end: 5 });
231
+
232
+ expect(applied?.value).toBe('Z beta Z gamma Z');
233
+ expect(applied?.caret).toBe(1);
234
+ expect(applied?.rest.length).toBe(2);
235
+ });
236
+ });