mbeditor 0.10.0 → 0.11.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 +97 -0
- data/README.md +73 -106
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +39 -1
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +45 -4
- data/app/assets/javascripts/mbeditor/editor_plugins.js +144 -29
- data/app/assets/javascripts/mbeditor/file_service.js +16 -0
- data/app/controllers/mbeditor/editors_controller.rb +16 -0
- data/app/services/mbeditor/js_globals_service.rb +31 -2
- data/app/services/mbeditor/js_program_service.rb +173 -0
- data/lib/mbeditor/configuration.rb +11 -2
- data/lib/mbeditor/engine.rb +0 -3
- data/lib/mbeditor/route_map.rb +1 -0
- data/lib/mbeditor/version.rb +1 -1
- metadata +3 -3
- data/lib/mbeditor/file_watcher.rb +0 -136
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a664c7e61d20ca986be983f370ade96b639796633533373068221f45ffdbf9e
|
|
4
|
+
data.tar.gz: d9d6f92fa563330c47bc94f080432fa1e38aeddd4449b5b37f473d0555f513c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c5ed1f9375cb92b32b74fb86f76b0f98a10646e6498407b89be7b4a07f4ab4105d0bb207ce40746b33f3f34a9eaaf4ee3de16f1816a378d16b0d23f70adac04
|
|
7
|
+
data.tar.gz: 2813e2df0341112f437241f7c27537ad03f5e66da90fa756fba95647855ac6d0885421a219a0baf392e9aedcaa5c6c86e9fd4bcffacae5337ca4a8ee899e8ecb
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,103 @@ 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
|
+
## [0.11.0] - 2026-07-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Real types for your own JavaScript, from your own JavaScript.** The
|
|
12
|
+
workspace's JS source is now loaded into Monaco's TypeScript program instead
|
|
13
|
+
of being grepped for names and declared as ambient `any`. Under Sprockets a
|
|
14
|
+
JS file with no `import`/`export` is a TypeScript *script*, so its top-level
|
|
15
|
+
declarations land in the global scope — which is exactly the Sprockets model.
|
|
16
|
+
Cross-file references now get inferred signatures, member completion, and
|
|
17
|
+
argument-count checking, and genuine unknowns still report `Cannot find
|
|
18
|
+
name`:
|
|
19
|
+
|
|
20
|
+
```jsx
|
|
21
|
+
var c = <Card title="x" />; // Card: (props: any) => JSX.Element
|
|
22
|
+
var s = formatCents(500); // formatCents(value: any): string
|
|
23
|
+
var t = formatCents(1, 2); // Expected 0-1 arguments, but got 2
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Two new options: `config.js_program` (default `true`) and
|
|
27
|
+
`config.js_program_exclude` (default `%w[vendor]`, added on top of
|
|
28
|
+
`excluded_paths`). Measured at ~93 ms/MB to build and ~30 ms per file
|
|
29
|
+
afterwards, so a ~10 MB tree costs under a second, once; only changed files
|
|
30
|
+
are re-sent after that.
|
|
31
|
+
|
|
32
|
+
Ambient declarations are still used for what a program cannot express.
|
|
33
|
+
TypeScript only sees *lexical* declarations: `window.Foo = ...` is not a
|
|
34
|
+
declaration to it, and UMD-wrapped libraries assign their global inside a
|
|
35
|
+
closure — `factory(global.React = {})` — which it cannot follow statically.
|
|
36
|
+
Their source contributes nothing, which is why vendored code is excluded by
|
|
37
|
+
default and React stays typed by a bundled stub. Point
|
|
38
|
+
`js_program_exclude` at any other third-party or generated JS.
|
|
39
|
+
- **A whitespace toggle in the status bar** (¶), showing tabs, spaces and
|
|
40
|
+
hidden characters in the active editor.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- **The editor became very slow on JSX files with many unresolved names.**
|
|
44
|
+
Opening such a file fired one `/js_definition` request per unknown symbol,
|
|
45
|
+
in parallel — each spawning an `rg` process — and called `addExtraLib` once
|
|
46
|
+
per resolution, re-validating every open model each time. A file with a
|
|
47
|
+
thousand warnings meant a thousand greps saturating the dev server and a
|
|
48
|
+
thousand full TypeScript re-validations. That starved the file-tree poll,
|
|
49
|
+
git status, and saves behind it. Lookups are now serialized and capped, and
|
|
50
|
+
the declaration updates are coalesced into a single flush.
|
|
51
|
+
- **Minified bundles crowded out the workspace's real globals.** A minified
|
|
52
|
+
file is one enormous line that usually opens with `var a,b,c,…` running to
|
|
53
|
+
thousands of declarators; split on commas, that single line exhausted the
|
|
54
|
+
3000-symbol cap before the scan reached your own components, so every
|
|
55
|
+
reference to them showed "Cannot find name". Declaring `a`/`n`/`t` as
|
|
56
|
+
ambient `any` also silenced real diagnostics for those names everywhere.
|
|
57
|
+
Minified files are now skipped by filename and by shape, and the endpoint
|
|
58
|
+
reports `truncated` so a workspace that outgrows the cap is diagnosable
|
|
59
|
+
instead of silently incomplete.
|
|
60
|
+
- **"File was edited externally" appeared for files nothing had touched.** The
|
|
61
|
+
check compared the file on disk against the editor buffer — which differ for
|
|
62
|
+
every unsaved tab by definition — so saving one file broadcast a change that
|
|
63
|
+
flagged every *other* dirty tab. It now compares disk against the last disk
|
|
64
|
+
content seen, so only a real on-disk change raises the banner.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## [0.10.1] - 2026-07-27
|
|
69
|
+
|
|
70
|
+
### Removed
|
|
71
|
+
- **The `listen`-based file watcher, and with it `config.watch_files`.**
|
|
72
|
+
0.10.0 enabled a workspace watcher by default. On Linux each watched
|
|
73
|
+
directory costs an inotify watch from `fs.inotify.max_user_watches`, a
|
|
74
|
+
*per-user* budget shared with everything else watching files — including the
|
|
75
|
+
host app's own code reloader and any other gem using `listen`. Exhausting it
|
|
76
|
+
raises `iNotify max watches exceeded`, and because `listen` reports some of
|
|
77
|
+
those failures from its own background thread, mbeditor could not even
|
|
78
|
+
rescue them. Claiming a share of a scarce OS resource by default was the
|
|
79
|
+
wrong trade for a development tool, and raising the limit needs root, which
|
|
80
|
+
a developer may not have.
|
|
81
|
+
|
|
82
|
+
Nothing is lost: external changes are picked up by polling, which is how the
|
|
83
|
+
editor already tracked git state. If you set `config.watch_files`, remove it
|
|
84
|
+
— it is now ignored.
|
|
85
|
+
|
|
86
|
+
### Fixed
|
|
87
|
+
- **The file tree never refreshed for changes made outside the editor.** Its
|
|
88
|
+
10-second poll returned early whenever the Action Cable socket was connected,
|
|
89
|
+
on the reasoning that the push covered it — but the server only broadcasts
|
|
90
|
+
from mbeditor's own mutation endpoints. With a socket connected, which is the
|
|
91
|
+
normal case, an external `git checkout` or generator run was never picked up.
|
|
92
|
+
The poll now always runs; the push remains the instant path for our own
|
|
93
|
+
writes. This is the bug the 0.10.0 watcher was compensating for.
|
|
94
|
+
- **Git line-number tinting went stale for external changes**, for the same
|
|
95
|
+
reason, and its refresh timer was being cleared and recreated on every
|
|
96
|
+
re-render so it never survived long enough to fire. It now polls on its own
|
|
97
|
+
timer and on window focus, matching the file tree.
|
|
98
|
+
- **Watched paths were dropped when the workspace was reached through a
|
|
99
|
+
symlink** (macOS `/var` → `/private/var`, or a symlinked checkout), because
|
|
100
|
+
reported paths resolve to the real path and no longer matched the configured
|
|
101
|
+
root.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
8
105
|
## [0.10.0] - 2026-07-27
|
|
9
106
|
|
|
10
107
|
### Added
|
data/README.md
CHANGED
|
@@ -16,7 +16,6 @@ Mbeditor (Mini Browser Editor) is a mountable Rails engine that adds a browser-b
|
|
|
16
16
|
- Optional RuboCop lint and format endpoints (uses host app RuboCop)
|
|
17
17
|
- Optional Ruby language-server integration (definitions, hover, completion, diagnostics)
|
|
18
18
|
- Optional test runner with inline failure markers and a dedicated results panel (Minitest and RSpec)
|
|
19
|
-
- Optional workspace file watching, so changes made outside the editor refresh the tree and git decorations
|
|
20
19
|
|
|
21
20
|
## Security Warning
|
|
22
21
|
Mbeditor exposes read and write access to your Rails application directory over HTTP. It is intended only for local development.
|
|
@@ -78,6 +77,10 @@ Mbeditor.configure do |config|
|
|
|
78
77
|
# config.ruby_def_include_dirs = %w[app/models app/controllers app/helpers app/concerns]
|
|
79
78
|
# config.related_files_custom_paths = %w[app/assets/javascripts/app app/policies]
|
|
80
79
|
|
|
80
|
+
# JavaScript intelligence (see the "JavaScript intelligence" section below)
|
|
81
|
+
# config.js_program = false # disable the source program entirely
|
|
82
|
+
# config.js_program_exclude = %w[vendor app/assets/javascripts/react] # third-party/generated JS
|
|
83
|
+
|
|
81
84
|
# Resilient routing (see the "Resilient Routing" section below)
|
|
82
85
|
# config.mount_path = "/mbeditor" # explicit prefix override; auto-detected when nil
|
|
83
86
|
# config.resilient_routing = false # escape hatch; true keeps the editor up when host routes break
|
|
@@ -96,12 +99,13 @@ end
|
|
|
96
99
|
| `search_timeout` | `15` | Wall-clock bound on project-search subprocesses; a tripped deadline returns the partial results collected so far. `nil` disables. |
|
|
97
100
|
| `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. |
|
|
98
101
|
| `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
|
+
| `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
|
+
| `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. |
|
|
99
104
|
| `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. |
|
|
100
105
|
| `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. |
|
|
101
106
|
| `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. |
|
|
102
107
|
| `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`. |
|
|
103
108
|
| `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. |
|
|
104
|
-
| `watch_files` | `:auto` | Watch the workspace for changes made outside the editor (a terminal `git checkout`, a generator, another editor) and push a refresh to open clients. Requires the host's [`listen`](https://github.com/guard/listen) gem; without it the editor behaves as before and only announces its own writes. `false` disables. |
|
|
105
109
|
|
|
106
110
|
### Authentication
|
|
107
111
|
|
|
@@ -143,6 +147,73 @@ See [Resilient Routing](#resilient-routing) for details.
|
|
|
143
147
|
| `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. |
|
|
144
148
|
| `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. |
|
|
145
149
|
|
|
150
|
+
## JavaScript intelligence
|
|
151
|
+
|
|
152
|
+
Under Sprockets every JS file shares one global scope, with no imports. The
|
|
153
|
+
editor models that in two layers.
|
|
154
|
+
|
|
155
|
+
**1. The source program.** Your workspace's own JS is loaded into Monaco's
|
|
156
|
+
TypeScript program. A JS file with no `import`/`export` is a TypeScript
|
|
157
|
+
*script*, so its top-level declarations land in the global scope — which is
|
|
158
|
+
exactly the Sprockets model. You get real inferred types across files:
|
|
159
|
+
|
|
160
|
+
```jsx
|
|
161
|
+
// app/assets/javascripts/ux/Card.jsx
|
|
162
|
+
var Card = function (props) { return <div>{props.title}</div>; };
|
|
163
|
+
function formatCents(value) { return "$" + (value / 100).toFixed(2); }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
```jsx
|
|
167
|
+
// somewhere else — no import needed
|
|
168
|
+
var c = <Card title="x" />; // Card: (props: any) => JSX.Element
|
|
169
|
+
var s = formatCents(500); // formatCents(value: any): string
|
|
170
|
+
var t = formatCents(1, 2); // Expected 0-1 arguments, but got 2
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Unknown names still report `Cannot find name` — this adds type information, it
|
|
174
|
+
doesn't silence errors.
|
|
175
|
+
|
|
176
|
+
**2. Ambient declarations**, for names the program can't supply.
|
|
177
|
+
|
|
178
|
+
Both layers are needed, because TypeScript only sees *lexical* declarations:
|
|
179
|
+
|
|
180
|
+
- `window.Foo = ...` is a runtime global TypeScript does not treat as a
|
|
181
|
+
declaration at all.
|
|
182
|
+
- UMD-wrapped libraries — React, lodash, axios — assign their global inside a
|
|
183
|
+
closure, `factory(global.React = {})`, which TypeScript cannot follow
|
|
184
|
+
statically. **Loading their source gets you nothing**, which is why
|
|
185
|
+
`js_program_exclude` defaults to `vendor` and why React is typed by a
|
|
186
|
+
bundled stub instead.
|
|
187
|
+
|
|
188
|
+
So point `js_program_exclude` at directories of third-party or generated JS,
|
|
189
|
+
and leave your own application code in:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
config.js_program_exclude = %w[vendor app/assets/javascripts/react]
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Cost
|
|
196
|
+
|
|
197
|
+
Measured against the Monaco TypeScript worker:
|
|
198
|
+
|
|
199
|
+
| program size | build | per file opened after |
|
|
200
|
+
|---|---|---|
|
|
201
|
+
| 1 MB | 210 ms | 9 ms |
|
|
202
|
+
| 3 MB | 378 ms | 11 ms |
|
|
203
|
+
| 5.5 MB | 443 ms | 23 ms |
|
|
204
|
+
| 9.4 MB | 872 ms | 32 ms |
|
|
205
|
+
|
|
206
|
+
Roughly 93 ms/MB, paid once per session. JS gzips about 4.5:1, so a 10 MB tree
|
|
207
|
+
is ~2.2 MB over the wire — worth knowing if your app runs on a remote host.
|
|
208
|
+
After the initial load only changed files are re-sent, never the whole tree.
|
|
209
|
+
|
|
210
|
+
Nothing is truncated silently: the browser console logs the file count, total
|
|
211
|
+
size, and every skipped file with a reason (minified, oversized, unreadable).
|
|
212
|
+
Minified bundles are skipped by filename and by shape, since they cost parse
|
|
213
|
+
time and declare only one-letter names inside a closure.
|
|
214
|
+
|
|
215
|
+
Set `config.js_program = false` to disable the layer entirely.
|
|
216
|
+
|
|
146
217
|
## Test Runner
|
|
147
218
|
|
|
148
219
|
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:
|
|
@@ -205,113 +276,9 @@ The gem keeps host/tooling responsibilities in the host app:
|
|
|
205
276
|
- `minitest` or `rspec` in the host app's bundle (required for the test runner)
|
|
206
277
|
- `actioncable` framework/gem (optional, required only for realtime file-change push + websocket state saves)
|
|
207
278
|
- `ruby-lsp` gem (optional — see below)
|
|
208
|
-
- `listen` gem (optional — see below)
|
|
209
279
|
|
|
210
280
|
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.
|
|
211
281
|
|
|
212
|
-
### Workspace file watching (Optional)
|
|
213
|
-
|
|
214
|
-
Add [`listen`](https://github.com/guard/listen) to the host app's development
|
|
215
|
-
group and mbeditor watches the workspace for changes it did not make itself:
|
|
216
|
-
|
|
217
|
-
```ruby
|
|
218
|
-
group :development do
|
|
219
|
-
gem 'listen'
|
|
220
|
-
end
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
Without it, only writes made *through* the editor announce themselves, so a
|
|
224
|
-
`git checkout` or a generator run in a terminal leaves the file tree and the
|
|
225
|
-
git line-number colours stale until you reopen the file. With it, those refresh
|
|
226
|
-
on their own.
|
|
227
|
-
|
|
228
|
-
The watcher runs only in `allowed_environments` and only in processes that
|
|
229
|
-
serve requests, so rake tasks and consoles never start one. It respects
|
|
230
|
-
`excluded_paths`, coalesces bursts (a branch switch is one refresh, not
|
|
231
|
-
hundreds), and requires Action Cable to actually deliver the push. If it cannot
|
|
232
|
-
start — inotify limits, permissions — it logs a warning and the editor carries
|
|
233
|
-
on exactly as it would without the gem. Set `config.watch_files = false` to
|
|
234
|
-
disable it even when `listen` is present.
|
|
235
|
-
|
|
236
|
-
### Ruby language server (Optional)
|
|
237
|
-
|
|
238
|
-
Add [ruby-lsp](https://github.com/Shopify/ruby-lsp) to the host app's
|
|
239
|
-
development group and mbeditor uses it automatically for Ruby
|
|
240
|
-
go-to-definition, hover, completion, and diagnostics:
|
|
241
|
-
|
|
242
|
-
```ruby
|
|
243
|
-
gem "ruby-lsp", require: false, group: :development
|
|
244
|
-
gem "ruby-lsp-rails", require: false, group: :development # Rails-aware results
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
`ruby-lsp-rails` needs no mbeditor configuration — ruby-lsp loads it as an
|
|
248
|
-
addon, so associations, model attributes, and route helpers start resolving on
|
|
249
|
-
their own.
|
|
250
|
-
|
|
251
|
-
What changes when it's present:
|
|
252
|
-
|
|
253
|
-
- **Diagnostics.** Ruby files are checked by ruby-lsp instead of booting
|
|
254
|
-
RuboCop over HTTP on every debounce, so you also get Prism syntax errors and
|
|
255
|
-
warnings alongside RuboCop offenses. Quick-fix lightbulbs still work for
|
|
256
|
-
correctable cops. Very large files fall back to syntax-only diagnostics.
|
|
257
|
-
- **Definitions, hover, completion.** Answered from the language server's index
|
|
258
|
-
rather than a workspace grep, including your unsaved buffer contents.
|
|
259
|
-
|
|
260
|
-
Everything degrades on its own: if ruby-lsp is missing, times out (its first
|
|
261
|
-
index of a large app takes a while), or crashes, that request falls back to the
|
|
262
|
-
built-in grep/Ripper services. ERB templates always use the built-in services —
|
|
263
|
-
ruby-lsp cannot parse ERB.
|
|
264
|
-
|
|
265
|
-
### Realtime via Action Cable (Optional)
|
|
266
|
-
|
|
267
|
-
Mbeditor works without Action Cable. If Action Cable is unavailable, unreachable, or returns transient errors, the editor automatically falls back to polling.
|
|
268
|
-
|
|
269
|
-
To enable realtime features in a host app:
|
|
270
|
-
|
|
271
|
-
1. Ensure Action Cable is enabled in the host app (for apps that do not load it by default, add the framework/gem explicitly).
|
|
272
|
-
2. Mount cable in host routes:
|
|
273
|
-
|
|
274
|
-
```ruby
|
|
275
|
-
mount ActionCable.server => '/cable'
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
3. Make Action Cable JavaScript available to the page (for asset-pipeline apps, `actioncable.js` is typically sufficient).
|
|
279
|
-
|
|
280
|
-
If any of these are missing, mbeditor still runs in polling mode.
|
|
281
|
-
|
|
282
|
-
### Syntax Highlighting Support
|
|
283
|
-
Monaco runtime assets are served from the engine route namespace (`/mbeditor/monaco-editor/*` and `/mbeditor/monaco_worker.js`).
|
|
284
|
-
The gem includes syntax highlighting for common Rails and React development file types:
|
|
285
|
-
|
|
286
|
-
**Web & Template Languages:**
|
|
287
|
-
- **Ruby** (.rb, Gemfile, gemspec, Rakefile)
|
|
288
|
-
- **HTML**
|
|
289
|
-
- **ERB** (.html.erb, .erb) — dedicated ERB grammar, plus Ruby intellisense
|
|
290
|
-
inside `<% %>` tags: hover, completion, go-to-definition (Ctrl/Cmd+click or
|
|
291
|
-
F12) and auto-`end`, all inert in the surrounding HTML. ERB uses the built-in
|
|
292
|
-
workspace services rather than ruby-lsp, which cannot parse ERB.
|
|
293
|
-
- **HAML** (.haml) — plaintext syntax highlighting (no dedicated HAML grammar in Monaco; haml-lint provides inline error markers when available)
|
|
294
|
-
- **CSS** and **SCSS** stylesheets
|
|
295
|
-
|
|
296
|
-
**JavaScript & React:**
|
|
297
|
-
- **JavaScript / JSX** (.js, .jsx, .js.jsx) — Monaco's TypeScript worker runs in
|
|
298
|
-
checked-JS mode with JSX enabled. Built for the Sprockets world where every
|
|
299
|
-
top-level `var`/`function`/`class` (and `window.X =` assignment) is a global:
|
|
300
|
-
the editor scans the workspace once at boot (`GET /js_globals`) and declares
|
|
301
|
-
all of them as ambient globals, so cross-file component references need no
|
|
302
|
-
`import` and produce no "Cannot find name" diagnostics. The list refreshes
|
|
303
|
-
automatically when files change. Runtime-only globals the static scan can't
|
|
304
|
-
see (e.g. `Routes`, `I18n`) can be declared via
|
|
305
|
-
`config.js_global_identifiers = %w[Routes I18n]`.
|
|
306
|
-
Known limits: everything is typed `any` (no cross-file type inference), and
|
|
307
|
-
genuinely undefined names are shown as warnings, not errors.
|
|
308
|
-
- **TypeScript** (.ts, .tsx)
|
|
309
|
-
|
|
310
|
-
**Configuration & Documentation:**
|
|
311
|
-
- **YAML** (.yml, .yaml)
|
|
312
|
-
- **Markdown** (.md)
|
|
313
|
-
|
|
314
|
-
These language modules are packaged locally with the gem for true offline operation. No network fallback is needed—all highlighting works without internet connectivity.
|
|
315
282
|
|
|
316
283
|
## Asset Pipeline
|
|
317
284
|
|
|
@@ -70,6 +70,9 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
70
70
|
|
|
71
71
|
var blameDecorationsRef = useRef([]);
|
|
72
72
|
var gitLineDecorationsRef = useRef([]);
|
|
73
|
+
// Latest git line-diff refresh, read by the poll effect so its interval does
|
|
74
|
+
// not have to be torn down whenever the active tab changes.
|
|
75
|
+
var gitLineRefreshRef = useRef(null);
|
|
73
76
|
var blameZoneIdsRef = useRef([]);
|
|
74
77
|
var testDecorationIdsRef = useRef([]);
|
|
75
78
|
var testZoneIdsRef = useRef([]);
|
|
@@ -1290,7 +1293,14 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
1290
1293
|
// The ranges come from `git diff -U0` against HEAD, so they describe the file
|
|
1291
1294
|
// as last written to disk. Monaco anchors decorations to the model and shifts
|
|
1292
1295
|
// them as you type, which keeps them roughly right mid-edit; the authoritative
|
|
1293
|
-
// refresh happens
|
|
1296
|
+
// refresh happens on the signals wired up at the end of this effect.
|
|
1297
|
+
|
|
1298
|
+
// Poll cadence for the tint, matching the file tree's. Slower to react than a
|
|
1299
|
+
// filesystem watcher would be, at the cost of one `git diff -U0` on one file
|
|
1300
|
+
// per visible pane — against a resource nothing else is competing for, rather
|
|
1301
|
+
// than a share of the kernel's inotify budget.
|
|
1302
|
+
var GIT_LINE_POLL_MS = 10000;
|
|
1303
|
+
|
|
1294
1304
|
useEffect(function () {
|
|
1295
1305
|
if (!gitAvailable || !tab.path || tab.isDiff || tab.isCombinedDiff) return;
|
|
1296
1306
|
|
|
@@ -1347,17 +1357,45 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
1347
1357
|
|
|
1348
1358
|
refresh();
|
|
1349
1359
|
|
|
1360
|
+
// Publish the current refresh for the poll below, which lives in its own
|
|
1361
|
+
// effect so a re-render of this one cannot restart its clock.
|
|
1362
|
+
gitLineRefreshRef.current = refresh;
|
|
1363
|
+
|
|
1364
|
+
// Immediate path for writes mbeditor made itself.
|
|
1350
1365
|
var hasSocket = typeof WebSocketService !== 'undefined' && WebSocketService.onFilesChanged;
|
|
1351
1366
|
var onChanged = hasSocket ? function () { refresh(); } : null;
|
|
1352
1367
|
if (onChanged) WebSocketService.onFilesChanged(onChanged);
|
|
1353
1368
|
|
|
1354
1369
|
return function () {
|
|
1355
1370
|
cancelled = true;
|
|
1371
|
+
gitLineRefreshRef.current = null;
|
|
1356
1372
|
if (onChanged && WebSocketService.offFilesChanged) WebSocketService.offFilesChanged(onChanged);
|
|
1357
1373
|
clear();
|
|
1358
1374
|
};
|
|
1359
1375
|
}, [tab.path, tab.externalContentVersion, tab.isDiff, tab.isCombinedDiff, gitAvailable]);
|
|
1360
1376
|
|
|
1377
|
+
// The poll for changes made outside the editor — a terminal commit or branch
|
|
1378
|
+
// switch alters the diff without touching this buffer.
|
|
1379
|
+
//
|
|
1380
|
+
// Deliberately its own effect with no dependencies. Held inside the effect
|
|
1381
|
+
// above, the interval was cleared and recreated on every re-render of that
|
|
1382
|
+
// one and never survived long enough to fire, so the tint only ever updated
|
|
1383
|
+
// via the WebSocket — i.e. never for external changes, which is the whole
|
|
1384
|
+
// point of it. Reading the refresh through a ref keeps this clock running
|
|
1385
|
+
// across tab switches.
|
|
1386
|
+
useEffect(function () {
|
|
1387
|
+
var tick = function () {
|
|
1388
|
+
if (document.hidden) return;
|
|
1389
|
+
if (gitLineRefreshRef.current) gitLineRefreshRef.current();
|
|
1390
|
+
};
|
|
1391
|
+
var intervalId = setInterval(tick, GIT_LINE_POLL_MS);
|
|
1392
|
+
window.addEventListener('focus', tick);
|
|
1393
|
+
return function () {
|
|
1394
|
+
clearInterval(intervalId);
|
|
1395
|
+
window.removeEventListener('focus', tick);
|
|
1396
|
+
};
|
|
1397
|
+
}, []);
|
|
1398
|
+
|
|
1361
1399
|
// Handle Blame data fetching
|
|
1362
1400
|
useEffect(function () {
|
|
1363
1401
|
if (!isBlameVisible) {
|
|
@@ -599,6 +599,10 @@ var MbeditorApp = function MbeditorApp() {
|
|
|
599
599
|
customPathsRef.current = customPaths;
|
|
600
600
|
var recentSavesRef = useRef({});
|
|
601
601
|
var isSavingRef = useRef(false);
|
|
602
|
+
// path -> the file's content as last seen ON DISK (newline-normalised).
|
|
603
|
+
// External-change detection compares disk-to-disk; comparing disk to the
|
|
604
|
+
// buffer flags every dirty tab, which is just the definition of "dirty".
|
|
605
|
+
var lastDiskContentRef = useRef({});
|
|
602
606
|
|
|
603
607
|
// ── Draft backup helpers ─────────────────────────────────────────────────
|
|
604
608
|
var draftWriteTimerRef = useRef({});
|
|
@@ -1456,7 +1460,21 @@ var MbeditorApp = function MbeditorApp() {
|
|
|
1456
1460
|
if (!data || typeof data.content !== 'string') return;
|
|
1457
1461
|
var serverNorm = data.content.replace(/\r\n/g, '\n');
|
|
1458
1462
|
var tabNorm = (pt.tab.content || '').replace(/\r\n/g, '\n');
|
|
1463
|
+
|
|
1464
|
+
// Did the file on disk actually change? Compare disk against the last
|
|
1465
|
+
// disk content we saw, never against the buffer — a dirty buffer
|
|
1466
|
+
// differs from disk by definition, so the old comparison reported
|
|
1467
|
+
// every unsaved tab as "updated externally" whenever a files_changed
|
|
1468
|
+
// push arrived (which our own save of some *other* file triggers).
|
|
1469
|
+
// A clean tab's buffer IS the disk content, so it seeds the baseline;
|
|
1470
|
+
// a dirty tab with no baseline yet can't be judged, so record and wait.
|
|
1471
|
+
var prevDisk = lastDiskContentRef.current[pt.tab.path];
|
|
1472
|
+
lastDiskContentRef.current[pt.tab.path] = serverNorm;
|
|
1459
1473
|
if (serverNorm === tabNorm) return;
|
|
1474
|
+
if (prevDisk === undefined && pt.tab.dirty) return;
|
|
1475
|
+
if (prevDisk === undefined) prevDisk = tabNorm;
|
|
1476
|
+
if (serverNorm === prevDisk) return;
|
|
1477
|
+
|
|
1460
1478
|
if (!pt.tab.dirty) {
|
|
1461
1479
|
EditorStore.setState({
|
|
1462
1480
|
panes: EditorStore.getState().panes.map(function (p) {
|
|
@@ -1498,14 +1516,18 @@ var MbeditorApp = function MbeditorApp() {
|
|
|
1498
1516
|
});
|
|
1499
1517
|
}
|
|
1500
1518
|
|
|
1501
|
-
// Auto-refresh the file tree every 10s to pick up external changes (new files,
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1519
|
+
// Auto-refresh the file tree every 10s to pick up external changes (new files,
|
|
1520
|
+
// deletions, a branch switch in a terminal).
|
|
1521
|
+
//
|
|
1522
|
+
// This runs whether or not the WebSocket is connected. It used to skip when
|
|
1523
|
+
// connected, on the reasoning that the push covered it — but the server only
|
|
1524
|
+
// broadcasts from mbeditor's own mutation endpoints, so a connected socket
|
|
1525
|
+
// meant external changes were never picked up at all. The push remains the
|
|
1526
|
+
// instant path for our own writes; this is what catches everything else.
|
|
1504
1527
|
// Uses functional setTreeData to skip the re-render when nothing has changed.
|
|
1505
1528
|
useEffect(function () {
|
|
1506
1529
|
var intervalId = setInterval(function () {
|
|
1507
1530
|
if (document.hidden) return;
|
|
1508
|
-
if (WebSocketService.isConnected()) return; // WebSocket is handling refreshes
|
|
1509
1531
|
FileService.getTree().then(function (data) {
|
|
1510
1532
|
var newData = data || [];
|
|
1511
1533
|
setTreeData(function (prevData) {
|
|
@@ -5102,6 +5124,25 @@ var MbeditorApp = function MbeditorApp() {
|
|
|
5102
5124
|
React.createElement("i", { className: "fas fa-stream" }),
|
|
5103
5125
|
" Logs"
|
|
5104
5126
|
),
|
|
5127
|
+
React.createElement(
|
|
5128
|
+
"button",
|
|
5129
|
+
{
|
|
5130
|
+
type: "button",
|
|
5131
|
+
className: "statusbar-btn" + (editorPrefs.renderWhitespace === 'all' ? " active" : ""),
|
|
5132
|
+
title: editorPrefs.renderWhitespace === 'all'
|
|
5133
|
+
? "Hide whitespace characters"
|
|
5134
|
+
: "Show whitespace characters (tabs, spaces, control characters)",
|
|
5135
|
+
"aria-pressed": editorPrefs.renderWhitespace === 'all',
|
|
5136
|
+
onClick: function () {
|
|
5137
|
+
setEditorPrefs(function (p) {
|
|
5138
|
+
return _extends({}, p, {
|
|
5139
|
+
renderWhitespace: p.renderWhitespace === 'all' ? 'none' : 'all'
|
|
5140
|
+
});
|
|
5141
|
+
});
|
|
5142
|
+
}
|
|
5143
|
+
},
|
|
5144
|
+
React.createElement("i", { className: "fas fa-paragraph" })
|
|
5145
|
+
),
|
|
5105
5146
|
activeEOL && React.createElement(
|
|
5106
5147
|
"button",
|
|
5107
5148
|
{
|
|
@@ -107,26 +107,122 @@
|
|
|
107
107
|
|
|
108
108
|
// Declare a discovered global in Monaco's extra libs so the TS2304 warning disappears.
|
|
109
109
|
// Calling addExtraLib with the same URI replaces the previous content in-place.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
//
|
|
111
|
+
// Coalesced: addExtraLib invalidates the TypeScript worker and re-validates
|
|
112
|
+
// EVERY open model. A JSX file that references 500 host-app globals resolves
|
|
113
|
+
// 500 symbols, and calling addExtraLib once per symbol meant 500 full
|
|
114
|
+
// re-validations — the editor spends minutes pegged at 100% CPU redoing work
|
|
115
|
+
// it is about to redo again. Batch them into one flush instead.
|
|
116
|
+
var _discoveredFlushTimer = null;
|
|
117
|
+
function flushDiscoveredGlobals() {
|
|
118
|
+
_discoveredFlushTimer = null;
|
|
114
119
|
var mts = window.monaco && window.monaco.languages && window.monaco.languages.typescript;
|
|
115
|
-
if (!mts) return;
|
|
120
|
+
if (!mts || !mts.javascriptDefaults) return;
|
|
116
121
|
var decls = Object.keys(discoveredJsGlobals)
|
|
117
122
|
.map(function(k) { return 'declare var ' + k + ': any;'; }).join('\n');
|
|
118
123
|
mts.javascriptDefaults.addExtraLib(decls, 'inmemory://mbeditor/discovered-globals.d.ts');
|
|
119
124
|
}
|
|
120
125
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
//
|
|
126
|
+
function addDiscoveredGlobal(name) {
|
|
127
|
+
if (discoveredJsGlobals[name]) return;
|
|
128
|
+
if (REACT_MINI_UMD_GLOBALS[name]) return; // already in the mini-UMD
|
|
129
|
+
discoveredJsGlobals[name] = true;
|
|
130
|
+
if (_discoveredFlushTimer) return;
|
|
131
|
+
_discoveredFlushTimer = setTimeout(flushDiscoveredGlobals, 300);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Reactive TS2304 resolution runs ONE /js_definition request at a time.
|
|
135
|
+
// Each request spawns an rg process on the server, so firing one per
|
|
136
|
+
// unresolved symbol in parallel (a big JSX file can have hundreds) saturated
|
|
137
|
+
// the dev server: the file tree poll, git status, and file saves all queued
|
|
138
|
+
// behind hundreds of greps. That is what made the whole editor feel slow and
|
|
139
|
+
// what let the "file was edited externally" check race its own save.
|
|
140
|
+
var JS_LOOKUP_QUEUE_MAX = 400;
|
|
141
|
+
var jsLookupQueue = [];
|
|
142
|
+
var jsLookupBusy = false;
|
|
143
|
+
|
|
144
|
+
function pumpJsLookupQueue() {
|
|
145
|
+
if (jsLookupBusy) return;
|
|
146
|
+
var job = jsLookupQueue.shift();
|
|
147
|
+
if (!job) return;
|
|
148
|
+
jsLookupBusy = true;
|
|
149
|
+
var done = function () { jsLookupBusy = false; pumpJsLookupQueue(); };
|
|
150
|
+
FileService.getJsDefinition(job.sym)
|
|
151
|
+
.then(function (data) {
|
|
152
|
+
var results = data && data.results;
|
|
153
|
+
if (results && results.length && results[0].file !== job.modelPath) {
|
|
154
|
+
addDiscoveredGlobal(job.sym);
|
|
155
|
+
} else if (!results || !results.length) {
|
|
156
|
+
if (isRuntimeWindowGlobal(job.sym)) addDiscoveredGlobal(job.sym);
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
.then(done, done);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function queueJsGlobalLookup(sym, modelPath) {
|
|
163
|
+
if (jsLookupQueue.length >= JS_LOOKUP_QUEUE_MAX) return;
|
|
164
|
+
jsLookupQueue.push({ sym: sym, modelPath: modelPath });
|
|
165
|
+
pumpJsLookupQueue();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── The workspace TypeScript program ──────────────────────────────────────
|
|
169
|
+
//
|
|
170
|
+
// Two layers, because one does not cover everything:
|
|
171
|
+
//
|
|
172
|
+
// 1. /js_program — the workspace's own JS source, added as extraLibs at
|
|
173
|
+
// file:/// URIs. A JS file with no import/export is a *script*, so
|
|
174
|
+
// TypeScript puts its top-level declarations in the global scope: the
|
|
175
|
+
// Sprockets model exactly. This gives REAL types — member completion,
|
|
176
|
+
// inferred signatures, argument-count checks — for the host app's own
|
|
177
|
+
// components, and still reports TS2304 for genuinely unknown names.
|
|
178
|
+
//
|
|
179
|
+
// 2. /js_globals — ambient `declare var X: any` for names the program
|
|
180
|
+
// can't supply. UMD-wrapped libraries (React, lodash, axios) assign
|
|
181
|
+
// their global inside a closure, `factory(global.React = {})`, which
|
|
182
|
+
// TypeScript cannot follow statically, so their source contributes no
|
|
183
|
+
// global at all. Those names only exist as ambient declarations.
|
|
184
|
+
//
|
|
185
|
+
// A global is skipped from layer 2 only when layer 1 genuinely supplies it,
|
|
186
|
+
// so a real inferred type is never shadowed by `any` — and, just as
|
|
187
|
+
// importantly, a name the program can't see never loses its declaration.
|
|
188
|
+
// "In a program file" is NOT sufficient: `window.Foo = ...` is a runtime
|
|
189
|
+
// global that TypeScript does not treat as a declaration at all, so those
|
|
190
|
+
// must keep their ambient `declare var` even though their file is in the
|
|
191
|
+
// program. Only lexical declarations land in TypeScript's global scope.
|
|
192
|
+
//
|
|
193
|
+
// Same-URI addExtraLib replaces content in place; that is how both layers
|
|
194
|
+
// refresh.
|
|
195
|
+
var PROGRAM_VISIBLE_KINDS = { 'var': 1, 'let': 1, 'const': 1, 'function': 1, 'class': 1 };
|
|
196
|
+
var programPaths = {}; // workspace-relative path -> true, for the filter above
|
|
197
|
+
|
|
198
|
+
function programUri(path) {
|
|
199
|
+
return 'file:///' + String(path).replace(/^\/+/, '');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function loadWorkspaceProgram(monaco) {
|
|
203
|
+
if (typeof FileService === 'undefined') return;
|
|
204
|
+
var mts = monaco && monaco.languages && monaco.languages.typescript;
|
|
205
|
+
if (!mts || !mts.javascriptDefaults) return;
|
|
206
|
+
|
|
207
|
+
var programLoaded = FileService.getJsProgram
|
|
208
|
+
? FileService.getJsProgram().then(function (data) {
|
|
209
|
+
if (!data || !data.ok || !data.files) return;
|
|
210
|
+
data.files.forEach(function (f) {
|
|
211
|
+
if (!f || typeof f.content !== 'string' || !f.path) return;
|
|
212
|
+
programPaths[f.path] = true;
|
|
213
|
+
mts.javascriptDefaults.addExtraLib(f.content, programUri(f.path));
|
|
214
|
+
});
|
|
215
|
+
if (data.skipped && data.skipped.length && window.console) {
|
|
216
|
+
console.info('[mbeditor] ' + data.fileCount + ' source files (' +
|
|
217
|
+
Math.round(data.totalBytes / 1024) + ' KB) in the TypeScript program; ' +
|
|
218
|
+
data.skipped.length + ' skipped:', data.skipped);
|
|
219
|
+
}
|
|
220
|
+
}).catch(function () { /* fall through to ambient globals alone */ })
|
|
221
|
+
: Promise.resolve();
|
|
222
|
+
|
|
223
|
+
programLoaded.then(function () { loadWorkspaceGlobals(monaco); });
|
|
224
|
+
}
|
|
225
|
+
|
|
130
226
|
function loadWorkspaceGlobals(monaco) {
|
|
131
227
|
if (typeof FileService === 'undefined' || !FileService.getJsGlobals) return;
|
|
132
228
|
var mts = monaco && monaco.languages && monaco.languages.typescript;
|
|
@@ -139,6 +235,11 @@
|
|
|
139
235
|
if (!name || !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) return;
|
|
140
236
|
if (REACT_MINI_UMD_GLOBALS[name]) return;
|
|
141
237
|
if (discoveredJsGlobals[name]) return; // already in discovered-globals.d.ts
|
|
238
|
+
// The program already declares this one, with a real type.
|
|
239
|
+
if (s.file && programPaths[s.file] && PROGRAM_VISIBLE_KINDS[s.kind]) {
|
|
240
|
+
attemptedJsGlobals[name] = true;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
142
243
|
names.push(name);
|
|
143
244
|
// Pre-seed the reactive resolver so the marker patcher never fires a
|
|
144
245
|
// per-symbol /js_definition request for these.
|
|
@@ -149,6 +250,23 @@
|
|
|
149
250
|
}).catch(function () { /* endpoint unavailable — reactive path still works */ });
|
|
150
251
|
}
|
|
151
252
|
|
|
253
|
+
// Incremental refresh: re-send only the files that changed, never the whole
|
|
254
|
+
// tree. A workspace can be tens of MB, so re-fetching it on every save would
|
|
255
|
+
// cost more than the feature is worth.
|
|
256
|
+
function refreshProgramPaths(monaco, paths) {
|
|
257
|
+
var mts = monaco && monaco.languages && monaco.languages.typescript;
|
|
258
|
+
if (!mts || !mts.javascriptDefaults) return;
|
|
259
|
+
if (typeof FileService === 'undefined' || !FileService.getJsProgramFile) return;
|
|
260
|
+
(paths || []).forEach(function (path) {
|
|
261
|
+
if (!path || !/\.(js|jsx|ts|tsx)$/i.test(path)) return;
|
|
262
|
+
FileService.getJsProgramFile(path).then(function (data) {
|
|
263
|
+
if (!data || !data.ok || !data.file) return;
|
|
264
|
+
programPaths[data.file.path] = true;
|
|
265
|
+
mts.javascriptDefaults.addExtraLib(data.file.content, programUri(data.file.path));
|
|
266
|
+
}).catch(function () {});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
152
270
|
// Navigate to the first workspace definition of a JS symbol.
|
|
153
271
|
// Returns a Promise<boolean> — true if a definition was found and opened.
|
|
154
272
|
// Try the host's ruby-lsp (via the /ruby_lsp bridge) for a Ruby language
|
|
@@ -848,15 +966,22 @@
|
|
|
848
966
|
);
|
|
849
967
|
}
|
|
850
968
|
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
|
|
969
|
+
// The workspace program (source files) plus the ambient globals it can't
|
|
970
|
+
// supply, loaded once now.
|
|
971
|
+
loadWorkspaceProgram(monaco);
|
|
972
|
+
|
|
973
|
+
// On a change: refresh just the touched files' program entries, and
|
|
974
|
+
// re-run the (cheap, cached) globals scan. The whole tree is never
|
|
975
|
+
// re-sent — see refreshProgramPaths.
|
|
854
976
|
var refreshWorkspaceGlobals = function () { loadWorkspaceGlobals(monaco); };
|
|
855
977
|
if (window._ && window._.debounce) {
|
|
856
978
|
refreshWorkspaceGlobals = window._.debounce(refreshWorkspaceGlobals, 2000);
|
|
857
979
|
}
|
|
858
980
|
if (typeof WebSocketService !== 'undefined' && WebSocketService.onFilesChanged) {
|
|
859
|
-
WebSocketService.onFilesChanged(function () {
|
|
981
|
+
WebSocketService.onFilesChanged(function (payload) {
|
|
982
|
+
if (payload && payload.paths) refreshProgramPaths(monaco, payload.paths);
|
|
983
|
+
refreshWorkspaceGlobals();
|
|
984
|
+
});
|
|
860
985
|
}
|
|
861
986
|
var _lastGlobalsFocusRefresh = Date.now();
|
|
862
987
|
window.addEventListener('focus', function () {
|
|
@@ -956,17 +1081,7 @@
|
|
|
956
1081
|
var sym = match[1];
|
|
957
1082
|
if (attemptedJsGlobals[sym]) return;
|
|
958
1083
|
attemptedJsGlobals[sym] = true;
|
|
959
|
-
|
|
960
|
-
FileService.getJsDefinition(sym)
|
|
961
|
-
.then(function(data) {
|
|
962
|
-
var results = data && data.results;
|
|
963
|
-
if (results && results.length && results[0].file !== modelPath) {
|
|
964
|
-
addDiscoveredGlobal(sym);
|
|
965
|
-
} else if (!results || !results.length) {
|
|
966
|
-
if (isRuntimeWindowGlobal(sym)) addDiscoveredGlobal(sym);
|
|
967
|
-
}
|
|
968
|
-
})
|
|
969
|
-
.catch(function() {});
|
|
1084
|
+
queueJsGlobalLookup(sym, model._mbeditorPath);
|
|
970
1085
|
});
|
|
971
1086
|
});
|
|
972
1087
|
}
|
|
@@ -247,6 +247,20 @@ var FileService = (function () {
|
|
|
247
247
|
.then(function(res) { return res.data; });
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
// The workspace's own JS source for Monaco's TypeScript program. This is the
|
|
251
|
+
// largest response the editor fetches (a big app is tens of MB before gzip),
|
|
252
|
+
// so it gets a generous timeout and is only ever fetched whole once — later
|
|
253
|
+
// changes go through getJsProgramFile.
|
|
254
|
+
function getJsProgram() {
|
|
255
|
+
return axios.get(window.mbeditorBasePath() + '/js_program', { timeout: 120000 })
|
|
256
|
+
.then(function(res) { return res.data; });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getJsProgramFile(path) {
|
|
260
|
+
return axios.get(window.mbeditorBasePath() + '/js_program', { params: { path: path }, timeout: 15000 })
|
|
261
|
+
.then(function(res) { return res.data; });
|
|
262
|
+
}
|
|
263
|
+
|
|
250
264
|
function getRelatedFiles(path) {
|
|
251
265
|
return axios.get(window.mbeditorBasePath() + '/related_files', { params: { path: path } })
|
|
252
266
|
.then(function(res) { return res.data; });
|
|
@@ -292,6 +306,8 @@ var FileService = (function () {
|
|
|
292
306
|
getFileIncludes: getFileIncludes,
|
|
293
307
|
getClientConfig: getClientConfig,
|
|
294
308
|
getJsGlobals: getJsGlobals,
|
|
309
|
+
getJsProgram: getJsProgram,
|
|
310
|
+
getJsProgramFile: getJsProgramFile,
|
|
295
311
|
rubyLspRequest: rubyLspRequest,
|
|
296
312
|
lspDiagnostics: lspDiagnostics,
|
|
297
313
|
getRelatedFiles: getRelatedFiles,
|
|
@@ -462,6 +462,21 @@ module Mbeditor
|
|
|
462
462
|
render json: { ok: false, error: e.message }, status: :unprocessable_content
|
|
463
463
|
end
|
|
464
464
|
|
|
465
|
+
# GET /mbeditor/js_program
|
|
466
|
+
# The workspace's own JS source, for Monaco's TypeScript program. With
|
|
467
|
+
# ?path= it returns just that one file, which is how the editor refreshes
|
|
468
|
+
# after a change without re-sending the whole tree.
|
|
469
|
+
def js_program
|
|
470
|
+
if params[:path].present?
|
|
471
|
+
entry = JsProgramService.file(workspace_root, params[:path])
|
|
472
|
+
return render json: { ok: true, file: entry }
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
render json: JsProgramService.call(workspace_root)
|
|
476
|
+
rescue StandardError => e
|
|
477
|
+
render json: { ok: false, error: e.message }, status: :unprocessable_content
|
|
478
|
+
end
|
|
479
|
+
|
|
465
480
|
RUBY_LSP_METHODS = {
|
|
466
481
|
"definition" => "textDocument/definition",
|
|
467
482
|
"hover" => "textDocument/hover",
|
|
@@ -964,6 +979,7 @@ module Mbeditor
|
|
|
964
979
|
FileTreeService.invalidate(root)
|
|
965
980
|
SearchReplaceService.invalidate_cache(root)
|
|
966
981
|
JsGlobalsService.invalidate(root)
|
|
982
|
+
JsProgramService.invalidate(root)
|
|
967
983
|
Thread.new do
|
|
968
984
|
GitInfoService.invalidate(root)
|
|
969
985
|
rescue => e
|
|
@@ -22,6 +22,24 @@ module Mbeditor
|
|
|
22
22
|
|
|
23
23
|
IDENTIFIER = /[A-Za-z_$][A-Za-z0-9_$]*/
|
|
24
24
|
|
|
25
|
+
# Minified bundles are the reason for both guards below.
|
|
26
|
+
#
|
|
27
|
+
# A minified file is one enormous line, and it usually opens with a
|
|
28
|
+
# multi-declarator `var a,b,c,d,…` running to thousands of names. Split on
|
|
29
|
+
# commas, that ONE line yields thousands of one-letter symbols — enough to
|
|
30
|
+
# exhaust MAX_SYMBOLS on its own, so the workspace's actual components are
|
|
31
|
+
# never reached and every reference to them shows "Cannot find name".
|
|
32
|
+
# Worse, declaring `a`/`n`/`t` as ambient `any` silences genuine
|
|
33
|
+
# diagnostics for those names everywhere.
|
|
34
|
+
#
|
|
35
|
+
# The name check catches the conventional cases; the line-length check
|
|
36
|
+
# catches bundles that don't say "min" in the filename. Neither is a
|
|
37
|
+
# judgement about vendored code in general — a normally-formatted
|
|
38
|
+
# vendor/assets library still contributes its globals, which is correct
|
|
39
|
+
# under Sprockets.
|
|
40
|
+
MINIFIED_NAME = /[.\-]min\.(js|jsx|ts|tsx)\z/i
|
|
41
|
+
MAX_LINE_LENGTH = 2_000
|
|
42
|
+
|
|
25
43
|
MUTEX = Mutex.new
|
|
26
44
|
private_constant :MUTEX
|
|
27
45
|
|
|
@@ -47,19 +65,27 @@ module Mbeditor
|
|
|
47
65
|
|
|
48
66
|
def compute(root)
|
|
49
67
|
symbols = {}
|
|
68
|
+
truncated = false
|
|
50
69
|
|
|
51
70
|
CodeSearchService.call(PATTERN, root).each do |raw|
|
|
52
|
-
|
|
71
|
+
if symbols.length >= MAX_SYMBOLS
|
|
72
|
+
truncated = true
|
|
73
|
+
break
|
|
74
|
+
end
|
|
53
75
|
|
|
54
76
|
m = raw.chomp.match(/\A(.+?):(\d+):(.*)\z/m)
|
|
55
77
|
next unless m
|
|
56
78
|
|
|
57
79
|
abs_path = m[1]
|
|
58
80
|
next unless abs_path.start_with?(root)
|
|
81
|
+
next if abs_path.match?(MINIFIED_NAME)
|
|
82
|
+
|
|
83
|
+
snippet = m[3].strip
|
|
84
|
+
next if snippet.length > MAX_LINE_LENGTH
|
|
59
85
|
|
|
60
86
|
rel = abs_path.delete_prefix(root).delete_prefix("/")
|
|
61
87
|
line = m[2].to_i
|
|
62
|
-
extract_identifiers(
|
|
88
|
+
extract_identifiers(snippet).each do |name, kind|
|
|
63
89
|
symbols[name] ||= { name: name, file: rel, line: line, kind: kind }
|
|
64
90
|
end
|
|
65
91
|
end
|
|
@@ -71,6 +97,9 @@ module Mbeditor
|
|
|
71
97
|
{
|
|
72
98
|
ok: true,
|
|
73
99
|
generatedAt: Time.now.to_i,
|
|
100
|
+
# Surfaced so a workspace that outgrows the cap is diagnosable from
|
|
101
|
+
# the endpoint instead of silently missing globals.
|
|
102
|
+
truncated: truncated,
|
|
74
103
|
symbols: symbols.values.first(MAX_SYMBOLS).sort_by { |s| s[:name] }
|
|
75
104
|
}
|
|
76
105
|
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# Enumerates the workspace's own JavaScript source and hands it to the editor
|
|
5
|
+
# so Monaco's TypeScript worker can build a real program from it.
|
|
6
|
+
#
|
|
7
|
+
# This is the file-based replacement for declaring every discovered name as
|
|
8
|
+
# ambient `any` (JsGlobalsService). Under Sprockets a JS file with no
|
|
9
|
+
# import/export is a *script*, so its top-level declarations land in the
|
|
10
|
+
# global scope — which is exactly TypeScript's own model for script files.
|
|
11
|
+
# Giving the worker the sources instead of a name list yields real inferred
|
|
12
|
+
# types, member completions, and argument-count checking, and it still
|
|
13
|
+
# reports "Cannot find name" for genuinely unknown identifiers.
|
|
14
|
+
#
|
|
15
|
+
# What it deliberately does NOT solve: UMD-wrapped libraries. React, lodash
|
|
16
|
+
# and axios all assign their global inside a closure
|
|
17
|
+
# (`factory(global.React = {})`), which TypeScript cannot follow statically —
|
|
18
|
+
# loading their source produces no global at all. Those stay on hand-written
|
|
19
|
+
# declarations (the React mini-UMD stub) or on JsGlobalsService's ambient
|
|
20
|
+
# names, which is why that service is still here.
|
|
21
|
+
#
|
|
22
|
+
# No truncation: a workspace that exceeds any limit reports what it skipped
|
|
23
|
+
# and why, rather than silently returning a partial program.
|
|
24
|
+
class JsProgramService
|
|
25
|
+
SOURCE_EXT = /\.(js|jsx|ts|tsx)\z/i
|
|
26
|
+
|
|
27
|
+
# Minified bundles cost parse time and yield nothing useful — their globals
|
|
28
|
+
# are one-letter names inside a closure. Matched by convention, then by
|
|
29
|
+
# shape for bundles whose filename doesn't say so.
|
|
30
|
+
MINIFIED_NAME = /[.\-]min\.(js|jsx|ts|tsx)\z/i
|
|
31
|
+
MAX_LINE_LENGTH = 2_000
|
|
32
|
+
|
|
33
|
+
# A single source file this large is a bundle or generated output, not
|
|
34
|
+
# something a person edits.
|
|
35
|
+
MAX_FILE_BYTES = 1024 * 1024
|
|
36
|
+
|
|
37
|
+
CACHE_TTL = 10 # seconds
|
|
38
|
+
|
|
39
|
+
MUTEX = Mutex.new
|
|
40
|
+
private_constant :MUTEX
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def call(workspace_root)
|
|
44
|
+
root = File.expand_path(workspace_root.to_s)
|
|
45
|
+
now = monotonic
|
|
46
|
+
MUTEX.synchronize do
|
|
47
|
+
entry = (@cache ||= {})[root]
|
|
48
|
+
return entry[:data] if entry && (now - entry[:ts]) < CACHE_TTL
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
data = compute(root)
|
|
52
|
+
MUTEX.synchronize { (@cache ||= {})[root] = { ts: monotonic, data: data } }
|
|
53
|
+
data
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Content for a single workspace-relative path, for incremental refresh
|
|
57
|
+
# after a file changes. Returns nil when the path isn't program material.
|
|
58
|
+
def file(workspace_root, relative_path)
|
|
59
|
+
root = File.expand_path(workspace_root.to_s)
|
|
60
|
+
rel = relative_path.to_s.delete_prefix("/")
|
|
61
|
+
return nil unless rel.match?(SOURCE_EXT)
|
|
62
|
+
return nil if rel.match?(MINIFIED_NAME)
|
|
63
|
+
return nil if matcher(root).excluded?(rel)
|
|
64
|
+
|
|
65
|
+
abs = File.expand_path(File.join(root, rel))
|
|
66
|
+
return nil unless abs == File.join(root, rel) # no traversal out of the workspace
|
|
67
|
+
return nil unless File.file?(abs) && !File.symlink?(abs)
|
|
68
|
+
|
|
69
|
+
content = read_source(abs)
|
|
70
|
+
content && { path: rel, content: content }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def invalidate(workspace_root)
|
|
74
|
+
MUTEX.synchronize { (@cache ||= {}).delete(File.expand_path(workspace_root.to_s)) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def compute(root)
|
|
80
|
+
return disabled_result unless Mbeditor.configuration.js_program
|
|
81
|
+
|
|
82
|
+
files = []
|
|
83
|
+
skipped = []
|
|
84
|
+
total = 0
|
|
85
|
+
|
|
86
|
+
each_candidate(root) do |rel, abs|
|
|
87
|
+
if File.size(abs) > MAX_FILE_BYTES
|
|
88
|
+
skipped << { path: rel, reason: "larger than #{MAX_FILE_BYTES} bytes" }
|
|
89
|
+
next
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
content = read_source(abs)
|
|
93
|
+
if content.nil?
|
|
94
|
+
skipped << { path: rel, reason: "minified or unreadable" }
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
total += content.bytesize
|
|
99
|
+
files << { path: rel, content: content }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
ok: true,
|
|
104
|
+
enabled: true,
|
|
105
|
+
generatedAt: Time.now.to_i,
|
|
106
|
+
fileCount: files.length,
|
|
107
|
+
totalBytes: total,
|
|
108
|
+
skipped: skipped,
|
|
109
|
+
files: files.sort_by { |f| f[:path] }
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def disabled_result
|
|
114
|
+
{ ok: true, enabled: false, generatedAt: Time.now.to_i,
|
|
115
|
+
fileCount: 0, totalBytes: 0, skipped: [], files: [] }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Walks the workspace, pruning excluded directories before descending so
|
|
119
|
+
# a node_modules tree is never entered. Symlinks are skipped outright:
|
|
120
|
+
# this walk is an enumeration, not a resolve_path lookup, and following
|
|
121
|
+
# them could both escape the workspace and loop.
|
|
122
|
+
def each_candidate(root)
|
|
123
|
+
m = matcher(root)
|
|
124
|
+
stack = [root]
|
|
125
|
+
while (dir = stack.pop)
|
|
126
|
+
children(dir).each do |name|
|
|
127
|
+
abs = File.join(dir, name)
|
|
128
|
+
rel = abs.delete_prefix(root).delete_prefix("/")
|
|
129
|
+
next if m.excluded?(rel)
|
|
130
|
+
next if File.symlink?(abs)
|
|
131
|
+
|
|
132
|
+
if File.directory?(abs)
|
|
133
|
+
stack.push(abs)
|
|
134
|
+
elsif name.match?(SOURCE_EXT) && !name.match?(MINIFIED_NAME)
|
|
135
|
+
yield rel, abs
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def children(dir)
|
|
142
|
+
Dir.children(dir)
|
|
143
|
+
rescue SystemCallError
|
|
144
|
+
[]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def matcher(root)
|
|
148
|
+
ExclusionMatcher.new(exclusion_patterns, root: root)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def exclusion_patterns
|
|
152
|
+
Array(Mbeditor.configuration.excluded_paths).map(&:to_s) +
|
|
153
|
+
Array(Mbeditor.configuration.js_program_exclude).map(&:to_s)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Returns nil for anything that isn't usable program source: unreadable,
|
|
157
|
+
# invalid encoding, or minified-by-shape (one very long line).
|
|
158
|
+
def read_source(abs)
|
|
159
|
+
content = File.read(abs, encoding: Encoding::UTF_8)
|
|
160
|
+
return nil unless content.valid_encoding?
|
|
161
|
+
return nil if content.each_line.any? { |line| line.chomp.length > MAX_LINE_LENGTH }
|
|
162
|
+
|
|
163
|
+
content
|
|
164
|
+
rescue SystemCallError, IOError
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def monotonic
|
|
169
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -9,9 +9,10 @@ module Mbeditor
|
|
|
9
9
|
:lint_timeout, :base_branch_candidates, :git_timeout, :search_timeout,
|
|
10
10
|
:ruby_def_include_dirs, :related_files_custom_paths,
|
|
11
11
|
:mount_path, :resilient_routing, :js_global_identifiers,
|
|
12
|
+
:js_program, :js_program_exclude,
|
|
12
13
|
:js_syntax_check, :babel_standalone_path,
|
|
13
14
|
:ruby_lsp, :ruby_lsp_command, :ruby_lsp_timeout,
|
|
14
|
-
:search_respect_gitignore
|
|
15
|
+
:search_respect_gitignore
|
|
15
16
|
|
|
16
17
|
def initialize
|
|
17
18
|
@allowed_environments = [:development]
|
|
@@ -35,6 +36,15 @@ module Mbeditor
|
|
|
35
36
|
@related_files_custom_paths = []
|
|
36
37
|
@authentication_cache_ttl = 0
|
|
37
38
|
@js_global_identifiers = [] # extra ambient JS globals for the editor (runtime-only names invisible to static scan, e.g. %w[Routes I18n])
|
|
39
|
+
# Load the workspace's own JS source into Monaco's TypeScript program, so
|
|
40
|
+
# cross-file references get real inferred types instead of ambient `any`.
|
|
41
|
+
@js_program = true
|
|
42
|
+
# Excluded from that program on top of excluded_paths. Vendored libraries
|
|
43
|
+
# are UMD-wrapped (the global is assigned inside a closure), so their
|
|
44
|
+
# source yields no globals to TypeScript and only costs parse time — they
|
|
45
|
+
# stay on ambient declarations instead. Add any other directory of
|
|
46
|
+
# third-party or generated JS here, e.g. "app/assets/javascripts/react".
|
|
47
|
+
@js_program_exclude = %w[vendor]
|
|
38
48
|
@js_syntax_check = :auto # save-time babel parse check via host mini_racer + babel-standalone; false disables
|
|
39
49
|
@babel_standalone_path = nil # explicit path to babel-standalone JS; nil auto-detects via the asset pipeline
|
|
40
50
|
@ruby_lsp = :auto # use the host's ruby-lsp for Ruby definitions/hover/completion when available; false disables
|
|
@@ -42,7 +52,6 @@ module Mbeditor
|
|
|
42
52
|
@ruby_lsp_timeout = 3 # seconds per LSP request before falling back to the built-in services
|
|
43
53
|
@mount_path = nil # explicit URL prefix override; nil falls through to detection/"/mbeditor"
|
|
44
54
|
@resilient_routing = true # serve /mbeditor from middleware so the editor survives a broken host routes.rb; false is the escape hatch
|
|
45
|
-
@watch_files = :auto # watch the workspace for changes made outside the editor when the host has the `listen` gem; false disables
|
|
46
55
|
end
|
|
47
56
|
end
|
|
48
57
|
end
|
data/lib/mbeditor/engine.rb
CHANGED
|
@@ -4,7 +4,6 @@ require "mbeditor/rack/silence_ping_request"
|
|
|
4
4
|
require "mbeditor/rack/handle_pending_migrations"
|
|
5
5
|
require "mbeditor/rack/resilient_router"
|
|
6
6
|
require "mbeditor/cable_log_filter"
|
|
7
|
-
require "mbeditor/file_watcher"
|
|
8
7
|
|
|
9
8
|
module Mbeditor
|
|
10
9
|
class Engine < ::Rails::Engine
|
|
@@ -82,8 +81,6 @@ module Mbeditor
|
|
|
82
81
|
raise ArgumentError, "[mbeditor] config.workspace_root is set to '#{cfg.workspace_root}' but that path is not a directory"
|
|
83
82
|
end
|
|
84
83
|
|
|
85
|
-
Mbeditor::FileWatcher.start_if_enabled
|
|
86
|
-
|
|
87
84
|
if cfg.redmine_enabled
|
|
88
85
|
require "uri"
|
|
89
86
|
if cfg.redmine_url.blank?
|
data/lib/mbeditor/route_map.rb
CHANGED
|
@@ -32,6 +32,7 @@ module Mbeditor
|
|
|
32
32
|
get 'js_definition', to: 'editors#js_definition'
|
|
33
33
|
get 'js_members', to: 'editors#js_members'
|
|
34
34
|
get 'js_globals', to: 'editors#js_globals'
|
|
35
|
+
get 'js_program', to: 'editors#js_program'
|
|
35
36
|
post 'ruby_lsp', to: 'editors#ruby_lsp'
|
|
36
37
|
get 'module_members', to: 'editors#module_members'
|
|
37
38
|
get 'file_includes', to: 'editors#file_includes'
|
data/lib/mbeditor/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mbeditor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Oliver Noonan
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -114,6 +114,7 @@ files:
|
|
|
114
114
|
- app/services/mbeditor/js_definition_service.rb
|
|
115
115
|
- app/services/mbeditor/js_globals_service.rb
|
|
116
116
|
- app/services/mbeditor/js_members_service.rb
|
|
117
|
+
- app/services/mbeditor/js_program_service.rb
|
|
117
118
|
- app/services/mbeditor/js_syntax_check_service.rb
|
|
118
119
|
- app/services/mbeditor/log_tail_service.rb
|
|
119
120
|
- app/services/mbeditor/lsp_diagnostics_translator.rb
|
|
@@ -136,7 +137,6 @@ files:
|
|
|
136
137
|
- lib/mbeditor/configuration.rb
|
|
137
138
|
- lib/mbeditor/editor_bootstrap.rb
|
|
138
139
|
- lib/mbeditor/engine.rb
|
|
139
|
-
- lib/mbeditor/file_watcher.rb
|
|
140
140
|
- lib/mbeditor/mount_path.rb
|
|
141
141
|
- lib/mbeditor/private_routes.rb
|
|
142
142
|
- lib/mbeditor/rack/handle_pending_migrations.rb
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Mbeditor
|
|
4
|
-
# Watches the workspace for changes made outside the editor — a terminal
|
|
5
|
-
# `git checkout`, a rebase, another editor, a generator — and broadcasts the
|
|
6
|
-
# same `files_changed` payload the mutation endpoints send. Clients refresh
|
|
7
|
-
# the file tree, git line-number tinting and cached globals from it.
|
|
8
|
-
#
|
|
9
|
-
# The `listen` gem is an optional host dependency. Without it the editor
|
|
10
|
-
# behaves exactly as before: only changes made *through* mbeditor announce
|
|
11
|
-
# themselves. Nothing warns loudly about the missing gem — it is opt-in.
|
|
12
|
-
#
|
|
13
|
-
# Only one watcher runs per process. It is deliberately not started in test
|
|
14
|
-
# or in non-server processes (rake, console, the Rails runner), where a
|
|
15
|
-
# background listener thread is pure overhead.
|
|
16
|
-
module FileWatcher
|
|
17
|
-
# Coalesce bursts: a branch switch touches hundreds of files, and each one
|
|
18
|
-
# would otherwise be its own broadcast.
|
|
19
|
-
DEBOUNCE_SECONDS = 0.3
|
|
20
|
-
|
|
21
|
-
class << self
|
|
22
|
-
def available?
|
|
23
|
-
return @available if defined?(@available)
|
|
24
|
-
|
|
25
|
-
@available = begin
|
|
26
|
-
require "listen"
|
|
27
|
-
true
|
|
28
|
-
rescue LoadError
|
|
29
|
-
false
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def running?
|
|
34
|
-
!@listener.nil?
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Boot entry point. Confined to the environments the editor is allowed in
|
|
38
|
-
# and to processes that actually serve requests — a rake task or console
|
|
39
|
-
# has no client to broadcast to, and a listener thread there would only
|
|
40
|
-
# burn file handles. MBEDITOR_FORCE_WATCH overrides the process check for
|
|
41
|
-
# unusual servers and for tests.
|
|
42
|
-
def start_if_enabled
|
|
43
|
-
cfg = Mbeditor.configuration
|
|
44
|
-
return false if cfg.watch_files == false
|
|
45
|
-
return false unless cfg.allowed_environments.map(&:to_s).include?(Rails.env.to_s)
|
|
46
|
-
return false unless serving_requests?
|
|
47
|
-
|
|
48
|
-
start(cfg.workspace_root.presence || Rails.root.to_s)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Returns true when a watcher was started, false for every reason not to
|
|
52
|
-
# (gem absent, already running, no workspace, disabled by config).
|
|
53
|
-
def start(root)
|
|
54
|
-
return false unless available?
|
|
55
|
-
return false if running?
|
|
56
|
-
|
|
57
|
-
root = root.to_s
|
|
58
|
-
return false if root.empty? || !File.directory?(root)
|
|
59
|
-
|
|
60
|
-
ignores = ignore_patterns(root)
|
|
61
|
-
@listener = ::Listen.to(root, ignore: ignores, latency: DEBOUNCE_SECONDS) do |modified, added, removed|
|
|
62
|
-
broadcast(root, modified + added + removed)
|
|
63
|
-
end
|
|
64
|
-
@listener.start
|
|
65
|
-
Rails.logger.info("[mbeditor] watching #{root} for external changes")
|
|
66
|
-
true
|
|
67
|
-
rescue StandardError => e
|
|
68
|
-
# A watcher that cannot start must never take the host app down with it:
|
|
69
|
-
# inotify limits on Linux, permission issues, an unreadable root.
|
|
70
|
-
Rails.logger.warn("[mbeditor] file watcher failed to start: #{e.class}: #{e.message}")
|
|
71
|
-
@listener = nil
|
|
72
|
-
false
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def stop
|
|
76
|
-
@listener&.stop
|
|
77
|
-
rescue StandardError
|
|
78
|
-
nil
|
|
79
|
-
ensure
|
|
80
|
-
@listener = nil
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
private
|
|
84
|
-
|
|
85
|
-
def serving_requests?
|
|
86
|
-
return true if ENV["MBEDITOR_FORCE_WATCH"]
|
|
87
|
-
|
|
88
|
-
defined?(Rails::Server) || defined?(Puma::Server) || defined?(Unicorn) || defined?(Passenger)
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# `listen` matches ignores against paths relative to the watched root, so
|
|
92
|
-
# the configured exclusions become anchored regexps. Escaping matters:
|
|
93
|
-
# entries like "vendor/bundle" and "public/assets" contain separators, and
|
|
94
|
-
# a stray metacharacter in host config should not build a bogus pattern.
|
|
95
|
-
def ignore_patterns(root)
|
|
96
|
-
Array(Mbeditor.configuration.excluded_paths).map(&:to_s).reject(&:empty?).map do |path|
|
|
97
|
-
%r{\A#{Regexp.escape(path.delete_prefix("/").delete_suffix("/"))}(/|\z)}
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Paths arrive absolute. Anything that does not sit under the workspace
|
|
102
|
-
# is dropped rather than sent raw: the client keys everything by
|
|
103
|
-
# workspace-relative path, and an absolute one would leak host layout.
|
|
104
|
-
def relative_paths(root, paths)
|
|
105
|
-
paths.filter_map do |path|
|
|
106
|
-
rel = path.to_s.delete_prefix("#{root}/")
|
|
107
|
-
rel unless rel.empty? || rel == path.to_s
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def broadcast(root, paths)
|
|
112
|
-
relative = relative_paths(root, paths)
|
|
113
|
-
|
|
114
|
-
invalidate_caches(root)
|
|
115
|
-
return unless defined?(ActionCable.server)
|
|
116
|
-
|
|
117
|
-
payload = { type: "files_changed" }
|
|
118
|
-
payload[:paths] = relative.first(200) if relative.any?
|
|
119
|
-
ActionCable.server.broadcast("mbeditor_editor", payload)
|
|
120
|
-
rescue StandardError => e
|
|
121
|
-
Rails.logger.warn("[mbeditor] file watcher broadcast failed: #{e.class}: #{e.message}")
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# Mirrors EditorsController#broadcast_files_changed: a change the editor
|
|
125
|
-
# did not make invalidates exactly the same caches as one it did.
|
|
126
|
-
def invalidate_caches(root)
|
|
127
|
-
FileTreeService.invalidate(root)
|
|
128
|
-
SearchReplaceService.invalidate_cache(root)
|
|
129
|
-
JsGlobalsService.invalidate(root)
|
|
130
|
-
GitInfoService.invalidate(root)
|
|
131
|
-
rescue StandardError => e
|
|
132
|
-
Rails.logger.warn("[mbeditor] file watcher cache invalidation failed: #{e.class}: #{e.message}")
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
end
|