mbeditor 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +191 -0
  3. data/README.md +226 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +948 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +661 -140
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +68 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +273 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +481 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/js_globals_service.rb +31 -2
  37. data/app/services/mbeditor/js_program_service.rb +173 -0
  38. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  39. data/app/services/mbeditor/model_graph_service.rb +232 -0
  40. data/app/services/mbeditor/presence_registry.rb +83 -0
  41. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  42. data/app/services/mbeditor/search_replace_service.rb +24 -4
  43. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  44. data/lib/mbeditor/configuration.rb +43 -3
  45. data/lib/mbeditor/engine.rb +34 -0
  46. data/lib/mbeditor/exception_log.rb +84 -0
  47. data/lib/mbeditor/route_map.rb +6 -0
  48. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  49. data/lib/mbeditor/version.rb +1 -1
  50. data/lib/mbeditor.rb +1 -0
  51. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  52. metadata +16 -2
@@ -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
@@ -157,24 +275,110 @@
157
275
  // error, server-side fallback signal, or an empty result. A 422 means the
158
276
  // server has decided ruby-lsp is unavailable — flip the flag off so we stop
159
277
  // asking.
160
- function tryRubyLsp(lspMethod, model, position) {
278
+ // A Monaco marker's `code` is either a plain string or, when the backend
279
+ // supplied a docs URL, a { value, target } object. Everything that compares
280
+ // or displays a cop name has to go through this.
281
+ function codeValue(code) {
282
+ if (code && typeof code === 'object') return code.value || '';
283
+ return code || '';
284
+ }
285
+
286
+ // Identifies a marker across both the backend shape (copName/startLine) and
287
+ // the Monaco shape (code/startLineNumber), so the code-action provider can
288
+ // find the fixes belonging to the marker it was handed. Cop name alone won't
289
+ // do — the same cop fires many times in a file.
290
+ function markerFixKey(m) {
291
+ var cop = m.copName !== undefined ? m.copName : codeValue(m.code);
292
+ var line = m.startLine !== undefined ? m.startLine : m.startLineNumber;
293
+ var col = m.startCol !== undefined ? m.startCol : m.startColumn;
294
+ return cop + ':' + line + ':' + col;
295
+ }
296
+
297
+ // How long a failure keeps us off ruby-lsp. Long enough that a dead server
298
+ // isn't hammered on every keystroke, short enough that a server which comes
299
+ // back on its own is picked up without a page reload — which is what the old
300
+ // permanent flag flip cost you.
301
+ var LSP_BACKOFF_MS = 60000;
302
+
303
+ // Single owner of the "ruby-lsp is unwell" state. Everything that talks to
304
+ // the bridge routes its failures here so the status indicator and the
305
+ // request guard can never disagree.
306
+ function noteLspFailure(err) {
307
+ var status = err && err.response && err.response.status;
308
+ var data = (err && err.response && err.response.data) || (err && err.lspData) || {};
309
+ // A 422 means the server has decided ruby-lsp isn't there at all; a
310
+ // 'failed' state means it crashed past its restart budget. Both are worth
311
+ // backing off from. An ordinary timeout is not.
312
+ if (status !== 422 && data.lspState !== 'failed') return;
313
+
314
+ window.MBEDITOR_RUBY_LSP_DISABLED_UNTIL = Date.now() + LSP_BACKOFF_MS;
315
+ window.MBEDITOR_RUBY_LSP_REASON = data.reason || data.error ||
316
+ (data.lspState === 'failed' ? 'ruby-lsp crashed repeatedly' : 'ruby-lsp is unavailable');
161
317
  try {
162
- if (!window.MBEDITOR_RUBY_LSP_AVAILABLE) return Promise.resolve(null);
318
+ window.dispatchEvent(new CustomEvent('mbeditor:lsp-health'));
319
+ } catch (e) { /* CustomEvent unavailable — the guard still works */ }
320
+ }
321
+
322
+ function lspBackedOff() {
323
+ return Date.now() < (window.MBEDITOR_RUBY_LSP_DISABLED_UNTIL || 0);
324
+ }
325
+
326
+ // Raw-passthrough LSP methods keep their 0-based ranges on the wire; these
327
+ // two put them back into Monaco's 1-based world at the provider.
328
+ function lspRange(r) {
329
+ if (!r) return new window.monaco.Range(1, 1, 1, 1);
330
+ return new window.monaco.Range(
331
+ (r.start && r.start.line || 0) + 1,
332
+ (r.start && r.start.character || 0) + 1,
333
+ (r.end && r.end.line || r.start && r.start.line || 0) + 1,
334
+ (r.end && r.end.character || r.start && r.start.character || 0) + 1
335
+ );
336
+ }
337
+
338
+ // The backend rewrites every in-workspace file:// URI to a relative path and
339
+ // drops the rest, so what arrives here is always workspace-relative. Monaco
340
+ // needs an absolute-looking URI, and registerEditorOpener maps it back.
341
+ function lspUri(relativePath) {
342
+ return window.monaco.Uri.parse('file:///' + String(relativePath).replace(/^\/+/, ''));
343
+ }
344
+
345
+ // Send a raw-passthrough request and hand back result.result, or null when
346
+ // the legacy path should run instead.
347
+ function rawRubyLsp(lspMethod, model, position) {
348
+ return tryRubyLsp(lspMethod, model, position || { lineNumber: 1, column: 1 });
349
+ }
350
+
351
+ // Requests that go through RuboCop rather than just Prism need more than the
352
+ // 6s default: the server's own budget for them is 10s.
353
+ var LSP_SLOW_METHODS = { diagnostics: 15000, formatting: 15000 };
354
+
355
+ function tryRubyLsp(lspMethod, model, position, extraBody) {
356
+ try {
357
+ if (!window.MBEDITOR_RUBY_LSP_AVAILABLE || lspBackedOff()) return Promise.resolve(null);
163
358
  if (typeof FileService === 'undefined' || !FileService.rubyLspRequest) return Promise.resolve(null);
164
359
  if (!model || !model._mbeditorPath || !position) return Promise.resolve(null);
165
360
  if (model.getValueLength() > 5 * 1024 * 1024) return Promise.resolve(null);
166
- return FileService.rubyLspRequest(lspMethod, model._mbeditorPath, model.getValue(), position.lineNumber, position.column)
361
+ var config = LSP_SLOW_METHODS[lspMethod] ? { timeout: LSP_SLOW_METHODS[lspMethod] } : null;
362
+ return FileService.rubyLspRequest(lspMethod, model._mbeditorPath, model.getValue(),
363
+ position.lineNumber, position.column, config, extraBody)
167
364
  .then(function (data) {
168
- if (!data || data.fallback || data.error) return null;
365
+ if (!data || data.fallback || data.error) {
366
+ // A 200 can still carry lspState: 'failed' — the server answered,
367
+ // the language server did not.
368
+ if (data) noteLspFailure({ lspData: data });
369
+ return null;
370
+ }
169
371
  if (lspMethod === 'definition') return (data.results && data.results.length) ? data : null;
170
372
  if (lspMethod === 'hover') return data.markdown ? data : null;
171
373
  if (lspMethod === 'completion') return (data.suggestions && data.suggestions.length) ? data : null;
374
+ // Raw-passthrough methods answer with { result: <LSP JSON> }. An
375
+ // empty array is a real answer ("no references here"), not a reason
376
+ // to fall back, so only a missing key counts as nothing.
377
+ if (data.result !== undefined) return data.result;
172
378
  return null;
173
379
  })
174
380
  .catch(function (err) {
175
- if (err && err.response && err.response.status === 422) {
176
- window.MBEDITOR_RUBY_LSP_AVAILABLE = false;
177
- }
381
+ noteLspFailure(err);
178
382
  return null;
179
383
  });
180
384
  } catch (e) {
@@ -445,8 +649,6 @@
445
649
  var suppressInternalEdit = false;
446
650
  var keydownDisposable = null;
447
651
  var emmetTabDisposable = null;
448
- var gotoMouseDisposable = null;
449
- var gotoActionDisposable = null;
450
652
  var jsGotoMouseDisposable = null;
451
653
  var jsGotoActionDisposable = null;
452
654
 
@@ -547,84 +749,9 @@
547
749
  event.stopPropagation();
548
750
  });
549
751
 
550
- // Navigate to a Ruby symbol: ruby-lsp first when available (accurate,
551
- // position-aware), else modules/classes go to their definition file and
552
- // lowercase symbols go to their def line via the grep/Ripper services.
553
- function legacyNavigateToWord(word) {
554
- if (/^[A-Z]/.test(word) && typeof FileService !== 'undefined' && FileService.getModuleMembers) {
555
- FileService.getModuleMembers(word).then(function(data) {
556
- if (!data || !data.file) return;
557
- var filename = data.file.split('/').pop();
558
- if (typeof TabManager !== 'undefined' && TabManager.openTab) {
559
- TabManager.openTab(data.file, filename, 1);
560
- }
561
- }).catch(function() {});
562
- return;
563
- }
564
- if (typeof FileService === 'undefined' || !FileService.getDefinition) return;
565
- FileService.getDefinition(word, 'ruby').then(function(data) {
566
- var results = data && Array.isArray(data.results) ? data.results : [];
567
- if (results.length === 0) return;
568
- var r = results[0];
569
- if (typeof TabManager !== 'undefined' && TabManager.openTab) {
570
- TabManager.openTab(r.file, r.file.split('/').pop(), r.line);
571
- }
572
- }).catch(function() {});
573
- }
574
-
575
- function navigateToWord(word, position) {
576
- if (isErbDoc) return legacyNavigateToWord(word);
577
-
578
- tryRubyLsp('definition', model, position).then(function (lsp) {
579
- if (lsp && lsp.results && lsp.results.length) {
580
- var r = lsp.results[0];
581
- if (typeof TabManager !== 'undefined' && TabManager.openTab) {
582
- TabManager.openTab(r.file, r.file.split('/').pop(), r.line);
583
- }
584
- return;
585
- }
586
- legacyNavigateToWord(word);
587
- });
588
- }
589
-
590
- // Ctrl/Cmd+click — navigate to definition
591
- gotoMouseDisposable = editor.onMouseDown(function(event) {
592
- var ctrlOrCmd = event.event.ctrlKey || event.event.metaKey;
593
- if (!ctrlOrCmd) return;
594
- // Target type 6 = CONTENT_TEXT in Monaco's MouseTargetType enum
595
- if (!event.target || event.target.type !== 6) return;
596
-
597
- var position = event.target.position;
598
- if (!position) return;
599
-
600
- var wordInfo = model.getWordAtPosition(position);
601
- if (!wordInfo || !wordInfo.word || wordInfo.word.length < 2) return;
602
- if (RUBY_KEYWORDS[wordInfo.word]) return;
603
- if (RUBY_CORE_METHODS[wordInfo.word]) return;
604
- if (isErbDoc && (!rubyContextAt(position) || RAILS_VIEW_HELPERS[wordInfo.word])) return;
605
-
606
- event.event.preventDefault();
607
- navigateToWord(wordInfo.word, position);
608
- });
609
-
610
- // F12 — go to definition from keyboard
611
- gotoActionDisposable = editor.addAction({
612
- id: 'mbeditor.gotoRubyDefinition',
613
- label: 'Go to Ruby Definition',
614
- keybindings: [window.monaco.KeyCode.F12],
615
- contextMenuGroupId: 'navigation',
616
- contextMenuOrder: 1.5,
617
- run: function(ed) {
618
- var pos = ed.getPosition();
619
- if (!pos) return;
620
- var wordInfo = model.getWordAtPosition(pos);
621
- if (!wordInfo || !wordInfo.word || wordInfo.word.length < 2) return;
622
- if (RUBY_KEYWORDS[wordInfo.word]) return;
623
- if (RUBY_CORE_METHODS[wordInfo.word]) return;
624
- if (isErbDoc && (!rubyContextAt(pos) || RAILS_VIEW_HELPERS[wordInfo.word])) return;
625
- navigateToWord(wordInfo.word, pos);
626
- }
627
- });
752
+ // Go-to-definition is a global DefinitionProvider (registered in
753
+ // registerGlobalExtensions), not wired per editor. Monaco owns Ctrl+click,
754
+ // F12, Alt+F12 peek and the Ctrl+hover preview from that one registration.
628
755
  }
629
756
 
630
757
  if (language === 'javascript') {
@@ -693,8 +820,6 @@
693
820
  dispose: function dispose() {
694
821
  if (keydownDisposable) keydownDisposable.dispose();
695
822
  if (emmetTabDisposable) emmetTabDisposable.dispose();
696
- if (gotoMouseDisposable) gotoMouseDisposable.dispose();
697
- if (gotoActionDisposable) gotoActionDisposable.dispose();
698
823
  if (jsGotoMouseDisposable) jsGotoMouseDisposable.dispose();
699
824
  if (jsGotoActionDisposable) jsGotoActionDisposable.dispose();
700
825
  contentDisposable.dispose();
@@ -848,15 +973,22 @@
848
973
  );
849
974
  }
850
975
 
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);
976
+ // The workspace program (source files) plus the ambient globals it can't
977
+ // supply, loaded once now.
978
+ loadWorkspaceProgram(monaco);
979
+
980
+ // On a change: refresh just the touched files' program entries, and
981
+ // re-run the (cheap, cached) globals scan. The whole tree is never
982
+ // re-sent — see refreshProgramPaths.
854
983
  var refreshWorkspaceGlobals = function () { loadWorkspaceGlobals(monaco); };
855
984
  if (window._ && window._.debounce) {
856
985
  refreshWorkspaceGlobals = window._.debounce(refreshWorkspaceGlobals, 2000);
857
986
  }
858
987
  if (typeof WebSocketService !== 'undefined' && WebSocketService.onFilesChanged) {
859
- WebSocketService.onFilesChanged(function () { refreshWorkspaceGlobals(); });
988
+ WebSocketService.onFilesChanged(function (payload) {
989
+ if (payload && payload.paths) refreshProgramPaths(monaco, payload.paths);
990
+ refreshWorkspaceGlobals();
991
+ });
860
992
  }
861
993
  var _lastGlobalsFocusRefresh = Date.now();
862
994
  window.addEventListener('focus', function () {
@@ -908,6 +1040,24 @@
908
1040
  return JS_KEEP_CODES[code] === true || JS_SYNTAX_CODE.test(code);
909
1041
  }
910
1042
 
1043
+ // A 2304 whose span is an assignment TARGET (`foo = 1` with no
1044
+ // declaration anywhere) is an implicit global — code the host's babel
1045
+ // pipeline rejects — so it stays an Error, while read-side 2304s
1046
+ // downgrade to Warning (those are usually host globals the language
1047
+ // service can't see). Matches `=` (not `==`/`=>`) and compound
1048
+ // assignment operators after the flagged identifier.
1049
+ var JS_ASSIGN_AFTER = /^\s*(=(?![=>])|(\*\*|<<|>>>?|[+\-*/%&|^]|&&|\|\||\?\?)=)/;
1050
+ var JS_IMPLICIT_GLOBAL_HINT = ' This assignment creates an implicit global — declare the variable with var, let, or const.';
1051
+ function isUndeclaredAssignment(model, marker) {
1052
+ var rest = model.getValueInRange({
1053
+ startLineNumber: marker.endLineNumber,
1054
+ startColumn: marker.endColumn,
1055
+ endLineNumber: marker.endLineNumber,
1056
+ endColumn: model.getLineMaxColumn(marker.endLineNumber)
1057
+ });
1058
+ return JS_ASSIGN_AFTER.test(rest);
1059
+ }
1060
+
911
1061
  var _severityPatchActive = false;
912
1062
  monaco.editor.onDidChangeMarkers(function(uris) {
913
1063
  if (_severityPatchActive) return;
@@ -924,14 +1074,17 @@
924
1074
  var patched = markers.filter(function(m) {
925
1075
  return entry.keep ? entry.keep(m) : true;
926
1076
  }).map(function(m) {
927
- return (m.severity === monaco.MarkerSeverity.Error && entry.warn[String(m.code)])
928
- ? Object.assign({}, m, { severity: monaco.MarkerSeverity.Warning })
929
- : m;
1077
+ if (m.severity !== monaco.MarkerSeverity.Error || !entry.warn[String(m.code)]) return m;
1078
+ if (String(m.code) === '2304' && isUndeclaredAssignment(model, m)) {
1079
+ return m.message.indexOf(JS_IMPLICIT_GLOBAL_HINT) !== -1 ? m
1080
+ : Object.assign({}, m, { message: m.message + JS_IMPLICIT_GLOBAL_HINT });
1081
+ }
1082
+ return Object.assign({}, m, { severity: monaco.MarkerSeverity.Warning });
930
1083
  });
931
1084
  // Re-applying an unchanged set would re-enter this handler
932
1085
  // forever, so only write when the patch actually changed something.
933
1086
  var changed = patched.length !== markers.length || patched.some(function(m, i) {
934
- return m.severity !== markers[i].severity;
1087
+ return m.severity !== markers[i].severity || m.message !== markers[i].message;
935
1088
  });
936
1089
  if (changed) monaco.editor.setModelMarkers(model, entry.owner, patched);
937
1090
  });
@@ -956,17 +1109,7 @@
956
1109
  var sym = match[1];
957
1110
  if (attemptedJsGlobals[sym]) return;
958
1111
  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() {});
1112
+ queueJsGlobalLookup(sym, model._mbeditorPath);
970
1113
  });
971
1114
  });
972
1115
  }
@@ -1335,44 +1478,87 @@
1335
1478
 
1336
1479
  // RuboCop quick-fix code-action provider for Ruby files.
1337
1480
  // Only registers when RuboCop is available in the workspace.
1481
+ //
1482
+ // Two sources of fixes, preferred in this order:
1483
+ //
1484
+ // 1. Edits ruby-lsp embedded in the diagnostic itself. Applied directly as
1485
+ // a Monaco workspace edit — no request, no subprocess. This is also the
1486
+ // only source of the "Disable <cop> for this line" action, which
1487
+ // ruby-lsp offers even for cops that can never be autocorrected.
1488
+ // 2. The /quick_fix endpoint, which runs `rubocop -A` over the buffer.
1489
+ // Still needed whenever diagnostics came from the plain rubocop path
1490
+ // rather than ruby-lsp.
1338
1491
  monaco.languages.registerCodeActionProvider('ruby', {
1339
1492
  provideCodeActions: function provideCodeActions(model, _range, context) {
1340
1493
  if (!window.MBEDITOR_RUBOCOP_AVAILABLE) return { actions: [], dispose: function() {} };
1341
1494
 
1342
1495
  var correctableCops = model._mbeditorCorrectableCops || new Set();
1343
- var rubocopMarkers = context.markers.filter(function(m) {
1344
- return m.source === 'rubocop' && m.code && correctableCops.has(m.code);
1345
- });
1346
-
1347
- if (rubocopMarkers.length === 0) return { actions: [], dispose: function() {} };
1348
-
1496
+ var embedded = model._mbeditorFixes || {};
1349
1497
  var modelPath = model._mbeditorPath || null;
1350
- if (!modelPath) return { actions: [], dispose: function() {} };
1351
-
1352
- var code = model.getValue();
1498
+ var actions = [];
1499
+
1500
+ context.markers.forEach(function (marker) {
1501
+ if (marker.source !== 'rubocop') return;
1502
+
1503
+ var cop = codeValue(marker.code);
1504
+ var fixes = embedded[markerFixKey(marker)];
1505
+
1506
+ if (fixes && fixes.length) {
1507
+ fixes.forEach(function (fix) {
1508
+ actions.push({
1509
+ title: fix.title,
1510
+ kind: 'quickfix',
1511
+ autocorrect: /^Autocorrect/.test(fix.title),
1512
+ diagnostics: [marker],
1513
+ edit: {
1514
+ edits: fix.edits.map(function (e) {
1515
+ return {
1516
+ resource: model.uri,
1517
+ versionId: model.getVersionId(),
1518
+ textEdit: {
1519
+ range: new monaco.Range(e.startLine, e.startCol, e.endLine, e.endCol),
1520
+ text: e.text
1521
+ }
1522
+ };
1523
+ })
1524
+ }
1525
+ });
1526
+ });
1527
+ return;
1528
+ }
1353
1529
 
1354
- var actions = rubocopMarkers.map(function(marker) {
1355
- return {
1356
- title: 'Fix: ' + marker.code,
1530
+ if (!cop || !correctableCops.has(cop) || !modelPath) return;
1531
+ actions.push({
1532
+ title: 'Fix: ' + cop,
1357
1533
  kind: 'quickfix',
1358
- isPreferred: rubocopMarkers.length === 1,
1359
1534
  diagnostics: [marker],
1360
1535
  command: {
1361
1536
  id: 'mbeditor.applyRubocopFix',
1362
- title: 'Apply RuboCop fix for ' + marker.code,
1363
- arguments: [model, marker, code, modelPath]
1537
+ title: 'Apply RuboCop fix for ' + cop,
1538
+ arguments: [model, cop, model.getValue(), modelPath]
1364
1539
  }
1365
- };
1540
+ });
1366
1541
  });
1367
1542
 
1543
+ // Mark the autocorrect as preferred when it is the only one on offer,
1544
+ // matching what this provider did before embedded fixes existed. With
1545
+ // several offenses under the cursor there is no single obvious fix, so
1546
+ // nothing is preferred and the user picks from the list.
1547
+ // (Note: monaco's `editor.action.autoFix` ignores isPreferred in this
1548
+ // standalone build — verified against a bare probe provider — so this
1549
+ // only affects ordering in the lightbulb menu.)
1550
+ var autocorrects = actions.filter(function (a) { return a.autocorrect; });
1551
+ if (autocorrects.length === 1) autocorrects[0].isPreferred = true;
1552
+ actions.forEach(function (a) { delete a.autocorrect; });
1553
+
1368
1554
  return { actions: actions, dispose: function() {} };
1369
1555
  }
1370
1556
  });
1371
1557
 
1372
1558
  // Command handler that fetches the fix from the backend and applies it.
1373
- monaco.editor.registerCommand('mbeditor.applyRubocopFix', function(_accessor, model, marker, code, modelPath) {
1559
+ monaco.editor.registerCommand('mbeditor.applyRubocopFix', function(_accessor, model, copName, code, modelPath) {
1374
1560
  if (typeof FileService === 'undefined' || !FileService.quickFixOffense) return;
1375
- FileService.quickFixOffense(modelPath, code, marker.code).then(function(data) {
1561
+ FileService.quickFixOffense(modelPath, code, copName).then(function(data) {
1376
1562
  if (!data || !data.fix) return;
1377
1563
  var fix = data.fix;
1378
1564
  model.pushEditOperations([], [{
@@ -1390,6 +1576,333 @@
1390
1576
  TabManager.openTab(path, String(path).split('/').pop(), line || 1);
1391
1577
  });
1392
1578
 
1579
+ // ── ruby-lsp navigation ──────────────────────────────────────────────────
1580
+
1581
+ // Teaches Monaco how to open a file:// resource in this editor. Without it
1582
+ // every provider below can find a location but nothing can go there:
1583
+ // peek-definition, the references widget and Ctrl+hover previews all route
1584
+ // their "open this" through here.
1585
+ monaco.editor.registerEditorOpener({
1586
+ openCodeEditor: function (_source, resource, selectionOrPosition) {
1587
+ var path = String(resource.path || '').replace(/^\/+/, '');
1588
+ if (!path || typeof TabManager === 'undefined' || !TabManager.openTab) return false;
1589
+
1590
+ var pos = selectionOrPosition || {};
1591
+ var line = pos.startLineNumber || pos.lineNumber || 1;
1592
+ var col = pos.startColumn || pos.column || 1;
1593
+ TabManager.openTab(path, path.split('/').pop(), line, null, false, col);
1594
+ return true;
1595
+ }
1596
+ });
1597
+
1598
+ // Words never worth a definition lookup: language keywords, core methods,
1599
+ // and (in ERB) Rails view helpers that live in the framework rather than
1600
+ // the workspace. Guarding here rather than in the request means Ctrl+hover
1601
+ // over `end` doesn't light up as a link.
1602
+ function rubyNavigableWord(model, position, isErb) {
1603
+ var info = model.getWordAtPosition(position);
1604
+ if (!info || !info.word || info.word.length < 2) return null;
1605
+ if (RUBY_KEYWORDS[info.word] || RUBY_CORE_METHODS[info.word]) return null;
1606
+ if (isErb) {
1607
+ if (!isInsideErbTag(model, position)) return null;
1608
+ if (RAILS_VIEW_HELPERS[info.word]) return null;
1609
+ }
1610
+ return info.word;
1611
+ }
1612
+
1613
+ // Definition: ruby-lsp when it can answer, else the grep/Ripper services.
1614
+ // ERB always takes the legacy path — Prism cannot parse ERB.
1615
+ ['ruby', 'erb'].forEach(function (languageId) {
1616
+ var isErb = languageId === 'erb';
1617
+
1618
+ monaco.languages.registerDefinitionProvider(languageId, {
1619
+ provideDefinition: function provideDefinition(model, position) {
1620
+ var word = rubyNavigableWord(model, position, isErb);
1621
+ if (!word) return null;
1622
+
1623
+ var lsp = isErb ? Promise.resolve(null) : tryRubyLsp('definition', model, position);
1624
+ return lsp.then(function (data) {
1625
+ if (data && data.results && data.results.length) {
1626
+ return data.results.map(function (r) {
1627
+ var line = r.line || 1;
1628
+ return {
1629
+ uri: lspUri(r.file),
1630
+ range: new monaco.Range(line, r.col || 1, r.endLine || line, r.endCol || 1)
1631
+ };
1632
+ });
1633
+ }
1634
+ return legacyRubyDefinition(word);
1635
+ });
1636
+ }
1637
+ });
1638
+ });
1639
+
1640
+ // The pre-ruby-lsp lookup, now expressed as locations rather than as a
1641
+ // side-effecting "open a tab": constants resolve through /module_members,
1642
+ // everything else through the Ripper-backed /definition index.
1643
+ function legacyRubyDefinition(word) {
1644
+ if (typeof FileService === 'undefined') return null;
1645
+
1646
+ if (/^[A-Z]/.test(word) && FileService.getModuleMembers) {
1647
+ return FileService.getModuleMembers(word).then(function (data) {
1648
+ if (!data || !data.file) return null;
1649
+ return [{ uri: lspUri(data.file), range: new monaco.Range(1, 1, 1, 1) }];
1650
+ }).catch(function () { return null; });
1651
+ }
1652
+
1653
+ if (!FileService.getDefinition) return null;
1654
+ return FileService.getDefinition(word, 'ruby').then(function (data) {
1655
+ var results = (data && Array.isArray(data.results)) ? data.results : [];
1656
+ return results.map(function (r) {
1657
+ return { uri: lspUri(r.file), range: new monaco.Range(r.line || 1, 1, r.line || 1, 1) };
1658
+ });
1659
+ }).catch(function () { return null; });
1660
+ }
1661
+
1662
+ // References. No legacy equivalent exists — the workspace search panel is a
1663
+ // text grep, not a reference index — so an empty list is the honest answer
1664
+ // when ruby-lsp can't help.
1665
+ monaco.languages.registerReferenceProvider('ruby', {
1666
+ provideReferences: function provideReferences(model, position) {
1667
+ if (!rubyNavigableWord(model, position, false)) return null;
1668
+
1669
+ return rawRubyLsp('references', model, position).then(function (locations) {
1670
+ if (!Array.isArray(locations)) return null;
1671
+ return locations.filter(function (loc) { return loc && loc.uri; }).map(function (loc) {
1672
+ return { uri: lspUri(loc.uri), range: lspRange(loc.range) };
1673
+ });
1674
+ });
1675
+ }
1676
+ });
1677
+
1678
+ // Occurrences of the symbol under the cursor. Monaco has a built-in
1679
+ // word-match highlighter, but it cannot tell a local named `id` from a
1680
+ // method named `id`; ruby-lsp resolves the actual symbol.
1681
+ monaco.languages.registerDocumentHighlightProvider('ruby', {
1682
+ provideDocumentHighlights: function provideDocumentHighlights(model, position) {
1683
+ return rawRubyLsp('document_highlight', model, position).then(function (highlights) {
1684
+ if (!Array.isArray(highlights)) return null;
1685
+ return highlights.filter(Boolean).map(function (h) {
1686
+ return { range: lspRange(h.range), kind: h.kind };
1687
+ });
1688
+ });
1689
+ }
1690
+ });
1691
+
1692
+ // Document symbols feed Monaco's breadcrumbs, sticky scroll and
1693
+ // Ctrl+Shift+O — all of which are dark for Ruby today.
1694
+ //
1695
+ // This deliberately does NOT replace ruby_outline.js. That lexer supplies
1696
+ // method visibility and test-block (describe/it/test) entries which
1697
+ // ruby-lsp's documentSymbol does not emit, and the outline panel groups on
1698
+ // both. It also still has to cover ERB, HAML, and the case where ruby-lsp
1699
+ // isn't running.
1700
+ var LSP_SYMBOL_KINDS = {
1701
+ 1: 'File', 2: 'Module', 3: 'Namespace', 4: 'Package', 5: 'Class',
1702
+ 6: 'Method', 7: 'Property', 8: 'Field', 9: 'Constructor', 10: 'Enum',
1703
+ 11: 'Interface', 12: 'Function', 13: 'Variable', 14: 'Constant',
1704
+ 15: 'String', 16: 'Number', 17: 'Boolean', 18: 'Array', 19: 'Object',
1705
+ 20: 'Key', 21: 'Null', 22: 'EnumMember', 23: 'Struct', 24: 'Event',
1706
+ 25: 'Operator', 26: 'TypeParameter'
1707
+ };
1708
+
1709
+ function toMonacoSymbols(symbols, depth) {
1710
+ if (!Array.isArray(symbols) || depth > 16) return [];
1711
+ return symbols.filter(Boolean).map(function (s) {
1712
+ var full = lspRange(s.range);
1713
+ return {
1714
+ name: String(s.name || ''),
1715
+ detail: s.detail || '',
1716
+ kind: monaco.languages.SymbolKind[LSP_SYMBOL_KINDS[s.kind] || 'Variable'],
1717
+ tags: [],
1718
+ range: full,
1719
+ selectionRange: s.selectionRange ? lspRange(s.selectionRange) : full,
1720
+ children: toMonacoSymbols(s.children, depth + 1)
1721
+ };
1722
+ });
1723
+ }
1724
+
1725
+ monaco.languages.registerDocumentSymbolProvider('ruby', {
1726
+ displayName: 'Ruby',
1727
+ provideDocumentSymbols: function provideDocumentSymbols(model) {
1728
+ return rawRubyLsp('document_symbol', model).then(function (symbols) {
1729
+ if (!Array.isArray(symbols)) return null;
1730
+ return toMonacoSymbols(symbols, 0);
1731
+ });
1732
+ }
1733
+ });
1734
+
1735
+ // Formatting. Until now Ruby had no formatting provider at all, so
1736
+ // Shift+Alt+F and format-on-save silently did nothing — formatting was
1737
+ // reachable only through the toolbar button. /format stays as the fallback
1738
+ // for when ruby-lsp is unavailable.
1739
+ monaco.languages.registerDocumentFormattingEditProvider('ruby', {
1740
+ displayName: 'RuboCop',
1741
+ provideDocumentFormattingEdits: function provideDocumentFormattingEdits(model, options) {
1742
+ var opts = { tab_size: (options && options.tabSize) || 2,
1743
+ insert_spaces: !options || options.insertSpaces !== false };
1744
+
1745
+ return tryRubyLsp('formatting', model, { lineNumber: 1, column: 1 }, opts)
1746
+ .then(function (edits) {
1747
+ if (Array.isArray(edits)) {
1748
+ return edits.map(function (e) {
1749
+ return { range: lspRange(e.range), text: e.newText || '' };
1750
+ });
1751
+ }
1752
+ return legacyRubyFormat(model);
1753
+ });
1754
+ }
1755
+ });
1756
+
1757
+ // Pre-provider path: /format returns the whole corrected file, so it
1758
+ // becomes one replacement spanning the buffer.
1759
+ function legacyRubyFormat(model) {
1760
+ if (typeof FileService === 'undefined' || !FileService.formatFile) return [];
1761
+ if (!model._mbeditorPath) return [];
1762
+
1763
+ return FileService.formatFile(model._mbeditorPath, model.getValue()).then(function (data) {
1764
+ var formatted = data && data.content;
1765
+ if (typeof formatted !== 'string' || formatted === model.getValue()) return [];
1766
+ return [{ range: model.getFullModelRange(), text: formatted }];
1767
+ }).catch(function () { return []; });
1768
+ }
1769
+
1770
+ // Parameter hints while typing a call's arguments.
1771
+ monaco.languages.registerSignatureHelpProvider('ruby', {
1772
+ signatureHelpTriggerCharacters: ['(', ','],
1773
+ provideSignatureHelp: function provideSignatureHelp(model, position) {
1774
+ return rawRubyLsp('signature_help', model, position).then(function (help) {
1775
+ if (!help || !Array.isArray(help.signatures) || !help.signatures.length) return null;
1776
+ // LSP's SignatureHelp is structurally identical to Monaco's, and
1777
+ // MarkupContent {kind, value} is accepted where an IMarkdownString is.
1778
+ return { value: help, dispose: function () {} };
1779
+ });
1780
+ }
1781
+ });
1782
+
1783
+ // Smart expand/shrink selection (Shift+Alt+Right / Left).
1784
+ monaco.languages.registerSelectionRangeProvider('ruby', {
1785
+ provideSelectionRanges: function provideSelectionRanges(model, positions) {
1786
+ // LSP takes a list of positions and answers one linked list per
1787
+ // position; the bridge sends a single position, so ask per position and
1788
+ // flatten each chain into the array Monaco wants.
1789
+ return Promise.all(positions.map(function (position) {
1790
+ return rawRubyLsp('selection_range', model, position).then(function (result) {
1791
+ var node = Array.isArray(result) ? result[0] : null;
1792
+ var ranges = [];
1793
+ // Bounded: the chain comes from a subprocess and a cycle would spin.
1794
+ while (node && node.range && ranges.length < 64) {
1795
+ ranges.push({ range: lspRange(node.range) });
1796
+ node = node.parent;
1797
+ }
1798
+ return ranges;
1799
+ });
1800
+ })).then(function (perPosition) {
1801
+ return perPosition.some(function (r) { return r.length; }) ? perPosition : null;
1802
+ });
1803
+ }
1804
+ });
1805
+
1806
+ // F2 rename. ruby-lsp renames *constants only*, so resolveRenameLocation
1807
+ // declines anything else up front — otherwise F2 on a method name would
1808
+ // open the input box and then fail after you'd typed a new name.
1809
+ monaco.languages.registerRenameProvider('ruby', {
1810
+ resolveRenameLocation: function resolveRenameLocation(model, position) {
1811
+ return rawRubyLsp('prepare_rename', model, position).then(function (result) {
1812
+ if (!result) {
1813
+ return { rejectReason: 'Only Ruby constants can be renamed here.' };
1814
+ }
1815
+ // prepareRename answers either a bare Range or { range, placeholder }.
1816
+ var range = result.range || result;
1817
+ var wordInfo = model.getWordAtPosition(position);
1818
+ return {
1819
+ range: lspRange(range),
1820
+ text: result.placeholder || (wordInfo && wordInfo.word) || ''
1821
+ };
1822
+ });
1823
+ },
1824
+
1825
+ provideRenameEdits: function provideRenameEdits(model, position, newName) {
1826
+ if (typeof FileService === 'undefined' || !FileService.rubyRename) return null;
1827
+
1828
+ // Every path with a live model. The server returns their edits for us
1829
+ // to apply rather than writing them, so unsaved buffers survive.
1830
+ var openPaths = monaco.editor.getModels()
1831
+ .filter(function (m) { return m._mbeditorPath && !m.isDisposed(); })
1832
+ .map(function (m) { return m._mbeditorPath; });
1833
+
1834
+ return FileService.rubyRename(model._mbeditorPath, model.getValue(),
1835
+ position.lineNumber, position.column, newName, openPaths)
1836
+ .then(function (data) {
1837
+ var edits = [];
1838
+ Object.keys((data && data.edits) || {}).forEach(function (relPath) {
1839
+ var target = modelForPath(relPath) || null;
1840
+ data.edits[relPath].forEach(function (e) {
1841
+ edits.push({
1842
+ resource: target ? target.uri : lspUri(relPath),
1843
+ versionId: target ? target.getVersionId() : undefined,
1844
+ textEdit: {
1845
+ range: new monaco.Range(e.startLine, e.startCol, e.endLine, e.endCol),
1846
+ text: e.text
1847
+ }
1848
+ });
1849
+ });
1850
+ });
1851
+
1852
+ reportRenameOutcome(data);
1853
+ return { edits: edits };
1854
+ })
1855
+ .catch(function (err) {
1856
+ var message = (err && err.response && err.response.data && err.response.data.error) ||
1857
+ 'Rename failed.';
1858
+ return { edits: [], rejectReason: message };
1859
+ });
1860
+ }
1861
+ });
1862
+
1863
+ function modelForPath(relPath) {
1864
+ return monaco.editor.getModels().filter(function (m) {
1865
+ return m._mbeditorPath === relPath && !m.isDisposed();
1866
+ })[0];
1867
+ }
1868
+
1869
+ // Files written straight to disk leave no dirty tab and no undo entry, so
1870
+ // say how many were touched — otherwise a workspace-wide rename looks like
1871
+ // it only changed the file you were looking at.
1872
+ function reportRenameOutcome(data) {
1873
+ if (typeof EditorStore === 'undefined' || !EditorStore.setStatus) return;
1874
+
1875
+ var written = (data && data.written) || [];
1876
+ var rejected = (data && data.rejected) || [];
1877
+ var parts = [];
1878
+ if (written.length) parts.push(written.length + ' file' + (written.length === 1 ? '' : 's') + ' saved');
1879
+ if (rejected.length) parts.push(rejected.length + ' skipped (outside the workspace)');
1880
+ if (!parts.length) return;
1881
+
1882
+ EditorStore.setStatus('Renamed — ' + parts.join(', '), rejected.length ? 'warning' : 'success');
1883
+ }
1884
+
1885
+ // Real class/def/block/heredoc folding. Monaco merges the results of every
1886
+ // folding provider, so the vim-marker provider registered further down
1887
+ // keeps working alongside this one.
1888
+ monaco.languages.registerFoldingRangeProvider('ruby', {
1889
+ provideFoldingRanges: function provideFoldingRanges(model) {
1890
+ return rawRubyLsp('folding_range', model).then(function (ranges) {
1891
+ if (!Array.isArray(ranges)) return null;
1892
+ return ranges.filter(Boolean).map(function (r) {
1893
+ return {
1894
+ start: (r.startLine || 0) + 1,
1895
+ end: (r.endLine || 0) + 1,
1896
+ kind: r.kind === 'comment' ? monaco.languages.FoldingRangeKind.Comment
1897
+ : r.kind === 'imports' ? monaco.languages.FoldingRangeKind.Imports
1898
+ : r.kind === 'region' ? monaco.languages.FoldingRangeKind.Region
1899
+ : undefined
1900
+ };
1901
+ });
1902
+ });
1903
+ }
1904
+ });
1905
+
1393
1906
  // Ruby method definition hover provider.
1394
1907
  // Calls the backend /definition endpoint (Ripper-based) and renders
1395
1908
  // the method signature and any preceding # comments as hover markdown.
@@ -1828,6 +2341,14 @@
1828
2341
  window.MbeditorEditorPlugins = {
1829
2342
  registerGlobalExtensions: registerGlobalExtensions,
1830
2343
  attachEditorFeatures: attachEditorFeatures,
2344
+ // The one place that decides ruby-lsp is unwell. Anything outside this file
2345
+ // that talks to the bridge reports its failures here rather than writing
2346
+ // the window flags itself.
2347
+ noteLspFailure: noteLspFailure,
2348
+ lspBackedOff: lspBackedOff,
2349
+ // EditorPanel keys the embedded-fix side map with this; the code-action
2350
+ // provider reads it back. One definition so the two cannot drift.
2351
+ markerFixKey: markerFixKey,
1831
2352
  // Exposed so the ERB gating can be asserted directly in system tests.
1832
2353
  isInsideErbTag: isInsideErbTag,
1833
2354
  runRubyEnter: function runRubyEnter(editor) {