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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1fe258515a6f0945da8bfcbfbe85197262af224c054924367597d97d370530e2
4
- data.tar.gz: d3f65b3c351fef56291170d9401103d9b73d4b3a7ebad24885713b8deef5df85
3
+ metadata.gz: 3a664c7e61d20ca986be983f370ade96b639796633533373068221f45ffdbf9e
4
+ data.tar.gz: d9d6f92fa563330c47bc94f080432fa1e38aeddd4449b5b37f473d0555f513c5
5
5
  SHA512:
6
- metadata.gz: 4de0d8f9374a370f4df1339166ff648013dafb07b23eeba6936f3ebf1d492c831905d7143d5ece2ece4e397f4f4b314b7461e496cec00003c507db5701347604
7
- data.tar.gz: 17c4ef992175fc0a5fd8f18102214ac46cce75963ba7d06661f3ffac66eb7e7d55e69bef6c6f71e3ddb9999839c78fd9a2668abb99397442ac7bfb68da6ee561
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 when the file lands on disk and the server broadcasts it.
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, deletions, etc.)
1502
- // When an ActionCable WebSocket is connected this acts only as a safety-net fallback —
1503
- // the WebSocket push above handles immediate invalidation after mbeditor mutations.
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
- function addDiscoveredGlobal(name) {
111
- if (discoveredJsGlobals[name]) return;
112
- if (REACT_MINI_UMD_GLOBALS[name]) return; // already in the mini-UMD
113
- discoveredJsGlobals[name] = true;
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
- // Bulk ambient globals: fetch every top-level declaration in the workspace
122
- // (the Sprockets global scope — /js_globals greps var/function/class at
123
- // column 0 plus window.X assignments across *.js/*.jsx/*.js.jsx/...) and
124
- // declare them all in ONE extraLib. This proactively prevents TS2304
125
- // ("Cannot find name") for cross-file component references instead of the
126
- // reactive one-network-lookup-per-symbol path below, which stays as a
127
- // fallback for anything the static scan can't see.
128
- // Same-URI addExtraLib replaces content in place, which is the refresh
129
- // mechanism (called again on files_changed broadcasts).
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
- // Workspace-wide ambient globals, loaded once now and refreshed when
852
- // files change (debounced) or on refocus (throttled, for the no-WS case).
853
- loadWorkspaceGlobals(monaco);
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 () { refreshWorkspaceGlobals(); });
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
- var modelPath = model._mbeditorPath;
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
- break if symbols.length >= MAX_SYMBOLS
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(m[3].strip).each do |name, kind|
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, :watch_files
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
@@ -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?
@@ -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'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.0"
5
5
  end
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.10.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-27 00:00:00.000000000 Z
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