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.
- package/README.md +31 -11
- package/bunfig.toml +11 -0
- package/dist/client/index.html +16 -5
- package/dist/client/index.js +1393 -0
- package/dist/client/static/js/index-8t70jhyj.js +5149 -0
- package/dist/client/static/js/index-n4wkqj7t.css +1 -0
- package/index.html +54 -0
- package/package.json +9 -10
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192x192.png +0 -0
- package/public/icon.svg +60 -0
- package/public/manifest.webmanifest +38 -0
- package/src/cli/lib/commands/serve.js +9 -5
- package/src/cli/lib/runtime.js +18 -7
- package/src/client/components/common/code-editor/handler.test.ts +274 -0
- package/src/client/components/common/code-editor/handler.ts +175 -0
- package/src/client/components/common/code-editor/index.tsx +185 -220
- package/src/client/components/common/code-surface/index.tsx +109 -12
- package/src/client/components/common/lazy-panels.tsx +5 -1
- package/src/client/components/common/provider-icon/glyphs.ts +118 -0
- package/src/client/components/common/provider-icon/index.tsx +85 -0
- package/src/client/components/common/viewport.ts +5 -3
- package/src/client/components/layout/RightActivityBar.tsx +2 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +4 -0
- package/src/client/components/layout/desktop-layout/index.tsx +26 -4
- package/src/client/components/mobile/LayoutWrapper.tsx +16 -0
- package/src/client/components/mobile/RightSidebar.tsx +11 -4
- package/src/client/components/mobile/SessionSidebar.tsx +10 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +0 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -1
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +3 -0
- package/src/client/components/settings/categories/ProviderSettings.tsx +45 -4
- package/src/client/components/settings/categories/UsageSettings.tsx +22 -7
- package/src/client/components/settings/categories/provider-settings/AddModelModal.tsx +322 -0
- package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +152 -56
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +52 -1
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +133 -0
- package/src/client/components/settings/categories/provider-settings/DialectFields.tsx +136 -0
- package/src/client/components/settings/categories/provider-settings/EffortLadder.tsx +89 -0
- package/src/client/components/settings/categories/provider-settings/Header.tsx +2 -2
- package/src/client/components/settings/categories/provider-settings/LoginModal.tsx +2 -2
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +22 -2
- package/src/client/components/settings/categories/provider-settings/PresetPicker.tsx +82 -0
- package/src/client/components/settings/categories/provider-settings/PriceFields.tsx +107 -0
- package/src/client/components/settings/categories/provider-settings/ReconnectModal.tsx +43 -22
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +2 -2
- package/src/client/components/settings/categories/usage-settings/LimitsSection.tsx +115 -0
- package/src/client/components/settings/categories/usage-settings/ProviderCard.tsx +19 -26
- package/src/client/components/settings/categories/usage-settings/ProviderDetail.tsx +29 -22
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +60 -48
- package/src/client/components/settings/categories/usage-settings/format.ts +36 -0
- package/src/client/components/settings/categories/usage-settings/providers.ts +52 -28
- package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
- package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
- package/src/client/components/workspace/browser-panel/index.tsx +6 -6
- package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
- package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +71 -38
- package/src/client/components/workspace/diff-panel/index.tsx +139 -53
- package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
- package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
- package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
- package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
- package/src/client/components/workspace/editor/Tabs.tsx +2 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +129 -26
- package/src/client/components/workspace/editor/index.tsx +180 -62
- package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
- package/src/client/components/workspace/model-dropdown/Item.tsx +13 -23
- package/src/client/components/workspace/model-dropdown/Panel.tsx +1 -1
- package/src/client/components/workspace/model-dropdown/Section.tsx +13 -4
- package/src/client/components/workspace/model-dropdown/index.tsx +7 -3
- package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
- package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
- package/src/client/components/workspace/todo-panel/index.tsx +161 -0
- package/src/client/components/workspace/usage-panel/ProviderSelect.tsx +10 -5
- package/src/client/components/workspace/usage-panel/index.tsx +36 -18
- package/src/client/data/samples/dialogue-session.ts +1 -1
- package/src/client/data/samples/sample-tools/other.ts +1 -1
- package/src/client/data/settings/provider.ts +141 -9
- package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
- package/src/client/hooks/editor/scroll-container.test.ts +102 -0
- package/src/client/hooks/editor/scroll-container.ts +26 -0
- package/src/client/hooks/editor/use-code-window.ts +2 -20
- package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
- package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
- package/src/client/hooks/editor/use-editor-find.ts +321 -0
- package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
- package/src/client/hooks/editor/use-editor-history.ts +134 -0
- package/src/client/hooks/editor/use-file-editor.ts +24 -27
- package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
- package/src/client/hooks/editor/use-occurrences.ts +98 -0
- package/src/client/hooks/models/use-provider-names.ts +1 -1
- package/src/client/hooks/settings/providers/actions.ts +337 -0
- package/src/client/hooks/settings/providers/api.ts +69 -0
- package/src/client/hooks/settings/providers/index.ts +138 -0
- package/src/client/hooks/settings/providers/messages.ts +77 -0
- package/src/client/hooks/settings/useUsageReport.ts +8 -3
- package/src/client/hooks/ui/theme.ts +3 -3
- package/src/client/hooks/workspace/file-tabs.ts +62 -11
- package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
- package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
- package/src/client/hooks/workspace/session-todos.ts +102 -0
- package/src/client/stubs/elkjs.ts +6 -1
- package/src/client/tailwind.css +30 -0
- package/src/server/index.ts +20 -2
- package/src/server/lib/assets/font-css.server.ts +86 -0
- package/src/server/lib/assets/fonts.server.ts +117 -0
- package/src/server/lib/bundler/css.ts +116 -0
- package/src/server/lib/lifecycle/shell-route.ts +17 -0
- package/src/server/lib/models/provider-listing.server.ts +257 -0
- package/src/server/lib/models/provider-registry.server.test.ts +122 -0
- package/src/server/lib/models/provider-registry.server.ts +76 -25
- package/src/server/lib/omp/config/models-config.ts +11 -0
- package/src/server/lib/omp/config/models-validation.test.ts +137 -0
- package/src/server/lib/omp/config/models-validation.ts +111 -2
- package/src/server/lib/omp/config/provider-seeds.test.ts +167 -0
- package/src/server/lib/omp/config/provider-seeds.ts +176 -0
- package/src/server/lib/omp/config/providers.ts +94 -100
- package/src/server/lib/omp/core/auth-credentials.ts +45 -0
- package/src/server/lib/omp/session/todos.ts +100 -0
- package/src/server/lib/omp/session/{usage.ts → usage/aggregate.ts} +1 -118
- package/src/server/lib/omp/session/usage/shaping.ts +130 -0
- package/src/server/lib/usage/deepseek.ts +4 -5
- package/src/server/lib/usage/kenari.ts +7 -6
- package/src/server/lib/usage/omp-usage.server.test.ts +126 -0
- package/src/server/lib/usage/omp-usage.server.ts +194 -0
- package/src/server/lib/usage/provider-key.ts +81 -3
- package/src/server/lib/usage/providers.server.test.ts +47 -0
- package/src/server/lib/usage/providers.server.ts +208 -0
- package/src/server/lib/usage/vendor-balance.server.test.ts +104 -0
- package/src/server/lib/usage/vendor-balance.server.ts +284 -0
- package/src/server/plugins/shell.server.ts +56 -0
- package/src/server/plugins/ssr.ts +24 -36
- package/src/server/plugins/static.test.ts +26 -29
- package/src/server/plugins/static.ts +40 -19
- package/src/server/routes/fs/action-create.test.ts +90 -0
- package/src/server/routes/fs/action.ts +23 -0
- package/src/server/routes/omp/index.ts +2 -0
- package/src/server/routes/omp/session-todos.ts +80 -0
- package/src/server/routes/settings/index.ts +2 -0
- package/src/server/routes/settings/provider-model.ts +107 -0
- package/src/server/routes/settings/provider-models.ts +65 -202
- package/src/server/routes/settings/providers.ts +22 -7
- package/src/server/routes/settings/usage.ts +125 -45
- package/src/server/routes/telemetry/tokens.ts +2 -1
- package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
- package/src/shared/lib/chat/todo/progress.ts +97 -0
- package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
- package/src/shared/lib/chat/todo/snapshot.ts +215 -0
- package/src/shared/lib/code/editor/commands.test.ts +155 -0
- package/src/shared/lib/code/editor/commands.ts +260 -0
- package/src/shared/lib/code/editor/edits.test.ts +100 -0
- package/src/shared/lib/code/editor/edits.ts +122 -0
- package/src/shared/lib/code/editor/find-status.test.ts +50 -0
- package/src/shared/lib/code/editor/find-status.ts +36 -0
- package/src/shared/lib/code/editor/find.test.ts +203 -0
- package/src/shared/lib/code/editor/find.ts +224 -0
- package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
- package/src/shared/lib/code/editor/keymap.test.ts +109 -0
- package/src/shared/lib/code/editor/keymap.ts +151 -0
- package/src/shared/lib/code/editor/layers.test.ts +77 -0
- package/src/shared/lib/code/editor/layers.ts +67 -0
- package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
- package/src/shared/lib/code/editor/line-edits.ts +159 -0
- package/src/shared/lib/code/editor/lines.test.ts +81 -0
- package/src/shared/lib/code/editor/lines.ts +83 -0
- package/src/shared/lib/code/editor/marks.test.ts +86 -0
- package/src/shared/lib/code/editor/marks.ts +106 -0
- package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
- package/src/shared/lib/code/editor/occurrences.ts +111 -0
- package/src/shared/lib/code/editor/palette.test.ts +91 -0
- package/src/shared/lib/code/editor/palette.ts +92 -0
- package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
- package/src/shared/lib/code/editor/reset-css.ts +32 -0
- package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
- package/src/shared/lib/code/editor/text-commands.ts +154 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
- package/src/shared/lib/code/syntax-highlight.ts +29 -17
- package/src/shared/lib/code/windowed-highlight.ts +52 -1
- package/src/shared/lib/fs/diff-parser.test.ts +116 -0
- package/src/shared/lib/fs/diff-parser.ts +86 -33
- package/src/shared/lib/models/{provider-connection.ts → provider/connection.ts} +37 -0
- package/src/shared/lib/models/provider/dialect.ts +179 -0
- package/src/shared/lib/models/provider/glyph.test.ts +116 -0
- package/src/shared/lib/models/provider/glyph.ts +200 -0
- package/src/shared/lib/models/{provider-models.ts → provider/models.ts} +91 -6
- package/src/shared/lib/models/provider/presets.test.ts +172 -0
- package/src/shared/lib/models/provider/presets.ts +83 -0
- package/src/shared/lib/ui/key-binding.test.ts +115 -0
- package/src/shared/lib/ui/key-binding.ts +143 -0
- package/src/shared/lib/util/platform.test.ts +87 -0
- package/src/shared/lib/util/platform.ts +30 -0
- package/src/shared/lib/workspace/file-tab-id.ts +29 -0
- package/src/shared/lib/workspace/keymap.test.ts +82 -0
- package/src/shared/lib/workspace/keymap.ts +30 -0
- package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
- package/src/shared/lib/workspace/right-panels.ts +8 -0
- package/src/shared/lib/workspace/session-state/store.ts +0 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/settings/provider.ts +81 -0
- package/src/shared/types/settings/usage.ts +88 -14
- package/src/shared/types/todo.ts +108 -0
- package/tsconfig.json +3 -1
- package/dist/client/static/css/index.5f630421b7.css +0 -2
- package/dist/client/static/font/KaTeX_AMS-Regular.a79f1c3119.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Caligraphic-Bold.ec17d13264.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Caligraphic-Regular.55fac25845.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Fraktur-Bold.d42a5579b0.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Fraktur-Regular.d3c882a649.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Main-Bold.c3fb5ac22f.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Main-BoldItalic.6f2bb1dff2.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Main-Italic.8916142bec.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Main-Regular.0462f03bdf.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Math-BoldItalic.572d331f69.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Math-Italic.f28c23acad.woff2 +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Bold.8c5b5494b6.woff2 +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Italic.3b1e59b3ba.woff2 +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Regular.ba21ed5f84.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Script-Regular.03e9641d6f.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Size1-Regular.eae34984b3.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Size2-Regular.5916a24fa3.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Size4-Regular.10d95fd3a2.woff2 +0 -0
- package/dist/client/static/font/KaTeX_Typewriter-Regular.a8709e3622.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-400-normal.2655b10c55.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-500-normal.92a991f204.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-600-normal.43788eb7f1.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-700-normal.2667215d33.woff2 +0 -0
- package/dist/client/static/js/77.0d81c74cf0.js +0 -139
- package/dist/client/static/js/77.0d81c74cf0.js.LICENSE.txt +0 -8
- package/dist/client/static/js/async/0.8e59f2eb5b.js +0 -1
- package/dist/client/static/js/async/0k.5852e6b4fa.js +0 -1
- package/dist/client/static/js/async/0w.9ed36d5860.js +0 -1
- package/dist/client/static/js/async/1.f387c12b63.js +0 -1
- package/dist/client/static/js/async/12.d091f91c99.js +0 -1
- package/dist/client/static/js/async/13.8f810a8a6c.js +0 -1
- package/dist/client/static/js/async/19.6fe909d55a.js +0 -160
- package/dist/client/static/js/async/1b.23f1141a38.js +0 -1
- package/dist/client/static/js/async/1k.973e8e21fc.js +0 -2
- package/dist/client/static/js/async/1k.973e8e21fc.js.LICENSE.txt +0 -11
- package/dist/client/static/js/async/1p.0d5cddda96.js +0 -1
- package/dist/client/static/js/async/2.b589f1733b.js +0 -1
- package/dist/client/static/js/async/2v.96165101de.js +0 -1
- package/dist/client/static/js/async/3.0cacc08423.js +0 -1
- package/dist/client/static/js/async/37.b28b2f61cd.js +0 -1
- package/dist/client/static/js/async/3d.5085fb913f.js +0 -1
- package/dist/client/static/js/async/3j.357c80ff38.js +0 -2
- package/dist/client/static/js/async/3j.357c80ff38.js.LICENSE.txt +0 -11
- package/dist/client/static/js/async/3p.bcf32a0983.js +0 -1
- package/dist/client/static/js/async/3r.3a69e98ee0.js +0 -64
- package/dist/client/static/js/async/3u.1228460e2a.js +0 -1
- package/dist/client/static/js/async/3y.3530b824ad.js +0 -198
- package/dist/client/static/js/async/3y.3530b824ad.js.LICENSE.txt +0 -5
- package/dist/client/static/js/async/4.9442eeb472.js +0 -1
- package/dist/client/static/js/async/4e.678285ca55.js +0 -1
- package/dist/client/static/js/async/4k.33a0c7eeb4.js +0 -1
- package/dist/client/static/js/async/4m.928a1dde85.js +0 -31
- package/dist/client/static/js/async/4m.928a1dde85.js.LICENSE.txt +0 -11
- package/dist/client/static/js/async/4q.aad20dab25.js +0 -1
- package/dist/client/static/js/async/5.ebaf8b89cc.js +0 -36
- package/dist/client/static/js/async/51.95bcae98af.js +0 -2
- package/dist/client/static/js/async/51.95bcae98af.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
- package/dist/client/static/js/async/5j.d6407c4248.js +0 -287
- package/dist/client/static/js/async/5o.88316d4460.js +0 -62
- package/dist/client/static/js/async/6.f034c9b528.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/6o.5b1a06a382.js +0 -1
- package/dist/client/static/js/async/7.e4a9288dd0.js +0 -1
- package/dist/client/static/js/async/73.308dff4da8.js +0 -1
- package/dist/client/static/js/async/7e.a1cc9bfd88.js +0 -1
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.fadbbc0583.js +0 -1
- package/dist/client/static/js/async/8.5067439d8c.js +0 -1
- package/dist/client/static/js/async/8j.ea110663fb.js +0 -1
- package/dist/client/static/js/async/a.bce4542392.js +0 -1
- package/dist/client/static/js/async/aa.65d3852576.js +0 -1
- package/dist/client/static/js/async/ar.9303e5f2e8.js +0 -2
- package/dist/client/static/js/async/ar.9303e5f2e8.js.LICENSE.txt +0 -11
- package/dist/client/static/js/async/az.d656062b0f.js +0 -1
- package/dist/client/static/js/async/b.b4f6f06515.js +0 -1
- package/dist/client/static/js/async/bi.09688ba75c.js +0 -1
- package/dist/client/static/js/async/bq.586bc738d1.js +0 -1
- package/dist/client/static/js/async/c.797d933882.js +0 -1
- package/dist/client/static/js/async/c0.0af1e640d3.js +0 -24
- package/dist/client/static/js/async/ca.8a52cfd447.js +0 -1
- package/dist/client/static/js/async/cf.d34fff5d67.js +0 -1
- package/dist/client/static/js/async/ct.b74928f7a3.js +0 -1
- package/dist/client/static/js/async/cx.3cb0242a3a.js +0 -226
- package/dist/client/static/js/async/d.a5fc8fe1b3.js +0 -1
- package/dist/client/static/js/async/da.f62c70f6c8.js +0 -1
- package/dist/client/static/js/async/dp.1725d6432c.js +0 -1
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/f.ab50467719.js +0 -19
- package/dist/client/static/js/async/f.ab50467719.js.LICENSE.txt +0 -12
- package/dist/client/static/js/async/f7.46971e9d6d.js +0 -2
- package/dist/client/static/js/async/f7.46971e9d6d.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/f8.bbf0dbae70.js +0 -1
- package/dist/client/static/js/async/g.cb81980498.js +0 -2
- package/dist/client/static/js/async/g.cb81980498.js.LICENSE.txt +0 -11
- package/dist/client/static/js/async/h.4e790471e4.js +0 -27
- package/dist/client/static/js/async/h1.393f066d09.js +0 -1
- package/dist/client/static/js/async/h3.216e9798ab.js +0 -148
- package/dist/client/static/js/async/hd.0da931b622.js +0 -1
- package/dist/client/static/js/async/hk.ba7fb04d41.js +0 -1
- package/dist/client/static/js/async/hn.b645041e00.js +0 -1
- package/dist/client/static/js/async/ho.475a5e1ae4.js +0 -1
- package/dist/client/static/js/async/hp.e9fe2e395a.js +0 -2
- package/dist/client/static/js/async/hp.e9fe2e395a.js.LICENSE.txt +0 -17
- package/dist/client/static/js/async/hs.e7c59d8090.js +0 -41
- package/dist/client/static/js/async/i.7c148e8440.js +0 -1
- package/dist/client/static/js/async/id.573df20f29.js +0 -1
- package/dist/client/static/js/async/j.ab925882d8.js +0 -1
- package/dist/client/static/js/async/jd.63886a1f09.js +0 -1
- package/dist/client/static/js/async/jg.d86ebd4013.js +0 -86
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/jll.11313b455a.js +0 -1
- package/dist/client/static/js/async/js.67182759ef.js +0 -1
- package/dist/client/static/js/async/jsc.23b70d6f40.js +0 -1
- package/dist/client/static/js/async/jse.334588ff5d.js +0 -1
- package/dist/client/static/js/async/jz.72eb929106.js +0 -77
- package/dist/client/static/js/async/k.3210286401.js +0 -217
- package/dist/client/static/js/async/ka.c1990ecaa3.js +0 -115
- package/dist/client/static/js/async/katex-vendor.de6a87c308.js +0 -1
- package/dist/client/static/js/async/ku.29c8a59d0c.js +0 -1
- package/dist/client/static/js/async/ky.8dab1dade0.js +0 -1
- package/dist/client/static/js/async/l.12dd34e1be.js +0 -1
- package/dist/client/static/js/async/l9.949710adaf.js +0 -83
- package/dist/client/static/js/async/l9.949710adaf.js.LICENSE.txt +0 -13
- package/dist/client/static/js/async/ls.9d9a01026c.js +0 -104
- package/dist/client/static/js/async/m.eb2e6752e2.js +0 -28
- package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +0 -11
- package/dist/client/static/js/async/m3.d2b05f9604.js +0 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/ma.2c261b04dd.js +0 -2
- package/dist/client/static/js/async/ma.2c261b04dd.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/me.8ace414f80.js +0 -23
- package/dist/client/static/js/async/ms.98960af4eb.js +0 -67
- package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +0 -12
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/n.a9f1c51701.js +0 -1
- package/dist/client/static/js/async/na.a36cd532d6.js +0 -1
- package/dist/client/static/js/async/nk.fc7e58325e.js +0 -1
- package/dist/client/static/js/async/o.36353dfb88.js +0 -1
- package/dist/client/static/js/async/oc.b73042e8bd.js +0 -8
- package/dist/client/static/js/async/og.8eb577893a.js +0 -193
- package/dist/client/static/js/async/p.48ecfcd14e.js +0 -1
- package/dist/client/static/js/async/px.fc6877924b.js +0 -1
- package/dist/client/static/js/async/q.22588845a9.js +0 -21
- package/dist/client/static/js/async/q.22588845a9.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/q5.97dc1481b6.js +0 -286
- package/dist/client/static/js/async/qs.2a9ddb3c72.js +0 -1
- package/dist/client/static/js/async/r.51010ad87c.js +0 -1
- package/dist/client/static/js/async/rd.cb6bf495bb.js +0 -2
- package/dist/client/static/js/async/rd.cb6bf495bb.js.LICENSE.txt +0 -4
- package/dist/client/static/js/async/s.0f395cdda5.js +0 -94
- package/dist/client/static/js/async/t.4dca0e2ba8.js +0 -1
- package/dist/client/static/js/async/t9.d44f57d518.js +0 -1
- package/dist/client/static/js/async/ta.973534bbec.js +0 -1
- package/dist/client/static/js/async/tr.e878658140.js +0 -309
- package/dist/client/static/js/async/u.0871ac5b22.js +0 -1
- package/dist/client/static/js/async/ud.3065bfee31.js +0 -2
- package/dist/client/static/js/async/ud.3065bfee31.js.LICENSE.txt +0 -4
- package/dist/client/static/js/async/up.ea990da0ff.js +0 -32
- package/dist/client/static/js/async/uw.8890abbabc.js +0 -1
- package/dist/client/static/js/async/v.7d28d3c241.js +0 -34
- package/dist/client/static/js/async/vp.9b13bf8610.js +0 -1
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/w.08ac210f60.js +0 -1
- package/dist/client/static/js/async/wt.ad92393a90.js +0 -1
- package/dist/client/static/js/async/x.563f72ca6b.js +0 -1
- package/dist/client/static/js/async/x0.475516418f.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/xe.da8dcc2015.js +0 -2
- package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +0 -11
- package/dist/client/static/js/async/xl.a3c562a58c.js +0 -1
- package/dist/client/static/js/async/xn.6510c0abe2.js +0 -2
- package/dist/client/static/js/async/xn.6510c0abe2.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/xn1.83a7784fe1.js +0 -10
- package/dist/client/static/js/async/xn1.83a7784fe1.js.LICENSE.txt +0 -11
- package/dist/client/static/js/async/xnl.feb0021e50.js +0 -2
- package/dist/client/static/js/async/xnl.feb0021e50.js.LICENSE.txt +0 -12
- package/dist/client/static/js/async/y.1a9ed7a5d8.js +0 -1
- package/dist/client/static/js/async/y6.43c57defe4.js +0 -1
- package/dist/client/static/js/async/yb.8c1342401d.js +0 -1
- package/dist/client/static/js/async/ybq.4491f2f2dc.js +0 -1
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/async/yq.917ddd7ba2.js +0 -1
- package/dist/client/static/js/async/ys.00687cf82d.js +0 -38
- package/dist/client/static/js/async/yv.d99d829136.js +0 -1
- package/dist/client/static/js/async/z.56f61a4374.js +0 -1
- package/dist/client/static/js/async/zi.5fdb07b86a.js +0 -24
- package/dist/client/static/js/async/zj.654f37e8ff.js +0 -1
- package/dist/client/static/js/async/zn.966d6f1cc6.js +0 -1
- package/dist/client/static/js/async/zo.26213b40c0.js +0 -1
- package/dist/client/static/js/async/zx.e9f4105e00.js +0 -1
- package/dist/client/static/js/index.e6a257402a.js +0 -38
- package/dist/client/static/js/index.e6a257402a.js.LICENSE.txt +0 -4
- package/dist/client/static/js/markdown-vendor.71e43278b3.js +0 -144
- package/dist/client/static/js/shiki-vendor.cb8452ddcd.js +0 -1
- package/src/client/components/settings/categories/provider-settings/Icons.tsx +0 -35
- package/src/client/hooks/settings/providers.ts +0 -326
- package/src/server/plugins/dev-assets.ts +0 -49
- package/src/shared/lib/code/editor-utils.ts +0 -16
- package/src/shared/lib/models/provider-presets.ts +0 -29
- /package/dist/client/{apple-touch-icon.png → static/apple-touch-icon-dskx5sr3.png} +0 -0
- /package/dist/client/{icon-192x192.png → static/icon-192x192-6gn5cr2f.png} +0 -0
- /package/dist/client/{icon.svg → static/icon-d07z9dtz.svg} +0 -0
- /package/dist/client/{manifest.webmanifest → static/manifest-bnyy75kt.webmanifest} +0 -0
- /package/{dist/client → public}/icon-512x512.png +0 -0
- /package/{dist/client → public}/icon-maskable-512x512.png +0 -0
- /package/{dist/client → public}/sw.js +0 -0
- /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
- /package/src/shared/lib/models/{provider-cleanup.ts → provider/cleanup.ts} +0 -0
- /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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 (
|
|
208
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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,
|
|
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
|
+
});
|