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,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Line arithmetic over a buffer.
|
|
8
|
+
*
|
|
9
|
+
* Extracted from the find module because the editor's line commands (delete,
|
|
10
|
+
* move, duplicate, comment) and the highlighter need the same two facts — which
|
|
11
|
+
* line an offset is on, and where that line begins — and neither of them is a
|
|
12
|
+
* find concern. Keeping one implementation is what stops a line command from
|
|
13
|
+
* disagreeing with the gutter about where line 12 starts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Half-open `[start, end)` offset range in a document. */
|
|
17
|
+
export interface TextRange {
|
|
18
|
+
start: number;
|
|
19
|
+
end: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Index of the line containing `offset` (0-based). */
|
|
23
|
+
export function lineIndexAt(text: string, offset: number): number {
|
|
24
|
+
const limit = Math.min(Math.max(offset, 0), text.length);
|
|
25
|
+
let line = 0;
|
|
26
|
+
for (let index = text.indexOf('\n'); index !== -1 && index < limit; index = text.indexOf('\n', index + 1)) {
|
|
27
|
+
line++;
|
|
28
|
+
}
|
|
29
|
+
return line;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Offset of the first character of line `lineIndex` (0-based). */
|
|
33
|
+
export function lineStartOffset(text: string, lineIndex: number): number {
|
|
34
|
+
let offset = 0;
|
|
35
|
+
for (let i = 0; i < lineIndex; i++) {
|
|
36
|
+
const next = text.indexOf('\n', offset);
|
|
37
|
+
if (next === -1) return text.length;
|
|
38
|
+
offset = next + 1;
|
|
39
|
+
}
|
|
40
|
+
return offset;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** How many lines `text` holds; a trailing newline opens one more (empty) line. */
|
|
44
|
+
export function countLines(text: string): number {
|
|
45
|
+
let count = 1;
|
|
46
|
+
for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) count++;
|
|
47
|
+
return count;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The whole line containing `offset`, INCLUDING its trailing newline.
|
|
52
|
+
*
|
|
53
|
+
* Including the newline is what makes the range directly usable by the line
|
|
54
|
+
* commands: deleting it removes the line rather than blanking it, and moving it
|
|
55
|
+
* carries the line's own separator with it. A caret on the empty line a
|
|
56
|
+
* trailing newline opens (`"a\n"` at offset 2) yields an empty range there,
|
|
57
|
+
* which the commands treat as "the newline above" — the honest reading of a
|
|
58
|
+
* position that has no characters of its own.
|
|
59
|
+
*/
|
|
60
|
+
export function lineRangeAt(text: string, offset: number): TextRange {
|
|
61
|
+
const lineStart = lineStartOffset(text, lineIndexAt(text, offset));
|
|
62
|
+
const lineEnd = text.indexOf('\n', lineStart);
|
|
63
|
+
if (lineEnd === -1) return { start: lineStart, end: text.length };
|
|
64
|
+
return { start: lineStart, end: lineEnd + 1 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The ranges of every line a selection touches, in document order.
|
|
69
|
+
*
|
|
70
|
+
* A selection ending exactly at a line start does not touch that line — the
|
|
71
|
+
* same rule VS Code applies, and the one that keeps ⌘⇧L on a whole-line
|
|
72
|
+
* selection from splitting off an empty trailing entry.
|
|
73
|
+
*/
|
|
74
|
+
export function lineRangesInSelection(text: string, selection: TextRange): TextRange[] {
|
|
75
|
+
const startLine = lineIndexAt(text, selection.start);
|
|
76
|
+
const lastOffset = selection.end > selection.start && text[selection.end - 1] === '\n' ? selection.end - 1 : selection.end;
|
|
77
|
+
const endLine = lineIndexAt(text, lastOffset);
|
|
78
|
+
const ranges: TextRange[] = [];
|
|
79
|
+
for (let line = startLine; line <= endLine; line++) {
|
|
80
|
+
ranges.push(lineRangeAt(text, lineStartOffset(text, line)));
|
|
81
|
+
}
|
|
82
|
+
return ranges;
|
|
83
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Find marks spliced into the highlighter's token markup.
|
|
8
|
+
*
|
|
9
|
+
* The cases that matter are the ones a naive implementation gets wrong: a match
|
|
10
|
+
* that spans several tokens (`const alpha` is three), a token that holds two
|
|
11
|
+
* matches, and text that has to be escaped on the way in. Every assertion is on
|
|
12
|
+
* the TEXT of the marks, because a match broken across tokens is several
|
|
13
|
+
* fragments by construction.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
|
|
18
|
+
import { tokenMarkup, MARK_CURRENT_OPEN, MARK_OPEN } from '@/shared/lib/code/editor/marks';
|
|
19
|
+
|
|
20
|
+
/** The text of every mark in `html`, in order. */
|
|
21
|
+
function marked(html: string): string[] {
|
|
22
|
+
return Array.from(html.matchAll(/<mark class="find-match"[^>]*>(.*?)<\/mark>/g), (match) => match[1]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('tokenMarkup', () => {
|
|
26
|
+
test('returns escaped content unchanged when there are no marks', () => {
|
|
27
|
+
expect(tokenMarkup('a < b', 0, undefined, -1)).toBe('a < b');
|
|
28
|
+
expect(tokenMarkup('a < b', 0, [], -1)).toBe('a < b');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('wraps the part of the token the match covers', () => {
|
|
32
|
+
expect(tokenMarkup('alpha', 0, [{ start: 0, end: 5 }], 0)).toBe(`${MARK_CURRENT_OPEN}alpha</mark>`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('splits a token that holds two matches', () => {
|
|
36
|
+
const html = tokenMarkup('a a', 0, [{ start: 0, end: 1 }, { start: 2, end: 3 }], -1);
|
|
37
|
+
|
|
38
|
+
expect(marked(html)).toEqual(['a', 'a']);
|
|
39
|
+
expect(html.match(/<mark/g)?.length).toBe(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('clips a match that starts before the token', () => {
|
|
43
|
+
// `const alpha` spans three tokens; the fragment inside `alpha` is the tail
|
|
44
|
+
// of one match, and the markup must not repeat the part already emitted.
|
|
45
|
+
const html = tokenMarkup('alpha', 6, [{ start: 0, end: 11 }], 0);
|
|
46
|
+
|
|
47
|
+
expect(marked(html)).toEqual(['alpha']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('clips a match that runs past the token', () => {
|
|
51
|
+
const html = tokenMarkup('const', 0, [{ start: 0, end: 11 }], -1);
|
|
52
|
+
|
|
53
|
+
expect(marked(html)).toEqual(['const']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('escapes the matched text it wraps', () => {
|
|
57
|
+
expect(tokenMarkup('<', 0, [{ start: 0, end: 1 }], -1)).toContain('<');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('the current match and an extra selection are painted differently', () => {
|
|
61
|
+
// The caret can only be in the primary range, so the two must not look
|
|
62
|
+
// alike — the user has to see where their keystrokes will land.
|
|
63
|
+
expect(tokenMarkup('a', 0, [{ start: 0, end: 1 }], 0)).toContain('data-find-current');
|
|
64
|
+
expect(tokenMarkup('a', 0, [{ start: 0, end: 1 }], -1, 0)).toContain('data-occurrence');
|
|
65
|
+
expect(tokenMarkup('a', 0, [{ start: 0, end: 1 }], -1, 0)).not.toContain('data-find-current');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a plain match is neither current nor an occurrence', () => {
|
|
69
|
+
const html = tokenMarkup('a', 0, [{ start: 0, end: 1 }], -1);
|
|
70
|
+
|
|
71
|
+
expect(html).toContain(MARK_OPEN);
|
|
72
|
+
expect(html).not.toContain('data-');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('marks are read in document order, so a match before the token is skipped', () => {
|
|
76
|
+
// The loop stops at the first mark that begins after the token: anything
|
|
77
|
+
// earlier cannot intersect it.
|
|
78
|
+
const html = tokenMarkup('beta', 6, [{ start: 0, end: 5 }, { start: 6, end: 10 }], -1);
|
|
79
|
+
|
|
80
|
+
expect(marked(html)).toEqual(['beta']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a zero-width mark paints nothing', () => {
|
|
84
|
+
expect(marked(tokenMarkup('alpha', 0, [{ start: 2, end: 2 }], -1))).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Find matches spliced into the highlighter's own markup.
|
|
8
|
+
*
|
|
9
|
+
* The find widget's highlights ride on the same layer the tokens do, so a mark
|
|
10
|
+
* can never drift from the text it belongs to. That means walking each token's
|
|
11
|
+
* content and inserting `<mark>` around the parts of it a match covers — a
|
|
12
|
+
* match routinely spans several tokens (`const alpha` is three) and a token
|
|
13
|
+
* routinely holds several matches.
|
|
14
|
+
*
|
|
15
|
+
* `<mark>` is not decoration: it is an INLINE element with a background and no
|
|
16
|
+
* padding or border, which is what keeps the transparent `<textarea>` above the
|
|
17
|
+
* layer aligned with the glyphs underneath. Anything that changes the box —
|
|
18
|
+
* a block display, a border, an outline with an offset — moves the text under
|
|
19
|
+
* the caret and breaks editing.
|
|
20
|
+
*
|
|
21
|
+
* Kept out of `syntax-highlight.ts` so the tokenizer module stays under the
|
|
22
|
+
* repository's line ceiling and because this is the find feature's concern
|
|
23
|
+
* inside the highlight pipeline, not the highlighter's.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { escapeCode } from '@/shared/lib/code/highlighter';
|
|
27
|
+
import type { FindMatch } from '@/shared/lib/code/editor/find';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How `highlightLines` paints its marks. Declared here rather than in the
|
|
31
|
+
* highlighter because the marks ARE this module's concern; the highlighter
|
|
32
|
+
* imports the type.
|
|
33
|
+
*/
|
|
34
|
+
export interface HighlightLinesOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Lines at or above this length are emitted as plain text instead of being
|
|
37
|
+
* tokenized — the guard a minified bundle (one line of hundreds of KB) needs.
|
|
38
|
+
*/
|
|
39
|
+
maxLineLength?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Match ranges (absolute offsets in `code`) to wrap in `<mark class="find-match">`.
|
|
42
|
+
* The find widget's highlights ride on the same markup the tokens do, so a
|
|
43
|
+
* match cannot drift from the text it belongs to — and an inline `<mark>`
|
|
44
|
+
* paints a background without moving the glyphs, which is what keeps the
|
|
45
|
+
* transparent `<textarea>` above aligned with this layer.
|
|
46
|
+
*/
|
|
47
|
+
marks?: readonly FindMatch[];
|
|
48
|
+
/** Index into `marks` of the current match, marked `data-find-current`. */
|
|
49
|
+
currentMark?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Marks from this index on are extra ⌘D SELECTIONS rather than find matches,
|
|
52
|
+
* and are painted as such. The caller appends them after the matches, so one
|
|
53
|
+
* index is all the split needs.
|
|
54
|
+
*/
|
|
55
|
+
occurrenceFrom?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const MARK_OPEN = '<mark class="find-match">';
|
|
59
|
+
/** The current match is the one the caret/next-step is aimed at; the CSS paints it darker. */
|
|
60
|
+
export const MARK_CURRENT_OPEN = '<mark class="find-match" data-find-current="">';
|
|
61
|
+
/**
|
|
62
|
+
* An extra ⌘D range. It is not a find match — it is a second SELECTION, and it
|
|
63
|
+
* has to read as one: the caret can only be in the primary range, so painting
|
|
64
|
+
* them alike would leave the user unable to tell which one their keystrokes go
|
|
65
|
+
* to. The two differ only in colour; the geometry is shared, which is what
|
|
66
|
+
* keeps the layers aligned.
|
|
67
|
+
*/
|
|
68
|
+
export const MARK_OCCURRENCE_OPEN = '<mark class="find-match" data-occurrence="">';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `content` with every intersecting mark spliced in, HTML-escaped.
|
|
72
|
+
*
|
|
73
|
+
* `tokenStart` is the token's absolute offset in the document; `marks` are in
|
|
74
|
+
* document order, which is what lets the loop stop at the first mark that
|
|
75
|
+
* begins after the token rather than scanning the whole list per token.
|
|
76
|
+
*/
|
|
77
|
+
export function tokenMarkup(
|
|
78
|
+
content: string,
|
|
79
|
+
tokenStart: number,
|
|
80
|
+
marks: readonly FindMatch[] | undefined,
|
|
81
|
+
currentMark: number,
|
|
82
|
+
occurrenceFrom = -1,
|
|
83
|
+
): string {
|
|
84
|
+
const tokenEnd = tokenStart + content.length;
|
|
85
|
+
if (!marks || marks.length === 0) return escapeCode(content);
|
|
86
|
+
let html = '';
|
|
87
|
+
let cursor = 0;
|
|
88
|
+
for (let index = 0; index < marks.length; index++) {
|
|
89
|
+
const mark = marks[index];
|
|
90
|
+
// Marks and tokens are both in document order: anything that ended before
|
|
91
|
+
// this token cannot intersect it.
|
|
92
|
+
if (mark.end <= tokenStart) continue;
|
|
93
|
+
if (mark.start >= tokenEnd) break;
|
|
94
|
+
// A zero-width range has no text to paint; emitting one produced an empty
|
|
95
|
+
// `<mark></mark>`, which is invisible but still a DOM node per occurrence.
|
|
96
|
+
if (mark.end <= mark.start) continue;
|
|
97
|
+
const from = Math.max(mark.start, tokenStart) - tokenStart;
|
|
98
|
+
const to = Math.min(mark.end, tokenEnd) - tokenStart;
|
|
99
|
+
if (to <= cursor) continue;
|
|
100
|
+
html += escapeCode(content.slice(cursor, from));
|
|
101
|
+
const open = index === currentMark ? MARK_CURRENT_OPEN : index >= occurrenceFrom && occurrenceFrom >= 0 ? MARK_OCCURRENCE_OPEN : MARK_OPEN;
|
|
102
|
+
html += open + escapeCode(content.slice(from, to)) + '</mark>';
|
|
103
|
+
cursor = to;
|
|
104
|
+
}
|
|
105
|
+
return html + escapeCode(content.slice(cursor));
|
|
106
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The multi-selection rules.
|
|
8
|
+
*
|
|
9
|
+
* ⌘D's two-stage behaviour is the one users notice immediately: the first press
|
|
10
|
+
* selects the word, the second adds the next occurrence. Getting that wrong
|
|
11
|
+
* leaves the word selected once and its neighbour selected twice, which reads
|
|
12
|
+
* as a duplicated cursor.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
growToNextOccurrence,
|
|
19
|
+
offsetBelow,
|
|
20
|
+
replicateAtRanges,
|
|
21
|
+
selectAllOccurrences,
|
|
22
|
+
} from '@/shared/lib/code/editor/occurrences';
|
|
23
|
+
|
|
24
|
+
describe('growToNextOccurrence', () => {
|
|
25
|
+
const text = 'alpha beta alpha gamma alpha';
|
|
26
|
+
|
|
27
|
+
test('the first press on a bare caret selects the word and adds nothing', () => {
|
|
28
|
+
expect(growToNextOccurrence(text, { start: 2, end: 2 }, [])).toEqual({
|
|
29
|
+
primary: { start: 0, end: 5 },
|
|
30
|
+
extras: [],
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('the second press adds the next occurrence', () => {
|
|
35
|
+
const grown = growToNextOccurrence(text, { start: 0, end: 5 }, []);
|
|
36
|
+
expect(grown?.primary).toEqual({ start: 0, end: 5 });
|
|
37
|
+
expect(grown?.extras).toEqual([{ start: 11, end: 16 }]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('each further press adds the one after it, never a duplicate', () => {
|
|
41
|
+
const grown = growToNextOccurrence(text, { start: 0, end: 5 }, [{ start: 11, end: 16 }]);
|
|
42
|
+
expect(grown?.extras).toEqual([
|
|
43
|
+
{ start: 11, end: 16 },
|
|
44
|
+
{ start: 23, end: 28 },
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('reports nothing when every occurrence is already selected', () => {
|
|
49
|
+
const extras = [
|
|
50
|
+
{ start: 11, end: 16 },
|
|
51
|
+
{ start: 23, end: 28 },
|
|
52
|
+
];
|
|
53
|
+
expect(growToNextOccurrence(text, { start: 0, end: 5 }, extras)).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('reports nothing on whitespace with no word beside it', () => {
|
|
57
|
+
expect(growToNextOccurrence(' ', { start: 1, end: 1 }, [])).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('selectAllOccurrences', () => {
|
|
62
|
+
test('the first occurrence is the primary, the rest are extras', () => {
|
|
63
|
+
const all = selectAllOccurrences('alpha beta alpha', { start: 0, end: 5 });
|
|
64
|
+
|
|
65
|
+
expect(all?.primary).toEqual({ start: 0, end: 5 });
|
|
66
|
+
expect(all?.extras).toEqual([{ start: 11, end: 16 }]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a bare caret takes the word under it', () => {
|
|
70
|
+
const all = selectAllOccurrences('alpha beta alpha', { start: 2, end: 2 });
|
|
71
|
+
expect(all?.primary).toEqual({ start: 0, end: 5 });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a one-off word yields the selection alone', () => {
|
|
75
|
+
const all = selectAllOccurrences('alpha beta', { start: 0, end: 5 });
|
|
76
|
+
expect(all?.primary).toEqual({ start: 0, end: 5 });
|
|
77
|
+
expect(all?.extras).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('offsetBelow', () => {
|
|
82
|
+
test('keeps the column', () => {
|
|
83
|
+
expect(offsetBelow('one\ntwo\nthree', 1)).toBe(5);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('clamps to the end of a shorter next line', () => {
|
|
87
|
+
// Column 6 does not exist on `ab`; the caret lands at its end rather than
|
|
88
|
+
// on a character the line never had.
|
|
89
|
+
expect(offsetBelow('longer line\nab\nnext', 6)).toBe(14);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('reports nothing on the last line', () => {
|
|
93
|
+
expect(offsetBelow('one\ntwo', 5)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('replicateAtRanges', () => {
|
|
98
|
+
test('reports nothing when there are no extra ranges', () => {
|
|
99
|
+
expect(replicateAtRanges('abc', 'abXc', { start: 2, end: 2 }, [])).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('replays one insertion at every range and collapses them to carets', () => {
|
|
103
|
+
const applied = replicateAtRanges('alpha alpha', 'Xalpha alpha', { start: 0, end: 0 }, [{ start: 6, end: 6 }]);
|
|
104
|
+
|
|
105
|
+
expect(applied?.value).toBe('Xalpha Xalpha');
|
|
106
|
+
expect(applied?.caret).toBe(1);
|
|
107
|
+
expect(applied?.rest).toEqual([{ start: 8, end: 8 }]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('replays a replacement over the selected text', () => {
|
|
111
|
+
const applied = replicateAtRanges('alpha beta alpha', 'Z beta alpha', { start: 0, end: 5 }, [{ start: 11, end: 16 }]);
|
|
112
|
+
|
|
113
|
+
expect(applied?.value).toBe('Z beta Z');
|
|
114
|
+
// The caret sits after the inserted `Z`, which now ends the buffer.
|
|
115
|
+
expect(applied?.rest).toEqual([{ start: 8, end: 8 }]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The multi-selection rules: what ⌘D, ⌘⇧L and ⌘⇧D do to a buffer, and how one
|
|
8
|
+
* browser edit is replayed at every range.
|
|
9
|
+
*
|
|
10
|
+
* Pure on purpose. The hook that owns the ranges is a ref and a notify call;
|
|
11
|
+
* every decision about WHERE a range goes and what the buffer becomes lives
|
|
12
|
+
* here, so it is testable without a DOM and cannot drift between the keyboard
|
|
13
|
+
* path and the palette path (both call the same functions).
|
|
14
|
+
*
|
|
15
|
+
* The replication rule is the load-bearing one. The browser applies a keystroke
|
|
16
|
+
* at the primary range and reports the whole new buffer; diffing that against
|
|
17
|
+
* the old one recovers "what was typed", and the same replacement is replayed
|
|
18
|
+
* at every other range in one pass. Typing, pasting, deleting and IME
|
|
19
|
+
* composition therefore need no branch per case.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
diffEdit,
|
|
24
|
+
nextOccurrence,
|
|
25
|
+
occurrenceRanges,
|
|
26
|
+
replicateEdit,
|
|
27
|
+
wordRangeAt,
|
|
28
|
+
type TextRange,
|
|
29
|
+
} from '@/shared/lib/code/editor/commands';
|
|
30
|
+
|
|
31
|
+
/** What a selection command did: the primary range plus the extra ones. */
|
|
32
|
+
export interface OccurrenceSelection {
|
|
33
|
+
primary: TextRange;
|
|
34
|
+
extras: TextRange[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* ⌘D. With a bare caret the first press selects the word under it and stops —
|
|
39
|
+
* "select this word" and "and the next one too" are two different asks, and
|
|
40
|
+
* adding a range on the first press would leave the word selected once and its
|
|
41
|
+
* neighbour selected twice. With a selection, the next unselected occurrence is
|
|
42
|
+
* added.
|
|
43
|
+
*
|
|
44
|
+
* Null when there is no word and no selection, or when every occurrence is
|
|
45
|
+
* already selected: the caller then leaves the selection alone rather than
|
|
46
|
+
* cycling silently.
|
|
47
|
+
*/
|
|
48
|
+
export function growToNextOccurrence(
|
|
49
|
+
text: string,
|
|
50
|
+
primary: TextRange,
|
|
51
|
+
extras: readonly TextRange[],
|
|
52
|
+
): OccurrenceSelection | null {
|
|
53
|
+
const hasSelection = primary.end > primary.start;
|
|
54
|
+
const seed = hasSelection ? primary : wordRangeAt(text, primary.start);
|
|
55
|
+
if (!seed) return null;
|
|
56
|
+
const word = text.slice(seed.start, seed.end);
|
|
57
|
+
if (!word) return null;
|
|
58
|
+
|
|
59
|
+
if (!hasSelection) return { primary: seed, extras: [...extras] };
|
|
60
|
+
|
|
61
|
+
const next = nextOccurrence(text, word, [primary, ...extras]);
|
|
62
|
+
if (!next) return null;
|
|
63
|
+
return { primary, extras: [...extras, next] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ⌘⇧L. Every occurrence becomes a range: the first is the primary (the textarea
|
|
68
|
+
* can only hold one selection) and the rest are extras. Null when there is
|
|
69
|
+
* nothing to select.
|
|
70
|
+
*/
|
|
71
|
+
export function selectAllOccurrences(text: string, primary: TextRange): OccurrenceSelection | null {
|
|
72
|
+
const seed = primary.end > primary.start ? primary : wordRangeAt(text, primary.start);
|
|
73
|
+
if (!seed) return null;
|
|
74
|
+
const word = text.slice(seed.start, seed.end);
|
|
75
|
+
if (!word) return null;
|
|
76
|
+
const [first, ...rest] = occurrenceRanges(text, word);
|
|
77
|
+
if (!first) return null;
|
|
78
|
+
return { primary: first, extras: rest };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* ⌘⇧D. The offset directly below `offset`, keeping the column — and clamped to
|
|
83
|
+
* that line's end, so a caret at the end of a short line lands at the end of the
|
|
84
|
+
* next one rather than on a character it never had. Null on the last line.
|
|
85
|
+
*/
|
|
86
|
+
export function offsetBelow(text: string, offset: number): number | null {
|
|
87
|
+
const lineEnd = text.indexOf('\n', offset);
|
|
88
|
+
if (lineEnd === -1) return null;
|
|
89
|
+
const column = offset - (text.lastIndexOf('\n', offset - 1) + 1);
|
|
90
|
+
const nextLineStart = lineEnd + 1;
|
|
91
|
+
const nextLineEnd = text.indexOf('\n', nextLineStart);
|
|
92
|
+
return Math.min(nextLineStart + column, nextLineEnd === -1 ? text.length : nextLineEnd);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Replay the browser's edit (`before` → `after`, applied at `primaryBefore`) at
|
|
97
|
+
* every extra range. Null when there are no extras, which is the caller's cue to
|
|
98
|
+
* keep the ordinary single-selection path.
|
|
99
|
+
*/
|
|
100
|
+
export function replicateAtRanges(
|
|
101
|
+
before: string,
|
|
102
|
+
after: string,
|
|
103
|
+
primaryBefore: TextRange,
|
|
104
|
+
extras: readonly TextRange[],
|
|
105
|
+
): { value: string; caret: number; rest: TextRange[] } | null {
|
|
106
|
+
if (extras.length === 0) return null;
|
|
107
|
+
const diff = diffEdit(before, after, primaryBefore);
|
|
108
|
+
const applied = replicateEdit(before, [{ start: diff.start, end: diff.end }, ...extras], diff.inserted);
|
|
109
|
+
const [caret, ...rest] = applied.carets;
|
|
110
|
+
return { value: applied.value, caret, rest: rest.map((offset) => ({ start: offset, end: offset })) };
|
|
111
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The command palette's list.
|
|
8
|
+
*
|
|
9
|
+
* The palette is a VIEW of the keymap, so the property that matters is that it
|
|
10
|
+
* cannot list something the keymap does not bind — an entry that does nothing
|
|
11
|
+
* reads as a broken editor rather than a missing feature. The filter is the
|
|
12
|
+
* other half: a reader looks for a command by its name OR by the shortcut they
|
|
13
|
+
* already know, and both have to find it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
|
|
18
|
+
import { buildPaletteEntries, filterPaletteEntries } from '@/shared/lib/code/editor/palette';
|
|
19
|
+
import { EDITOR_KEY_BINDINGS } from '@/shared/lib/code/editor/keymap';
|
|
20
|
+
|
|
21
|
+
describe('buildPaletteEntries', () => {
|
|
22
|
+
test('every entry is a bound command, labelled with the keymap’s own words', () => {
|
|
23
|
+
const entries = buildPaletteEntries(true);
|
|
24
|
+
|
|
25
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
const binding = EDITOR_KEY_BINDINGS.find((candidate) => candidate.command === entry.command);
|
|
28
|
+
expect(binding).toBeDefined();
|
|
29
|
+
expect(entry.label).toBe(binding!.label);
|
|
30
|
+
// The chord is derived from the same stroke the handler matches, so a
|
|
31
|
+
// listed shortcut is one that actually fires.
|
|
32
|
+
expect(entry.chord.length).toBeGreaterThan(0);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('the chord shown is the platform’s own', () => {
|
|
37
|
+
const mac = buildPaletteEntries(true).find((entry) => entry.command === 'selectNextOccurrence');
|
|
38
|
+
const other = buildPaletteEntries(false).find((entry) => entry.command === 'selectNextOccurrence');
|
|
39
|
+
|
|
40
|
+
expect(mac?.chord).toBe('⌘D');
|
|
41
|
+
expect(other?.chord).toBe('Ctrl+D');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('no command is listed twice', () => {
|
|
45
|
+
const commands = buildPaletteEntries(true).map((entry) => entry.command);
|
|
46
|
+
expect(new Set(commands).size).toBe(commands.length);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a command the keymap does not bind is not listed', () => {
|
|
50
|
+
// `selectWord` has no binding by design; listing it would promise a chord.
|
|
51
|
+
const commands = buildPaletteEntries(true).map((entry) => entry.command);
|
|
52
|
+
expect(commands).not.toContain('selectWord');
|
|
53
|
+
expect(commands).not.toContain('escape');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('filterPaletteEntries', () => {
|
|
58
|
+
const entries = buildPaletteEntries(true);
|
|
59
|
+
|
|
60
|
+
test('an empty query lists everything, in the palette’s own order', () => {
|
|
61
|
+
expect(filterPaletteEntries(entries, '')).toBe(entries);
|
|
62
|
+
expect(filterPaletteEntries(entries, ' ')).toBe(entries);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('matches on the label', () => {
|
|
66
|
+
const found = filterPaletteEntries(entries, 'occurrence').map((entry) => entry.command);
|
|
67
|
+
expect(found).toContain('selectNextOccurrence');
|
|
68
|
+
expect(found).toContain('selectAllOccurrences');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('matches on the shortcut, which is how a reader who knows the chord looks', () => {
|
|
72
|
+
const found = filterPaletteEntries(entries, '⌘D').map((entry) => entry.command);
|
|
73
|
+
expect(found).toContain('selectNextOccurrence');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('every word has to match, so a two-word query narrows', () => {
|
|
77
|
+
const broad = filterPaletteEntries(entries, 'line');
|
|
78
|
+
const narrow = filterPaletteEntries(entries, 'line up');
|
|
79
|
+
|
|
80
|
+
expect(narrow.length).toBeLessThan(broad.length);
|
|
81
|
+
expect(narrow.map((entry) => entry.command)).toContain('moveLineUp');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a query nothing matches yields an empty list rather than everything', () => {
|
|
85
|
+
expect(filterPaletteEntries(entries, 'zzzz')).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('matching ignores case', () => {
|
|
89
|
+
expect(filterPaletteEntries(entries, 'UNDO').map((entry) => entry.command)).toEqual(['undo']);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The command palette's list, as data.
|
|
8
|
+
*
|
|
9
|
+
* The palette is a VIEW of the keymap rather than a second list to maintain, so
|
|
10
|
+
* the rules live here — pure, testable, and shared with the component. The
|
|
11
|
+
* property that matters most is negative: an entry the keymap does not bind must
|
|
12
|
+
* never appear, because a palette entry that does nothing reads as a broken
|
|
13
|
+
* editor rather than a missing feature.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { EDITOR_KEY_BINDINGS, type EditorCommand } from '@/shared/lib/code/editor/keymap';
|
|
17
|
+
import { describeBinding } from '@/shared/lib/ui/key-binding';
|
|
18
|
+
|
|
19
|
+
export interface PaletteEntry {
|
|
20
|
+
command: EditorCommand;
|
|
21
|
+
label: string;
|
|
22
|
+
chord: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The palette's running order: what a reader reaches for most, first. The
|
|
27
|
+
* commands are named here rather than derived from the keymap's own order, which
|
|
28
|
+
* groups chords by family for readability rather than by frequency of use.
|
|
29
|
+
*/
|
|
30
|
+
export const PALETTE_ORDER: readonly EditorCommand[] = [
|
|
31
|
+
'find',
|
|
32
|
+
'replace',
|
|
33
|
+
'selectNextOccurrence',
|
|
34
|
+
'selectAllOccurrences',
|
|
35
|
+
'insertCursorBelow',
|
|
36
|
+
'toggleComment',
|
|
37
|
+
'moveLineUp',
|
|
38
|
+
'moveLineDown',
|
|
39
|
+
'copyLineUp',
|
|
40
|
+
'copyLineDown',
|
|
41
|
+
'deleteLine',
|
|
42
|
+
'insertLineBelow',
|
|
43
|
+
'insertLineAbove',
|
|
44
|
+
'indent',
|
|
45
|
+
'outdent',
|
|
46
|
+
'toggleWordWrap',
|
|
47
|
+
'toggleCaseSensitive',
|
|
48
|
+
'toggleWholeWord',
|
|
49
|
+
'toggleRegex',
|
|
50
|
+
'undo',
|
|
51
|
+
'redo',
|
|
52
|
+
'lineStart',
|
|
53
|
+
'lineEnd',
|
|
54
|
+
'documentStart',
|
|
55
|
+
'documentEnd',
|
|
56
|
+
'selectToLineStart',
|
|
57
|
+
'selectToLineEnd',
|
|
58
|
+
'selectToDocumentStart',
|
|
59
|
+
'selectToDocumentEnd',
|
|
60
|
+
'nextMatch',
|
|
61
|
+
'previousMatch',
|
|
62
|
+
'replaceOne',
|
|
63
|
+
'replaceAll',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The entries, built from the keymap so a command cannot be listed and unbound.
|
|
68
|
+
* A command in `PALETTE_ORDER` that the keymap dropped is skipped rather than
|
|
69
|
+
* rendered with an empty chord.
|
|
70
|
+
*/
|
|
71
|
+
export function buildPaletteEntries(isMac: boolean): PaletteEntry[] {
|
|
72
|
+
return PALETTE_ORDER.flatMap((command) => {
|
|
73
|
+
const binding = EDITOR_KEY_BINDINGS.find((entry) => entry.command === command);
|
|
74
|
+
if (!binding) return [];
|
|
75
|
+
return [{ command, label: binding.label, chord: describeBinding(binding, isMac) }];
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The entries a query matches. Matched on the label, the chord and the command
|
|
81
|
+
* name: typing `⌘d` finds the same command as typing `occurrence`, which is how
|
|
82
|
+
* a reader who already knows the shortcut looks for it.
|
|
83
|
+
*/
|
|
84
|
+
export function filterPaletteEntries(entries: readonly PaletteEntry[], query: string): readonly PaletteEntry[] {
|
|
85
|
+
const needle = query.trim().toLowerCase();
|
|
86
|
+
if (!needle) return entries;
|
|
87
|
+
const parts = needle.split(/\s+/);
|
|
88
|
+
return entries.filter((entry) => {
|
|
89
|
+
const haystack = `${entry.label} ${entry.chord} ${entry.command}`.toLowerCase();
|
|
90
|
+
return parts.every((part) => haystack.includes(part));
|
|
91
|
+
});
|
|
92
|
+
}
|