mbeditor 0.11.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -0
- data/README.md +153 -3
- data/app/assets/javascripts/mbeditor/application.js +5 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
- data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
- data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +911 -72
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
- data/app/assets/javascripts/mbeditor/file_import.js +146 -0
- data/app/assets/javascripts/mbeditor/file_service.js +52 -3
- data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
- data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
- data/app/assets/stylesheets/mbeditor/editor.css +273 -10
- data/app/channels/mbeditor/channel_authentication.rb +94 -0
- data/app/channels/mbeditor/collaboration_channel.rb +84 -0
- data/app/channels/mbeditor/editor_channel.rb +40 -1
- data/app/controllers/mbeditor/application_controller.rb +5 -1
- data/app/controllers/mbeditor/editors_controller.rb +465 -19
- data/app/controllers/mbeditor/git_controller.rb +9 -2
- data/app/services/mbeditor/availability_probe.rb +76 -17
- data/app/services/mbeditor/code_search_service.rb +23 -3
- data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
- data/app/services/mbeditor/file_import_service.rb +103 -0
- data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/git_service.rb +22 -6
- data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
- data/app/services/mbeditor/model_graph_service.rb +232 -0
- data/app/services/mbeditor/presence_registry.rb +83 -0
- data/app/services/mbeditor/ri_definition_service.rb +39 -5
- data/app/services/mbeditor/search_replace_service.rb +24 -4
- data/app/views/layouts/mbeditor/application.html.erb +2 -0
- data/lib/mbeditor/configuration.rb +33 -3
- data/lib/mbeditor/engine.rb +34 -0
- data/lib/mbeditor/exception_log.rb +84 -0
- data/lib/mbeditor/route_map.rb +5 -0
- data/lib/mbeditor/ruby_lsp_client.rb +28 -1
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- data/vendor/assets/javascripts/yjs-collab.js +12 -0
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c8e586fb734529e92147e9fb6935782e79baeb9722c833e8df0a97cc2c86c637
|
|
4
|
+
data.tar.gz: 5da9242367bd195c715af433c8fb12fa440a610cacb5a1ff871cb8361c82233a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f403b63083bc139fc6546477c42fb476283213f0409a287ec3ffe46cc1a90cb10f197d6521216efce8121d910fdb8b2c94eefd8cd0abd8829725c852f043e57a
|
|
7
|
+
data.tar.gz: 219a64ac7ecf5e2ded2081058f64f0bb4b0ce8373eac4f3cdcd8f9ca87aacd887676e680c31f360848dbcc255cfcdd176eaf67389569c64143639bf2643538f6
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,137 @@ 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
|
+
|
|
8
139
|
## [0.11.0] - 2026-07-29
|
|
9
140
|
|
|
10
141
|
### Added
|
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
|
|
|
@@ -97,21 +105,24 @@ end
|
|
|
97
105
|
| `rubocop_command` | `"rubocop"` | Command used for inline Ruby linting and formatting. |
|
|
98
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. |
|
|
99
107
|
| `search_timeout` | `15` | Wall-clock bound on project-search subprocesses; a tripped deadline returns the partial results collected so far. `nil` disables. |
|
|
100
|
-
| `search_respect_gitignore` | `
|
|
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). |
|
|
101
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]`). |
|
|
102
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. |
|
|
103
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. |
|
|
104
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. |
|
|
105
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. |
|
|
106
|
-
| `ruby_lsp` | `:auto` | Use the host's [ruby-lsp](https://github.com/Shopify/ruby-lsp) for Ruby go-to-definition, hover, completion, and
|
|
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. |
|
|
107
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`. |
|
|
108
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. |
|
|
109
120
|
|
|
110
121
|
### Authentication
|
|
111
122
|
|
|
112
123
|
| Option | Default | Description |
|
|
113
124
|
|--------|---------|-------------|
|
|
114
|
-
| `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. |
|
|
115
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. |
|
|
116
127
|
|
|
117
128
|
### Test runner
|
|
@@ -147,6 +158,12 @@ See [Resilient Routing](#resilient-routing) for details.
|
|
|
147
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. |
|
|
148
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. |
|
|
149
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
|
+
|
|
150
167
|
## JavaScript intelligence
|
|
151
168
|
|
|
152
169
|
Under Sprockets every JS file shares one global scope, with no imports. The
|
|
@@ -279,6 +296,113 @@ The gem keeps host/tooling responsibilities in the host app:
|
|
|
279
296
|
|
|
280
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.
|
|
281
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.
|
|
282
406
|
|
|
283
407
|
## Asset Pipeline
|
|
284
408
|
|
|
@@ -294,6 +418,32 @@ cd test/dummy && rails server
|
|
|
294
418
|
|
|
295
419
|
Then visit http://localhost:3000/mbeditor.
|
|
296
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
|
+
|
|
297
447
|
## Testing
|
|
298
448
|
|
|
299
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);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// CollaborationIdentity — zero-config participant identity for live collaboration
|
|
2
|
+
// (slice 5/9, #56).
|
|
3
|
+
//
|
|
4
|
+
// Every browser gets a friendly display name and a stable colour with no setup:
|
|
5
|
+
// generated once, persisted in localStorage, and editable by the user. The host
|
|
6
|
+
// app can override the name through the `user_name_callback` config option — that
|
|
7
|
+
// resolved value arrives via /client_config and is applied with setServerName().
|
|
8
|
+
// Precedence for the effective name is: server name > user/stored name > generated.
|
|
9
|
+
//
|
|
10
|
+
// The colour is derived deterministically from the effective name against a fixed
|
|
11
|
+
// palette, so the same name always renders in the same colour and two peers get
|
|
12
|
+
// visibly distinct carets.
|
|
13
|
+
var CollaborationIdentity = (function () {
|
|
14
|
+
var STORAGE_KEY = 'mbeditor.collab.identity';
|
|
15
|
+
|
|
16
|
+
// Fixed palette — distinct, saturated hues that read against the dark editor
|
|
17
|
+
// background. Index chosen deterministically from the name (see _colorFor).
|
|
18
|
+
var PALETTE = [
|
|
19
|
+
'#e06c75', '#98c379', '#e5c07b', '#61afef', '#c678dd',
|
|
20
|
+
'#56b6c2', '#d19a66', '#be5046', '#7e9cff', '#3fb950'
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
var ADJECTIVES = [
|
|
24
|
+
'Swift', 'Calm', 'Bright', 'Bold', 'Quiet', 'Clever', 'Brave', 'Lucky',
|
|
25
|
+
'Sunny', 'Witty', 'Nimble', 'Mellow', 'Eager', 'Jolly', 'Keen', 'Spry'
|
|
26
|
+
];
|
|
27
|
+
var ROLES = [
|
|
28
|
+
'Developer', 'Designer', 'Architect', 'Engineer', 'Coder', 'Hacker', 'Builder', 'Debugger',
|
|
29
|
+
'Tester', 'Analyst', 'Maker', 'Pioneer', 'Wizard', 'Operator', 'Scripter', 'Tinkerer'
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
var _serverName = null; // set by setServerName() from /client_config
|
|
33
|
+
var _stored = _load(); // { name, installId } persisted in the browser
|
|
34
|
+
var _listeners = []; // notified when the effective identity changes
|
|
35
|
+
|
|
36
|
+
// A presence participant is one tab/cable connection, so the client id is minted
|
|
37
|
+
// once per page load (NOT persisted — two tabs of the same profile are two
|
|
38
|
+
// distinct participants). Used to key the status-bar presence roster.
|
|
39
|
+
var _clientId = _mintClientId();
|
|
40
|
+
|
|
41
|
+
function _mintId() {
|
|
42
|
+
try {
|
|
43
|
+
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID();
|
|
44
|
+
} catch (e) { /* fall through to Math.random */ }
|
|
45
|
+
return 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _mintClientId() {
|
|
49
|
+
return _mintId();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function _load() {
|
|
53
|
+
var obj = null;
|
|
54
|
+
try {
|
|
55
|
+
var raw = window.localStorage.getItem(STORAGE_KEY);
|
|
56
|
+
if (raw) {
|
|
57
|
+
var parsed = JSON.parse(raw);
|
|
58
|
+
if (parsed && typeof parsed.name === 'string' && parsed.name) obj = parsed;
|
|
59
|
+
}
|
|
60
|
+
} catch (e) { /* storage unavailable / corrupt — fall through */ }
|
|
61
|
+
|
|
62
|
+
obj = obj || { name: _generateName() };
|
|
63
|
+
// Persistent per-browser id, distinct from the per-load client id. The colour
|
|
64
|
+
// is seeded from this so it survives a reload: seeding it from the client id
|
|
65
|
+
// meant every refresh handed you a new colour, and your peers watched your
|
|
66
|
+
// caret change hue for no reason. Backfilled for identities stored before
|
|
67
|
+
// this existed.
|
|
68
|
+
if (typeof obj.installId !== 'string' || !obj.installId) {
|
|
69
|
+
obj.installId = _mintId();
|
|
70
|
+
}
|
|
71
|
+
_persist(obj);
|
|
72
|
+
return obj;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _persist(obj) {
|
|
76
|
+
try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(obj)); }
|
|
77
|
+
catch (e) { /* private mode / quota — identity still works in-memory */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _generateName() {
|
|
81
|
+
var a = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
|
82
|
+
var n = ROLES[Math.floor(Math.random() * ROLES.length)];
|
|
83
|
+
return a + ' ' + n;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Stable string hash (djb2) → palette index.
|
|
87
|
+
//
|
|
88
|
+
// Seeded on the persisted install id — not the name, and not the per-load client
|
|
89
|
+
// id. Both of those were tried and both were wrong in opposite directions:
|
|
90
|
+
// hashing the name gave two participants sharing a display name the same colour,
|
|
91
|
+
// making their carets indistinguishable exactly when you most need to tell them
|
|
92
|
+
// apart (and the generated name is one of only 128 adjective+role pairs, a host
|
|
93
|
+
// app's user_name_callback can legitimately return the same name twice, and two
|
|
94
|
+
// tabs of one browser share the stored name outright); hashing the client id
|
|
95
|
+
// fixed that but re-rolled the colour on every page load, so a reload changed
|
|
96
|
+
// your caret's hue in front of everyone you were pairing with.
|
|
97
|
+
// A peer's colour arrives from another machine and is interpolated into a
|
|
98
|
+
// stylesheet to draw their caret, so it is a trust boundary: without this,
|
|
99
|
+
// a colour of `red;} html{display:none} .x{` closes the rule and injects
|
|
100
|
+
// arbitrary CSS into your editor. Accept a hex literal or nothing.
|
|
101
|
+
var HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
102
|
+
var FALLBACK_COLOR = '#888888';
|
|
103
|
+
|
|
104
|
+
function safeColor(value) {
|
|
105
|
+
return HEX_COLOR.test(String(value || '')) ? String(value) : FALLBACK_COLOR;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function _hash(seed) {
|
|
109
|
+
var h = 5381;
|
|
110
|
+
for (var i = 0; i < seed.length; i++) h = ((h << 5) + h + seed.charCodeAt(i)) | 0;
|
|
111
|
+
return Math.abs(h);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _colorFor(seed) {
|
|
115
|
+
return PALETTE[_hash(seed) % PALETTE.length];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _effectiveName() {
|
|
119
|
+
if (_serverName) return _serverName;
|
|
120
|
+
return _stored.name;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
var _color = _colorFor(_stored.installId);
|
|
124
|
+
// Published so a colour clash resolves the same way on both sides *and* the same
|
|
125
|
+
// way after a reload. Deriving it from the install id rather than sending the id
|
|
126
|
+
// itself keeps a persistent browser identifier off the wire; all the tie-break
|
|
127
|
+
// needs is a stable number to compare.
|
|
128
|
+
var _seed = _hash(_stored.installId);
|
|
129
|
+
|
|
130
|
+
function get() {
|
|
131
|
+
return { clientId: _clientId, name: _effectiveName(), color: _color, seed: _seed };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Hashing alone still collides: 10 palette entries and 5 participants is a ~70%
|
|
135
|
+
// chance some pair matches. The colour has to be picked *against* the roster,
|
|
136
|
+
// which only exists after connecting — so mint from the hash, then reconcile
|
|
137
|
+
// here whenever the roster changes.
|
|
138
|
+
//
|
|
139
|
+
// Called by every client on the same roster, so the rule has to be one both
|
|
140
|
+
// sides of a clash compute identically or they swap forever: on a collision the
|
|
141
|
+
// higher seed yields and the lower keeps its colour. Two yielders can briefly
|
|
142
|
+
// grab the same free slot; the next roster change re-runs this and the same
|
|
143
|
+
// tie-break settles it, so it converges rather than oscillating.
|
|
144
|
+
//
|
|
145
|
+
// Ordered on the persisted seed, falling back to the per-load client id only
|
|
146
|
+
// when seeds match (two tabs of one browser, which share stored identity). Using
|
|
147
|
+
// the client id as the primary key would decide the yielder afresh on every
|
|
148
|
+
// reload, so a clashing pair swapped colours each time either of them refreshed
|
|
149
|
+
// — the same churn that seeding the colour per-load caused.
|
|
150
|
+
//
|
|
151
|
+
// peers: [{ clientId, color, seed }] — the current roster, excluding us.
|
|
152
|
+
function _yieldsTo(peer) {
|
|
153
|
+
var mine = _seed;
|
|
154
|
+
var theirs = typeof peer.seed === 'number' ? peer.seed : -1;
|
|
155
|
+
if (theirs !== mine) return theirs < mine;
|
|
156
|
+
return String(peer.clientId) < _clientId;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function reconcileColor(peers) {
|
|
160
|
+
if (!peers || !peers.length) return;
|
|
161
|
+
|
|
162
|
+
var clash = peers.some(function (p) {
|
|
163
|
+
return p.color === _color && _yieldsTo(p);
|
|
164
|
+
});
|
|
165
|
+
if (!clash) return;
|
|
166
|
+
|
|
167
|
+
var taken = {};
|
|
168
|
+
peers.forEach(function (p) { if (p.color) taken[p.color] = true; });
|
|
169
|
+
var free = PALETTE.filter(function (c) { return !taken[c]; });
|
|
170
|
+
// More participants than colours — a duplicate is unavoidable, so keep ours
|
|
171
|
+
// rather than churn. The hover card names who is who.
|
|
172
|
+
if (!free.length) return;
|
|
173
|
+
|
|
174
|
+
var next = free[_seed % free.length];
|
|
175
|
+
if (next === _color) return;
|
|
176
|
+
_color = next;
|
|
177
|
+
_notify();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _notify() {
|
|
181
|
+
var id = get();
|
|
182
|
+
_listeners.forEach(function (cb) {
|
|
183
|
+
try { cb(id); } catch (e) { /* a bad listener must not break the others */ }
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Host-app override (from user_name_callback via /client_config). A blank value
|
|
188
|
+
// clears the override and falls back to the stored/generated name.
|
|
189
|
+
function setServerName(name) {
|
|
190
|
+
var next = (typeof name === 'string' && name.trim()) ? name.trim() : null;
|
|
191
|
+
if (next === _serverName) return;
|
|
192
|
+
_serverName = next;
|
|
193
|
+
_notify();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// User edit of their own display name. Persisted; clears any server override so
|
|
197
|
+
// the user's explicit choice wins for the rest of the session.
|
|
198
|
+
function setName(name) {
|
|
199
|
+
var clean = (typeof name === 'string') ? name.trim() : '';
|
|
200
|
+
if (!clean) return;
|
|
201
|
+
_stored = { name: clean };
|
|
202
|
+
_persist(_stored);
|
|
203
|
+
_serverName = null;
|
|
204
|
+
_notify();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Prompt-based editor wired to the status-bar presence chip. Returns nothing;
|
|
208
|
+
// listeners pick up the change and push it onto live awareness.
|
|
209
|
+
function editName() {
|
|
210
|
+
var current = get().name;
|
|
211
|
+
var next = window.prompt('Your collaboration name', current);
|
|
212
|
+
if (next != null) setName(next);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Subscribe to identity changes (name or colour). Returns an unsubscribe fn.
|
|
216
|
+
function onChange(cb) {
|
|
217
|
+
if (typeof cb !== 'function') return function () {};
|
|
218
|
+
_listeners.push(cb);
|
|
219
|
+
return function () {
|
|
220
|
+
var i = _listeners.indexOf(cb);
|
|
221
|
+
if (i !== -1) _listeners.splice(i, 1);
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
get: get,
|
|
227
|
+
safeColor: safeColor,
|
|
228
|
+
setName: setName,
|
|
229
|
+
setServerName: setServerName,
|
|
230
|
+
reconcileColor: reconcileColor,
|
|
231
|
+
editName: editName,
|
|
232
|
+
onChange: onChange
|
|
233
|
+
};
|
|
234
|
+
})();
|