mbeditor 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +153 -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 +911 -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 +517 -111
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +52 -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 +465 -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/lsp_diagnostics_translator.rb +99 -5
  37. data/app/services/mbeditor/model_graph_service.rb +232 -0
  38. data/app/services/mbeditor/presence_registry.rb +83 -0
  39. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  40. data/app/services/mbeditor/search_replace_service.rb +24 -4
  41. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  42. data/lib/mbeditor/configuration.rb +33 -3
  43. data/lib/mbeditor/engine.rb +34 -0
  44. data/lib/mbeditor/exception_log.rb +84 -0
  45. data/lib/mbeditor/route_map.rb +5 -0
  46. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  47. data/lib/mbeditor/version.rb +1 -1
  48. data/lib/mbeditor.rb +1 -0
  49. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  50. metadata +15 -2
@@ -275,24 +275,110 @@
275
275
  // error, server-side fallback signal, or an empty result. A 422 means the
276
276
  // server has decided ruby-lsp is unavailable — flip the flag off so we stop
277
277
  // asking.
278
- 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');
317
+ try {
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) {
279
356
  try {
280
- if (!window.MBEDITOR_RUBY_LSP_AVAILABLE) return Promise.resolve(null);
357
+ if (!window.MBEDITOR_RUBY_LSP_AVAILABLE || lspBackedOff()) return Promise.resolve(null);
281
358
  if (typeof FileService === 'undefined' || !FileService.rubyLspRequest) return Promise.resolve(null);
282
359
  if (!model || !model._mbeditorPath || !position) return Promise.resolve(null);
283
360
  if (model.getValueLength() > 5 * 1024 * 1024) return Promise.resolve(null);
284
- 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)
285
364
  .then(function (data) {
286
- 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
+ }
287
371
  if (lspMethod === 'definition') return (data.results && data.results.length) ? data : null;
288
372
  if (lspMethod === 'hover') return data.markdown ? data : null;
289
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;
290
378
  return null;
291
379
  })
292
380
  .catch(function (err) {
293
- if (err && err.response && err.response.status === 422) {
294
- window.MBEDITOR_RUBY_LSP_AVAILABLE = false;
295
- }
381
+ noteLspFailure(err);
296
382
  return null;
297
383
  });
298
384
  } catch (e) {
@@ -563,8 +649,6 @@
563
649
  var suppressInternalEdit = false;
564
650
  var keydownDisposable = null;
565
651
  var emmetTabDisposable = null;
566
- var gotoMouseDisposable = null;
567
- var gotoActionDisposable = null;
568
652
  var jsGotoMouseDisposable = null;
569
653
  var jsGotoActionDisposable = null;
570
654
 
@@ -665,84 +749,9 @@
665
749
  event.stopPropagation();
666
750
  });
667
751
 
668
- // Navigate to a Ruby symbol: ruby-lsp first when available (accurate,
669
- // position-aware), else modules/classes go to their definition file and
670
- // lowercase symbols go to their def line via the grep/Ripper services.
671
- function legacyNavigateToWord(word) {
672
- if (/^[A-Z]/.test(word) && typeof FileService !== 'undefined' && FileService.getModuleMembers) {
673
- FileService.getModuleMembers(word).then(function(data) {
674
- if (!data || !data.file) return;
675
- var filename = data.file.split('/').pop();
676
- if (typeof TabManager !== 'undefined' && TabManager.openTab) {
677
- TabManager.openTab(data.file, filename, 1);
678
- }
679
- }).catch(function() {});
680
- return;
681
- }
682
- if (typeof FileService === 'undefined' || !FileService.getDefinition) return;
683
- FileService.getDefinition(word, 'ruby').then(function(data) {
684
- var results = data && Array.isArray(data.results) ? data.results : [];
685
- if (results.length === 0) return;
686
- var r = results[0];
687
- if (typeof TabManager !== 'undefined' && TabManager.openTab) {
688
- TabManager.openTab(r.file, r.file.split('/').pop(), r.line);
689
- }
690
- }).catch(function() {});
691
- }
692
-
693
- function navigateToWord(word, position) {
694
- if (isErbDoc) return legacyNavigateToWord(word);
695
-
696
- tryRubyLsp('definition', model, position).then(function (lsp) {
697
- if (lsp && lsp.results && lsp.results.length) {
698
- var r = lsp.results[0];
699
- if (typeof TabManager !== 'undefined' && TabManager.openTab) {
700
- TabManager.openTab(r.file, r.file.split('/').pop(), r.line);
701
- }
702
- return;
703
- }
704
- legacyNavigateToWord(word);
705
- });
706
- }
707
-
708
- // Ctrl/Cmd+click — navigate to definition
709
- gotoMouseDisposable = editor.onMouseDown(function(event) {
710
- var ctrlOrCmd = event.event.ctrlKey || event.event.metaKey;
711
- if (!ctrlOrCmd) return;
712
- // Target type 6 = CONTENT_TEXT in Monaco's MouseTargetType enum
713
- if (!event.target || event.target.type !== 6) return;
714
-
715
- var position = event.target.position;
716
- if (!position) return;
717
-
718
- var wordInfo = model.getWordAtPosition(position);
719
- if (!wordInfo || !wordInfo.word || wordInfo.word.length < 2) return;
720
- if (RUBY_KEYWORDS[wordInfo.word]) return;
721
- if (RUBY_CORE_METHODS[wordInfo.word]) return;
722
- if (isErbDoc && (!rubyContextAt(position) || RAILS_VIEW_HELPERS[wordInfo.word])) return;
723
-
724
- event.event.preventDefault();
725
- navigateToWord(wordInfo.word, position);
726
- });
727
-
728
- // F12 — go to definition from keyboard
729
- gotoActionDisposable = editor.addAction({
730
- id: 'mbeditor.gotoRubyDefinition',
731
- label: 'Go to Ruby Definition',
732
- keybindings: [window.monaco.KeyCode.F12],
733
- contextMenuGroupId: 'navigation',
734
- contextMenuOrder: 1.5,
735
- run: function(ed) {
736
- var pos = ed.getPosition();
737
- if (!pos) return;
738
- var wordInfo = model.getWordAtPosition(pos);
739
- if (!wordInfo || !wordInfo.word || wordInfo.word.length < 2) return;
740
- if (RUBY_KEYWORDS[wordInfo.word]) return;
741
- if (RUBY_CORE_METHODS[wordInfo.word]) return;
742
- if (isErbDoc && (!rubyContextAt(pos) || RAILS_VIEW_HELPERS[wordInfo.word])) return;
743
- navigateToWord(wordInfo.word, pos);
744
- }
745
- });
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.
746
755
  }
747
756
 
748
757
  if (language === 'javascript') {
@@ -811,8 +820,6 @@
811
820
  dispose: function dispose() {
812
821
  if (keydownDisposable) keydownDisposable.dispose();
813
822
  if (emmetTabDisposable) emmetTabDisposable.dispose();
814
- if (gotoMouseDisposable) gotoMouseDisposable.dispose();
815
- if (gotoActionDisposable) gotoActionDisposable.dispose();
816
823
  if (jsGotoMouseDisposable) jsGotoMouseDisposable.dispose();
817
824
  if (jsGotoActionDisposable) jsGotoActionDisposable.dispose();
818
825
  contentDisposable.dispose();
@@ -1033,6 +1040,24 @@
1033
1040
  return JS_KEEP_CODES[code] === true || JS_SYNTAX_CODE.test(code);
1034
1041
  }
1035
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
+
1036
1061
  var _severityPatchActive = false;
1037
1062
  monaco.editor.onDidChangeMarkers(function(uris) {
1038
1063
  if (_severityPatchActive) return;
@@ -1049,14 +1074,17 @@
1049
1074
  var patched = markers.filter(function(m) {
1050
1075
  return entry.keep ? entry.keep(m) : true;
1051
1076
  }).map(function(m) {
1052
- return (m.severity === monaco.MarkerSeverity.Error && entry.warn[String(m.code)])
1053
- ? Object.assign({}, m, { severity: monaco.MarkerSeverity.Warning })
1054
- : 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 });
1055
1083
  });
1056
1084
  // Re-applying an unchanged set would re-enter this handler
1057
1085
  // forever, so only write when the patch actually changed something.
1058
1086
  var changed = patched.length !== markers.length || patched.some(function(m, i) {
1059
- return m.severity !== markers[i].severity;
1087
+ return m.severity !== markers[i].severity || m.message !== markers[i].message;
1060
1088
  });
1061
1089
  if (changed) monaco.editor.setModelMarkers(model, entry.owner, patched);
1062
1090
  });
@@ -1450,44 +1478,87 @@
1450
1478
 
1451
1479
  // RuboCop quick-fix code-action provider for Ruby files.
1452
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.
1453
1491
  monaco.languages.registerCodeActionProvider('ruby', {
1454
1492
  provideCodeActions: function provideCodeActions(model, _range, context) {
1455
1493
  if (!window.MBEDITOR_RUBOCOP_AVAILABLE) return { actions: [], dispose: function() {} };
1456
1494
 
1457
1495
  var correctableCops = model._mbeditorCorrectableCops || new Set();
1458
- var rubocopMarkers = context.markers.filter(function(m) {
1459
- return m.source === 'rubocop' && m.code && correctableCops.has(m.code);
1460
- });
1461
-
1462
- if (rubocopMarkers.length === 0) return { actions: [], dispose: function() {} };
1463
-
1496
+ var embedded = model._mbeditorFixes || {};
1464
1497
  var modelPath = model._mbeditorPath || null;
1465
- if (!modelPath) return { actions: [], dispose: function() {} };
1466
-
1467
- 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
+ }
1468
1529
 
1469
- var actions = rubocopMarkers.map(function(marker) {
1470
- return {
1471
- title: 'Fix: ' + marker.code,
1530
+ if (!cop || !correctableCops.has(cop) || !modelPath) return;
1531
+ actions.push({
1532
+ title: 'Fix: ' + cop,
1472
1533
  kind: 'quickfix',
1473
- isPreferred: rubocopMarkers.length === 1,
1474
1534
  diagnostics: [marker],
1475
1535
  command: {
1476
1536
  id: 'mbeditor.applyRubocopFix',
1477
- title: 'Apply RuboCop fix for ' + marker.code,
1478
- arguments: [model, marker, code, modelPath]
1537
+ title: 'Apply RuboCop fix for ' + cop,
1538
+ arguments: [model, cop, model.getValue(), modelPath]
1479
1539
  }
1480
- };
1540
+ });
1481
1541
  });
1482
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
+
1483
1554
  return { actions: actions, dispose: function() {} };
1484
1555
  }
1485
1556
  });
1486
1557
 
1487
1558
  // Command handler that fetches the fix from the backend and applies it.
1488
- monaco.editor.registerCommand('mbeditor.applyRubocopFix', function(_accessor, model, marker, code, modelPath) {
1559
+ monaco.editor.registerCommand('mbeditor.applyRubocopFix', function(_accessor, model, copName, code, modelPath) {
1489
1560
  if (typeof FileService === 'undefined' || !FileService.quickFixOffense) return;
1490
- FileService.quickFixOffense(modelPath, code, marker.code).then(function(data) {
1561
+ FileService.quickFixOffense(modelPath, code, copName).then(function(data) {
1491
1562
  if (!data || !data.fix) return;
1492
1563
  var fix = data.fix;
1493
1564
  model.pushEditOperations([], [{
@@ -1505,6 +1576,333 @@
1505
1576
  TabManager.openTab(path, String(path).split('/').pop(), line || 1);
1506
1577
  });
1507
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
+
1508
1906
  // Ruby method definition hover provider.
1509
1907
  // Calls the backend /definition endpoint (Ripper-based) and renders
1510
1908
  // the method signature and any preceding # comments as hover markdown.
@@ -1943,6 +2341,14 @@
1943
2341
  window.MbeditorEditorPlugins = {
1944
2342
  registerGlobalExtensions: registerGlobalExtensions,
1945
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,
1946
2352
  // Exposed so the ERB gating can be asserted directly in system tests.
1947
2353
  isInsideErbTag: isInsideErbTag,
1948
2354
  runRubyEnter: function runRubyEnter(editor) {