mbeditor 0.10.1 → 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: 47a8199a082957d396dce3cfa9de010ece11a11b2b938cf0cf81adfa6dd171f1
4
- data.tar.gz: 30dfee949bc6d06245c6bd1ba5b682431b36b064e0ad510b0dac44b320c5a287
3
+ metadata.gz: 3a664c7e61d20ca986be983f370ade96b639796633533373068221f45ffdbf9e
4
+ data.tar.gz: d9d6f92fa563330c47bc94f080432fa1e38aeddd4449b5b37f473d0555f513c5
5
5
  SHA512:
6
- metadata.gz: 81c342ce0dc3f80c44259ae3a130710013dcb81f45052d4cc4488d2a7dc112f7decc6b5d67f3f6aaa81cbcd7ef391d82136ee1adccdd369b369c7cfcd4d6afa7
7
- data.tar.gz: b201dda9b9fe55e9f44981725788b1587bc052f940652e8eb7d86a8dcc3d17c45bb0984f71cd3bbbd9f3a001b3b0125ac09a9e53eedd3e580eab96a445be6a17
6
+ metadata.gz: 0c5ed1f9375cb92b32b74fb86f76b0f98a10646e6498407b89be7b4a07f4ab4105d0bb207ce40746b33f3f34a9eaaf4ee3de16f1816a378d16b0d23f70adac04
7
+ data.tar.gz: 2813e2df0341112f437241f7c27537ad03f5e66da90fa756fba95647855ac6d0885421a219a0baf392e9aedcaa5c6c86e9fd4bcffacae5337ca4a8ee899e8ecb
data/CHANGELOG.md CHANGED
@@ -5,6 +5,66 @@ 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
+
8
68
  ## [0.10.1] - 2026-07-27
9
69
 
10
70
  ### Removed
data/README.md CHANGED
@@ -77,6 +77,10 @@ Mbeditor.configure do |config|
77
77
  # config.ruby_def_include_dirs = %w[app/models app/controllers app/helpers app/concerns]
78
78
  # config.related_files_custom_paths = %w[app/assets/javascripts/app app/policies]
79
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
+
80
84
  # Resilient routing (see the "Resilient Routing" section below)
81
85
  # config.mount_path = "/mbeditor" # explicit prefix override; auto-detected when nil
82
86
  # config.resilient_routing = false # escape hatch; true keeps the editor up when host routes break
@@ -95,6 +99,8 @@ end
95
99
  | `search_timeout` | `15` | Wall-clock bound on project-search subprocesses; a tripped deadline returns the partial results collected so far. `nil` disables. |
96
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. |
97
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. |
98
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. |
99
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. |
100
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. |
@@ -141,6 +147,73 @@ See [Resilient Routing](#resilient-routing) for details.
141
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. |
142
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. |
143
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
+
144
217
  ## Test Runner
145
218
 
146
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:
@@ -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) {
@@ -5106,6 +5124,25 @@ var MbeditorApp = function MbeditorApp() {
5106
5124
  React.createElement("i", { className: "fas fa-stream" }),
5107
5125
  " Logs"
5108
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
+ ),
5109
5146
  activeEOL && React.createElement(
5110
5147
  "button",
5111
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,6 +9,7 @@ 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
15
  :search_respect_gitignore
@@ -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
@@ -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.1"
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.1
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