mbeditor 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +191 -0
  3. data/README.md +226 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +948 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +661 -140
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +68 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +273 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +481 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/js_globals_service.rb +31 -2
  37. data/app/services/mbeditor/js_program_service.rb +173 -0
  38. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  39. data/app/services/mbeditor/model_graph_service.rb +232 -0
  40. data/app/services/mbeditor/presence_registry.rb +83 -0
  41. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  42. data/app/services/mbeditor/search_replace_service.rb +24 -4
  43. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  44. data/lib/mbeditor/configuration.rb +43 -3
  45. data/lib/mbeditor/engine.rb +34 -0
  46. data/lib/mbeditor/exception_log.rb +84 -0
  47. data/lib/mbeditor/route_map.rb +6 -0
  48. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  49. data/lib/mbeditor/version.rb +1 -1
  50. data/lib/mbeditor.rb +1 -0
  51. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  52. metadata +16 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47a8199a082957d396dce3cfa9de010ece11a11b2b938cf0cf81adfa6dd171f1
4
- data.tar.gz: 30dfee949bc6d06245c6bd1ba5b682431b36b064e0ad510b0dac44b320c5a287
3
+ metadata.gz: c8e586fb734529e92147e9fb6935782e79baeb9722c833e8df0a97cc2c86c637
4
+ data.tar.gz: 5da9242367bd195c715af433c8fb12fa440a610cacb5a1ff871cb8361c82233a
5
5
  SHA512:
6
- metadata.gz: 81c342ce0dc3f80c44259ae3a130710013dcb81f45052d4cc4488d2a7dc112f7decc6b5d67f3f6aaa81cbcd7ef391d82136ee1adccdd369b369c7cfcd4d6afa7
7
- data.tar.gz: b201dda9b9fe55e9f44981725788b1587bc052f940652e8eb7d86a8dcc3d17c45bb0984f71cd3bbbd9f3a001b3b0125ac09a9e53eedd3e580eab96a445be6a17
6
+ metadata.gz: f403b63083bc139fc6546477c42fb476283213f0409a287ec3ffe46cc1a90cb10f197d6521216efce8121d910fdb8b2c94eefd8cd0abd8829725c852f043e57a
7
+ data.tar.gz: 219a64ac7ecf5e2ded2081058f64f0bb4b0ce8373eac4f3cdcd8f9ca87aacd887676e680c31f360848dbcc255cfcdd176eaf67389569c64143639bf2643538f6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,197 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.12.0] - 2026-07-31
11
+
12
+ ### Added
13
+ - **Realtime collaborative editing (pair programming).** When Action Cable is
14
+ available, two or more people can open the same file and edit it together —
15
+ content converges through a Yjs CRDT, with live remote carets, selections and
16
+ a coloured name label per participant. Undo is scoped to your own edits, so
17
+ Ctrl+Z can never revert your partner's work. Joining late is safe: the shared
18
+ document wins over the copy on disk, and one save on any side settles the file
19
+ for everyone.
20
+
21
+ Participants appear as chips in the toolbar. A solid dot means they are in the
22
+ file you are looking at, a hollow ring means they are elsewhere (with their
23
+ filename beside it, when the toolbar is showing labels). Hovering gives their
24
+ name, current file and measured cable latency; clicking follows them, so their
25
+ file and scroll position track yours. Past three participants the chips
26
+ collapse to colour dots so the toolbar cannot overflow. Colours are assigned
27
+ against the live roster rather than hashed from the name, so two people never
28
+ share one while a free colour exists.
29
+
30
+ Collaboration only activates once someone else is actually connected — on your
31
+ own, the editor behaves exactly as before, keeping persistent undo history and
32
+ external-change detection.
33
+
34
+ **This is the one feature intended to be reached from another machine**, so it
35
+ is a deliberate exception to mbeditor's localhost-only posture. Read the
36
+ [Collaborative pairing](README.md#collaborative-pairing-optional) section
37
+ before exposing it: put it behind a trusted tunnel, set `authenticate_with`
38
+ (now also evaluated on the WebSocket handshake, fail-closed), restrict
39
+ `action_cable.allowed_request_origins`, and run a single web process. New
40
+ `user_name_callback` config resolves the display name from your host app.
41
+
42
+ - **Drag files and folders from your desktop straight into the explorer.**
43
+ Drop onto a folder row to import there, or onto the empty space below the
44
+ tree to import into the workspace root. Folders are imported recursively.
45
+ Any file type works — binary content round-trips byte for byte. When a
46
+ target path already exists, a dialog offers Overwrite all, Keep both (which
47
+ writes `name 2.ext` beside the original), or Skip. Imports are capped at 100
48
+ files and 50 MB per drop, and 5 MB per file, and every target path goes
49
+ through the same sandbox checks as every other file operation.
50
+
51
+ - **A model graph — an entity diagram of the host app's ActiveRecord models.**
52
+ The activity-bar button opens it as a full-width editor tab, drawing each
53
+ model with its fields and laying them out radially around whichever model has
54
+ the most associations. Search centres the view on a model; hovering an edge
55
+ names the relation.
56
+
57
+ Associations are read by **reflection, not by parsing model files**. mbeditor
58
+ runs inside the host app, so `reflect_on_all_associations` is right there and
59
+ resolves `class_name:`, `through:`, polymorphic and inverse sides correctly —
60
+ all of which regex or AST parsing of `has_many` lines silently gets wrong.
61
+ The graph never touches the database connection, so it works against a
62
+ database that isn't running or migrated. It is built only when the tab is
63
+ opened (it eager-loads the host app) and cached on a fingerprint of
64
+ `app/models` and `db/migrate`, so saving a model invalidates it. It also
65
+ writes `tmp/mbeditor_model_graph.mmd`, a Mermaid `erDiagram` that GitHub and
66
+ VS Code render natively.
67
+
68
+ - **Ruby navigation through real Monaco providers.** Go-to-definition was
69
+ hand-wired per editor with an `onMouseDown` and an `addAction`, so Monaco
70
+ never knew Ruby had definitions and peek-definition, Ctrl+hover previews and
71
+ the references widget all did nothing. Definition, references, document
72
+ symbols, folding, highlights, rename, formatting, signature help and
73
+ selection ranges are now registered providers, fed by a raw ruby-lsp
74
+ passthrough instead of a bespoke translator per method.
75
+
76
+ - **F2 renames a Ruby constant across the workspace.** Open files get their
77
+ edits back for Monaco to apply, so the change is undoable and marks the tab
78
+ dirty; closed files are written server-side. That split is what keeps
79
+ unsaved work safe — anything dirty is by definition open.
80
+ - **Shift+Alt+F and format-on-save now work for Ruby.**
81
+ - **RuboCop fixes apply straight from the diagnostic.** The edits are already
82
+ in the diagnostics response, so the lightbulb no longer makes a second
83
+ request or spawns a `rubocop -A` over the buffer on every click. "Disable
84
+ <cop> for this line" comes from the same payload.
85
+ - **Diagnostics are graded Error / Warning / Info / Hint** instead of
86
+ everything non-error being one yellow warning, and unnecessary code is
87
+ faded rather than squiggled. A status-bar chip shows ruby-lsp health.
88
+
89
+ - **Host-app exceptions appear in the Problems panel.** A failed request used to
90
+ show up only in the log. Controller exceptions are now listed with clickable
91
+ backtrace frames and pushed live over the existing cable channel. Backtraces
92
+ are filtered to frames under the workspace root and capped, since absolute
93
+ host paths are both a leak and unopenable. Development only, and
94
+ `config.exception_capture = false` disables it.
95
+
96
+ - **PageUp/PageDown cycle between the cursor and the last jump origin.**
97
+ Opening a file at a line — go-to-definition, a search result, a hover link —
98
+ snapshots where you came from, and PageUp/PageDown swap between the two.
99
+ Replaces the default page-scroll binding.
100
+
101
+ ### Changed
102
+ - **Search is dramatically faster on host apps without ripgrep**, where it was
103
+ effectively unusable. Three separate causes, all on the `git grep` tier:
104
+ the exclusion list was computed and then never put on the command line, so
105
+ git walked `node_modules` in full and the results were discarded afterwards;
106
+ `search_respect_gitignore` defaulted to `false`, which asks git to search
107
+ every ignored tree the app has; and `LC_ALL=C` was set, measured neutral for
108
+ the default and 2.2× *slower* for regex. Measured 3714 files walked → 253.
109
+ `search_respect_gitignore` now defaults to `true`, matching VS Code and
110
+ ripgrep. `GET /workspace` reports `searchBackend` so the live tier is visible,
111
+ and `ripgrep_command` now resolves the usual install prefixes as well as
112
+ `PATH` — a server started from launchd, systemd or an IDE has a stripped
113
+ `PATH`, which silently dropped search to the 10–30× slower tier.
114
+
115
+ - **Assignments to undeclared variables are reported as errors.** `foo = 1`
116
+ with no declaration anywhere is an implicit global the host's Babel pipeline
117
+ rejects, so it keeps Error severity with an explanatory hint. Read-side
118
+ unknowns still downgrade to a warning, since those are usually host globals
119
+ the language service cannot see.
120
+
121
+ ### Fixed
122
+ - **The bottom drawers covered the code instead of making room for it.** The
123
+ log and problems drawers were absolutely positioned, so they sat on top of
124
+ what you were reading. They are now ordinary flow siblings of the split
125
+ panes, so opening one shrinks the editor.
126
+ - **"Changes in Branch" showed nothing on a branch well ahead of the base.**
127
+ With no base branch resolved it fell back to diffing against the branch's
128
+ upstream — which for a feature branch is its own remote copy, reliably empty.
129
+ Every layer then degraded to empty rather than erroring, so it looked like
130
+ there were no changes.
131
+ - **The What's New tab was wiped by the session restore** when it opened on a
132
+ version change.
133
+ - **An idle editor no longer re-renders.** Polls that found nothing changed were
134
+ still writing fresh objects into state — the file tree every 10 s and the
135
+ ruby-lsp health chip every 10 s — each costing a full reconciliation of a
136
+ tree that had not changed. Verified by counting React renders over an idle
137
+ minute: now zero.
138
+
139
+ ## [0.11.0] - 2026-07-29
140
+
141
+ ### Added
142
+ - **Real types for your own JavaScript, from your own JavaScript.** The
143
+ workspace's JS source is now loaded into Monaco's TypeScript program instead
144
+ of being grepped for names and declared as ambient `any`. Under Sprockets a
145
+ JS file with no `import`/`export` is a TypeScript *script*, so its top-level
146
+ declarations land in the global scope — which is exactly the Sprockets model.
147
+ Cross-file references now get inferred signatures, member completion, and
148
+ argument-count checking, and genuine unknowns still report `Cannot find
149
+ name`:
150
+
151
+ ```jsx
152
+ var c = <Card title="x" />; // Card: (props: any) => JSX.Element
153
+ var s = formatCents(500); // formatCents(value: any): string
154
+ var t = formatCents(1, 2); // Expected 0-1 arguments, but got 2
155
+ ```
156
+
157
+ Two new options: `config.js_program` (default `true`) and
158
+ `config.js_program_exclude` (default `%w[vendor]`, added on top of
159
+ `excluded_paths`). Measured at ~93 ms/MB to build and ~30 ms per file
160
+ afterwards, so a ~10 MB tree costs under a second, once; only changed files
161
+ are re-sent after that.
162
+
163
+ Ambient declarations are still used for what a program cannot express.
164
+ TypeScript only sees *lexical* declarations: `window.Foo = ...` is not a
165
+ declaration to it, and UMD-wrapped libraries assign their global inside a
166
+ closure — `factory(global.React = {})` — which it cannot follow statically.
167
+ Their source contributes nothing, which is why vendored code is excluded by
168
+ default and React stays typed by a bundled stub. Point
169
+ `js_program_exclude` at any other third-party or generated JS.
170
+ - **A whitespace toggle in the status bar** (¶), showing tabs, spaces and
171
+ hidden characters in the active editor.
172
+
173
+ ### Fixed
174
+ - **The editor became very slow on JSX files with many unresolved names.**
175
+ Opening such a file fired one `/js_definition` request per unknown symbol,
176
+ in parallel — each spawning an `rg` process — and called `addExtraLib` once
177
+ per resolution, re-validating every open model each time. A file with a
178
+ thousand warnings meant a thousand greps saturating the dev server and a
179
+ thousand full TypeScript re-validations. That starved the file-tree poll,
180
+ git status, and saves behind it. Lookups are now serialized and capped, and
181
+ the declaration updates are coalesced into a single flush.
182
+ - **Minified bundles crowded out the workspace's real globals.** A minified
183
+ file is one enormous line that usually opens with `var a,b,c,…` running to
184
+ thousands of declarators; split on commas, that single line exhausted the
185
+ 3000-symbol cap before the scan reached your own components, so every
186
+ reference to them showed "Cannot find name". Declaring `a`/`n`/`t` as
187
+ ambient `any` also silenced real diagnostics for those names everywhere.
188
+ Minified files are now skipped by filename and by shape, and the endpoint
189
+ reports `truncated` so a workspace that outgrows the cap is diagnosable
190
+ instead of silently incomplete.
191
+ - **"File was edited externally" appeared for files nothing had touched.** The
192
+ check compared the file on disk against the editor buffer — which differ for
193
+ every unsaved tab by definition — so saving one file broadcast a change that
194
+ flagged every *other* dirty tab. It now compares disk against the last disk
195
+ content seen, so only a real on-disk change raises the banner.
196
+
197
+ ---
198
+
8
199
  ## [0.10.1] - 2026-07-27
9
200
 
10
201
  ### Removed
data/README.md CHANGED
@@ -25,6 +25,14 @@ Mbeditor exposes read and write access to your Rails application directory over
25
25
  - Always keep it in the development group in your Gemfile.
26
26
  - The engine enforces environment restrictions at runtime, and Gemfile scoping is a second line of defense that keeps the gem out of deploy builds.
27
27
 
28
+ > **Pairing crosses the localhost-only boundary by design.** Realtime collaborative
29
+ > editing (see [Collaborative pairing](#collaborative-pairing-optional)) is meant to
30
+ > be reached by a second person, so it necessarily exposes the editor beyond your own
31
+ > machine. Treat that as an explicit, deliberate exception to the rules above — confine
32
+ > exposure to a trusted tunnel or LAN, set an authentication hook, and tear it down when
33
+ > you finish pairing. It does not change the core rule: mbeditor is development-only and
34
+ > must never be reachable by untrusted users.
35
+
28
36
  ## Installation
29
37
  1. Add the gem to the host app Gemfile in development only:
30
38
 
@@ -77,6 +85,10 @@ Mbeditor.configure do |config|
77
85
  # config.ruby_def_include_dirs = %w[app/models app/controllers app/helpers app/concerns]
78
86
  # config.related_files_custom_paths = %w[app/assets/javascripts/app app/policies]
79
87
 
88
+ # JavaScript intelligence (see the "JavaScript intelligence" section below)
89
+ # config.js_program = false # disable the source program entirely
90
+ # config.js_program_exclude = %w[vendor app/assets/javascripts/react] # third-party/generated JS
91
+
80
92
  # Resilient routing (see the "Resilient Routing" section below)
81
93
  # config.mount_path = "/mbeditor" # explicit prefix override; auto-detected when nil
82
94
  # config.resilient_routing = false # escape hatch; true keeps the editor up when host routes break
@@ -93,19 +105,24 @@ end
93
105
  | `rubocop_command` | `"rubocop"` | Command used for inline Ruby linting and formatting. |
94
106
  | `git_timeout` | `10` | Seconds each git subprocess may run; a timed-out call degrades its own field of the git panel instead of failing the request. `nil` disables the bound. |
95
107
  | `search_timeout` | `15` | Wall-clock bound on project-search subprocesses; a tripped deadline returns the partial results collected so far. `nil` disables. |
96
- | `search_respect_gitignore` | `false` | When `true`, project search and definition lookups skip files ignored by `.gitignore`. The default searches them, matching the editor's "show me everything on disk" behaviour. |
108
+ | `search_respect_gitignore` | `true` | Project search and definition lookups skip files ignored by `.gitignore`, matching VS Code and ripgrep. Set to `false` to search ignored files too — expect it to be slow without ripgrep installed, since git then has to walk `node_modules`, `public/packs`, build output and caches. |
109
+ | `ripgrep_command` | `nil` | Path to the `rg` binary. `nil` auto-resolves: `PATH` first, then the usual install prefixes (`/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, linuxbrew, cargo). Set this if ripgrep lives somewhere unusual. See [Search performance](#search-performance). |
97
110
  | `js_global_identifiers` | `[]` | Extra JS names declared as ambient globals in the editor — for runtime-only globals the static workspace scan can't see (e.g. `%w[Routes I18n]`). |
111
+ | `js_program` | `true` | Load the workspace's own JS source into Monaco's TypeScript program, so cross-file references get real inferred types instead of ambient `any`. See [JavaScript intelligence](#javascript-intelligence). `false` falls back to ambient declarations alone. |
112
+ | `js_program_exclude` | `%w[vendor]` | Directories excluded from that program, on top of `excluded_paths`. Point this at any third-party or generated JS — vendored libraries are UMD-wrapped, so their source costs parse time and contributes no globals. |
98
113
  | `js_syntax_check` | `:auto` | Save-time babel parse check for JS/JSX using the host's `mini_racer` + babel-standalone (auto-detected; no-op when either is absent). `false` disables. |
99
114
  | `babel_standalone_path` | `nil` | Explicit path to the babel-standalone bundle for the syntax check; `nil` looks up `babel.min.js`/`babel.js` in the host's asset pipeline. |
100
- | `ruby_lsp` | `:auto` | Use the host's [ruby-lsp](https://github.com/Shopify/ruby-lsp) for Ruby go-to-definition, hover, completion, and diagnostics when it's installed (a persistent process is managed per workspace). `false` disables. Without ruby-lsp everything degrades to the built-in grep/Ripper services — no behavior change. |
115
+ | `ruby_lsp` | `:auto` | Use the host's [ruby-lsp](https://github.com/Shopify/ruby-lsp) for Ruby go-to-definition (with peek), find-references, hover, completion, diagnostics, document symbols, folding, formatting, signature help, smart-select, and constant rename when it's installed (a persistent process is managed per workspace). `false` disables. Without ruby-lsp everything degrades to the built-in grep/Ripper services — no behavior change. Adding `ruby-lsp-rails` to your Gemfile needs no configuration here: ruby-lsp loads it as an addon and its Rails-aware results come through automatically. |
101
116
  | `ruby_lsp_command` | `nil` | Override the ruby-lsp launch command (String or Array). `nil` auto-resolves `bin/ruby-lsp` → installed gem → `bundle exec ruby-lsp`. |
102
117
  | `ruby_lsp_timeout` | `3` | Seconds per LSP request; on timeout (e.g. during initial indexing) the editor falls back to the built-in services for that request. |
118
+ | `model_graph` | *(no setting)* | The Models sidebar tab draws an entity diagram of your ActiveRecord models and their associations, read by reflection when the tab is opened. Pan by dragging, zoom by scrolling, click a model for its schema. Also writes `tmp/mbeditor_model_graph.mmd`, a Mermaid ER diagram that GitHub and VS Code render natively. Needs no configuration; apps without ActiveRecord simply see a message. |
119
+ | `exception_capture` | `:auto` | Record exceptions raised by your controllers and list them in the Problems panel, with clickable backtrace frames. Development only; backtraces are trimmed to frames inside the workspace. `false` disables. Note exception messages can include request params — the same exposure the log panel already has. |
103
120
 
104
121
  ### Authentication
105
122
 
106
123
  | Option | Default | Description |
107
124
  |--------|---------|-------------|
108
- | `authenticate_with` | `nil` | Proc run as a `before_action` in all engine controllers. Executed via `instance_exec` inside the controller, so it has access to `session`, `cookies`, `redirect_to`, and auth-library class methods (e.g. Authlogic's `UserSession.find`) — but not helper methods from the host's `ApplicationController`. |
125
+ | `authenticate_with` | `nil` | Proc run as a `before_action` in all engine controllers. Executed via `instance_exec` inside the controller, so it has access to `session`, `cookies`, `redirect_to`, and auth-library class methods (e.g. Authlogic's `UserSession.find`) — but not helper methods from the host's `ApplicationController`. The same hook is also evaluated when the collaboration / editor **WebSocket** subscribes (see [Collaborative pairing](#collaborative-pairing-optional)); if it halts or raises, that subscription is rejected (fail-closed). Over the cable the proc runs against a request-derived probe, so request-scoped state may be narrower than over HTTP. |
109
126
  | `authentication_cache_ttl` | `0` | Seconds to cache the auth result in the session (`0` = no caching). Set e.g. `300` to avoid calling `authenticate_with` on every request when the proc is expensive. Trade-off: after host logout, mbeditor stays accessible for up to TTL seconds. |
110
127
 
111
128
  ### Test runner
@@ -141,6 +158,79 @@ See [Resilient Routing](#resilient-routing) for details.
141
158
  | `mount_path` | `nil` | Explicit URL prefix to serve resilient routing from. When `nil`, auto-detected from your `mount Mbeditor::Engine, at: "..."` line on every healthy boot. Set only to override detection. |
142
159
  | `resilient_routing` | `true` | Keeps mbeditor reachable when the host's `config/routes.rb` is broken, by serving its traffic from middleware that dispatches to a private route set. Set to `false` as an escape hatch: no middleware is inserted and the private set is never built. |
143
160
 
161
+ ### Collaboration
162
+
163
+ | Option | Default | Description |
164
+ |--------|---------|-------------|
165
+ | `user_name_callback` | `nil` | Proc resolving the display name shown on your caret during realtime collaboration. Executed via `instance_exec` inside the controller (like `authenticate_with`), so it can read `session`, `cookies`, `current_user`, etc. — e.g. `proc { current_user&.name }`. When `nil` or it returns a blank value, each browser falls back to a generated, locally-persisted, user-editable name. Collaboration activates once another participant actually connects, not merely when Action Cable is up (see [Collaborative pairing](#collaborative-pairing-optional)). |
166
+
167
+ ## JavaScript intelligence
168
+
169
+ Under Sprockets every JS file shares one global scope, with no imports. The
170
+ editor models that in two layers.
171
+
172
+ **1. The source program.** Your workspace's own JS is loaded into Monaco's
173
+ TypeScript program. A JS file with no `import`/`export` is a TypeScript
174
+ *script*, so its top-level declarations land in the global scope — which is
175
+ exactly the Sprockets model. You get real inferred types across files:
176
+
177
+ ```jsx
178
+ // app/assets/javascripts/ux/Card.jsx
179
+ var Card = function (props) { return <div>{props.title}</div>; };
180
+ function formatCents(value) { return "$" + (value / 100).toFixed(2); }
181
+ ```
182
+
183
+ ```jsx
184
+ // somewhere else — no import needed
185
+ var c = <Card title="x" />; // Card: (props: any) => JSX.Element
186
+ var s = formatCents(500); // formatCents(value: any): string
187
+ var t = formatCents(1, 2); // Expected 0-1 arguments, but got 2
188
+ ```
189
+
190
+ Unknown names still report `Cannot find name` — this adds type information, it
191
+ doesn't silence errors.
192
+
193
+ **2. Ambient declarations**, for names the program can't supply.
194
+
195
+ Both layers are needed, because TypeScript only sees *lexical* declarations:
196
+
197
+ - `window.Foo = ...` is a runtime global TypeScript does not treat as a
198
+ declaration at all.
199
+ - UMD-wrapped libraries — React, lodash, axios — assign their global inside a
200
+ closure, `factory(global.React = {})`, which TypeScript cannot follow
201
+ statically. **Loading their source gets you nothing**, which is why
202
+ `js_program_exclude` defaults to `vendor` and why React is typed by a
203
+ bundled stub instead.
204
+
205
+ So point `js_program_exclude` at directories of third-party or generated JS,
206
+ and leave your own application code in:
207
+
208
+ ```ruby
209
+ config.js_program_exclude = %w[vendor app/assets/javascripts/react]
210
+ ```
211
+
212
+ ### Cost
213
+
214
+ Measured against the Monaco TypeScript worker:
215
+
216
+ | program size | build | per file opened after |
217
+ |---|---|---|
218
+ | 1 MB | 210 ms | 9 ms |
219
+ | 3 MB | 378 ms | 11 ms |
220
+ | 5.5 MB | 443 ms | 23 ms |
221
+ | 9.4 MB | 872 ms | 32 ms |
222
+
223
+ Roughly 93 ms/MB, paid once per session. JS gzips about 4.5:1, so a 10 MB tree
224
+ is ~2.2 MB over the wire — worth knowing if your app runs on a remote host.
225
+ After the initial load only changed files are re-sent, never the whole tree.
226
+
227
+ Nothing is truncated silently: the browser console logs the file count, total
228
+ size, and every skipped file with a reason (minified, oversized, unreadable).
229
+ Minified bundles are skipped by filename and by shape, since they cost parse
230
+ time and declare only one-letter names inside a closure.
231
+
232
+ Set `config.js_program = false` to disable the layer entirely.
233
+
144
234
  ## Test Runner
145
235
 
146
236
  The Test button appears in the editor toolbar for any `.rb` file when a `test/` or `spec/` directory exists in the workspace root. Clicking it:
@@ -206,6 +296,113 @@ The gem keeps host/tooling responsibilities in the host app:
206
296
 
207
297
  All lint and test tools are auto-detected at runtime. The engine gracefully disables features if the tools are not available. Neither `rubocop`, `haml_lint`, nor any test framework are runtime dependencies of the gem itself — they are discovered from the host app's environment.
208
298
 
299
+ ### Realtime via Action Cable (Optional)
300
+
301
+ Mbeditor works without Action Cable. If Action Cable is unavailable, unreachable, or returns transient errors, the editor automatically falls back to polling.
302
+
303
+ To enable realtime features in a host app:
304
+
305
+ 1. Ensure Action Cable is enabled in the host app (for apps that do not load it by default, add the framework/gem explicitly).
306
+ 2. Mount cable in host routes:
307
+
308
+ ```ruby
309
+ mount ActionCable.server => '/cable'
310
+ ```
311
+
312
+ 3. Make Action Cable JavaScript available to the page (for asset-pipeline apps, `actioncable.js` is typically sufficient).
313
+
314
+ If any of these are missing, mbeditor still runs in polling mode.
315
+
316
+ ### Collaborative pairing (Optional)
317
+
318
+ When Action Cable is available, mbeditor supports **realtime collaborative editing** —
319
+ live cursors and content sync over a WebSocket, so a second person can join the same
320
+ files. This is the one feature intended to be reached from another machine, so exposing
321
+ it is a **deliberate exception** to the localhost-only [Security Warning](#security-warning)
322
+ above. Expose it narrowly and only while you are actively pairing.
323
+
324
+ Collaboration activates only once **another participant is actually connected**, not
325
+ merely because Action Cable is up. On your own the editor behaves exactly as it does
326
+ without cable — persistent undo history and external-change detection stay in force,
327
+ both of which defer to the shared document while a session is live.
328
+
329
+ **1. Restrict the network exposure to a trusted path.**
330
+ Put the editor behind a **trusted tunnel** (e.g. an authenticated `ngrok`/Tailscale/
331
+ Cloudflare tunnel, or SSH port-forward) or keep it on a **trusted LAN**. Never bind it to
332
+ a public interface or an untrusted network. The person you pair with is the only one who
333
+ should be able to reach the port.
334
+
335
+ **2. Set an authentication hook — it runs on the WebSocket handshake.**
336
+ Configure `authenticate_with` (see the [Authentication](#authentication) options).
337
+ The same hook that gates the HTTP editor is now also evaluated when the collaboration /
338
+ editor WebSocket subscribes: if it halts (e.g. `redirect_to`/`render`/`head`) — or raises —
339
+ the socket subscription is **rejected (fail-closed)**, so pairing cannot bypass your auth.
340
+
341
+ ```ruby
342
+ Mbeditor.configure do |c|
343
+ # Runs as a controller before_action AND on the cable subscribe.
344
+ c.authenticate_with = proc { head :forbidden unless UserSession.find }
345
+ end
346
+ ```
347
+
348
+ Because the cable mount can bypass parts of the host middleware stack, a hook that leans on
349
+ request-scoped state (full `session`, encrypted `cookies`) may see less over the WebSocket
350
+ than it does over HTTP. For defence in depth, also authenticate at your host app's
351
+ `ApplicationCable::Connection` (the standard `identified_by` / `reject_unauthorized_connection`
352
+ pattern) — mbeditor's hook is an additional gate, not a replacement for securing the cable
353
+ connection itself.
354
+
355
+ **3. Configure Action Cable allowed request origins.**
356
+ Action Cable rejects cross-origin WebSocket connections. When you reach the editor through a
357
+ tunnel or LAN host, that origin must be allowed, or the socket silently fails and pairing
358
+ falls back to single-user mode. Allow exactly the origin(s) you pair through — never `/.*/`:
359
+
360
+ ```ruby
361
+ # config/environments/development.rb
362
+ config.action_cable.allowed_request_origins = [
363
+ "https://your-pairing-tunnel.example.com",
364
+ %r{https://.*\.trusted-lan\.local}
365
+ ]
366
+ ```
367
+
368
+ When you finish pairing, close the tunnel / stop the forward so the editor is local-only again.
369
+
370
+ **4. Run a single web process.**
371
+ The shared document buffer is held in process memory and relayed over Action Cable's
372
+ default in-process (`async`) adapter, so collaboration state is **per-process**. If your
373
+ server runs multiple workers (e.g. Puma with `workers > 0` / `WEB_CONCURRENCY`), two
374
+ browsers can land on different workers and each see an empty or stale document — the most
375
+ common cause of *"the other person's edits never show up."* For pairing, run a single
376
+ worker (`WEB_CONCURRENCY=0`, or `bundle exec rails server` which is single-process by
377
+ default). A multi-worker setup would additionally need a cross-process cable adapter, but
378
+ the in-memory buffer still would not be shared — single-process is the supported mode.
379
+ ## Search performance
380
+
381
+ Project search and JS definition lookups pick a backend per call:
382
+ **ripgrep → `git grep` → `grep`**. ripgrep is 10–30× faster than the fallbacks,
383
+ so installing it is the single biggest thing you can do for search speed:
384
+
385
+ ```bash
386
+ brew install ripgrep # macOS
387
+ apt install ripgrep # Debian/Ubuntu
388
+ ```
389
+
390
+ `GET /mbeditor/workspace` reports `searchBackend` (`"rg"`, `"git"` or `"grep"`)
391
+ so you can check which tier is actually in use. Two things commonly make it
392
+ `"git"` when you expected `"rg"`:
393
+
394
+ - **ripgrep isn't on the server process's `PATH`.** A Rails server started from
395
+ launchd, systemd, foreman or an IDE inherits a stripped `PATH` that often
396
+ omits `/opt/homebrew/bin`. Mbeditor probes the usual install prefixes as well
397
+ as `PATH`; if yours is elsewhere, set `config.ripgrep_command`.
398
+ - **ripgrep genuinely isn't installed.** The `git grep` tier is then used. It
399
+ honours `excluded_paths` and `.gitignore`, so it stays usable — but it is
400
+ still far slower than ripgrep on a large workspace.
401
+
402
+ If search is slow, check `searchBackend` first, then confirm your build output
403
+ (`public/packs`, `app/assets/builds`, bundler/npm caches) is either gitignored
404
+ or listed in `excluded_paths`. Setting `search_respect_gitignore = false` makes
405
+ git walk every ignored tree and will be slow without ripgrep.
209
406
 
210
407
  ## Asset Pipeline
211
408
 
@@ -221,6 +418,32 @@ cd test/dummy && rails server
221
418
 
222
419
  Then visit http://localhost:3000/mbeditor.
223
420
 
421
+ ### Vendored JavaScript (no consumer build step)
422
+
423
+ All third-party JS is **prebuilt and committed** under `vendor/assets/javascripts/`
424
+ and served as-is by Sprockets. Installing the gem needs **zero JS tooling** — no
425
+ Node, npm, or bundler — which is the contract recorded in
426
+ [ADR-0001](docs/adr/0001-no-frontend-build-step.md). `package.json` exists only as
427
+ a dependency manifest for `npm audit`.
428
+
429
+ Most vendored libs are committed verbatim from npm. The one exception is the
430
+ collaborative-editing bundle, `vendor/assets/javascripts/yjs-collab.js`, which
431
+ combines Yjs + y-monaco + y-protocols (awareness) into a single IIFE exposing
432
+ `window.Y`, `window.MonacoBinding`, and `window.awarenessProtocol`. It is produced
433
+ by a **maintainer-only** build script. Monaco itself is not bundled — the binding
434
+ forwards to the page's runtime `window.monaco`.
435
+
436
+ To regenerate it after bumping any of those pinned versions in `package.json`:
437
+
438
+ ```bash
439
+ npm install # installs yjs / y-monaco / y-protocols + esbuild (maintainer-only)
440
+ npm run build:yjs # === node script/build-yjs-bundle.mjs
441
+ ```
442
+
443
+ then commit the regenerated `vendor/assets/javascripts/yjs-collab.js`. The build is
444
+ deterministic: rebuilding from the same pinned versions reproduces identical bytes.
445
+ This step is for maintainers only; it never runs on a consumer's machine.
446
+
224
447
  ## Testing
225
448
 
226
449
  The test suite uses Minitest via the dummy Rails app. Run all tests from the project root:
@@ -2,6 +2,7 @@
2
2
  //= require mbeditor/editor_store
3
3
  //= require mbeditor/file_icon
4
4
  //= require mbeditor/file_service
5
+ //= require mbeditor/file_import
5
6
  //= require mbeditor/history_service
6
7
  //= require mbeditor/websocket_service
7
8
  //= require mbeditor/git_service
@@ -9,6 +10,8 @@
9
10
  //= require mbeditor/conflict_parser
10
11
  //= require mbeditor/search_service
11
12
  //= require mbeditor/tab_manager
13
+ //= require mbeditor/collaboration_identity
14
+ //= require mbeditor/collaboration_service
12
15
  //= require mbeditor/color_provider
13
16
  //= require mbeditor/editor_plugins
14
17
  //= require mbeditor/ruby_outline
@@ -18,6 +21,7 @@
18
21
  //= require mbeditor/components/DiffViewer
19
22
  //= require mbeditor/components/CombinedDiffViewer
20
23
  //= require mbeditor/components/CommitGraph
24
+ //= require mbeditor/components/ModelGraph
21
25
  //= require mbeditor/components/ChangelogView
22
26
  //= require mbeditor/components/FileHistoryPanel
23
27
  //= require mbeditor/components/TestResultsPanel
@@ -31,3 +35,4 @@
31
35
  //= require mbeditor/components/TabBar
32
36
  //= require mbeditor/components/MbeditorApp
33
37
  //= require mbeditor/application_iife_tail
38
+ //= require mbeditor/components/ImportConflictModal
@@ -1,4 +1,10 @@
1
1
  window.SearchService = SearchService;
2
2
  window.GitService = GitService;
3
3
  window.FileService = FileService;
4
+ // Exposed for system tests to observe collaboration state and drive store updates
5
+ // (same test-seam convention as the services above).
6
+ window.EditorStore = EditorStore;
7
+ window.CollaborationService = CollaborationService;
8
+ window.CollaborationIdentity = CollaborationIdentity;
9
+ window.WebSocketService = WebSocketService;
4
10
  })(window.MbeditorRuntime.React, window.MbeditorRuntime.ReactDOM);