mbeditor 0.13.0 → 0.14.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -1
  3. data/app/assets/javascripts/mbeditor/application.js +3 -1
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +264 -22
  6. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
  7. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
  8. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
  9. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
  10. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  11. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +440 -334
  12. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  13. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  14. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  15. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  16. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
  17. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  18. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1322 -1431
  19. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  20. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +82 -72
  21. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +121 -81
  22. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  23. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  24. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  25. data/app/assets/javascripts/mbeditor/editor_plugins.js +694 -306
  26. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  27. data/app/assets/javascripts/mbeditor/file_service.js +46 -57
  28. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  29. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  30. data/app/assets/javascripts/mbeditor/search_service.js +29 -2
  31. data/app/assets/javascripts/mbeditor/tab_manager.js +135 -54
  32. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  33. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  34. data/app/assets/stylesheets/mbeditor/editor.css +762 -297
  35. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  36. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  37. data/app/channels/mbeditor/collaboration_channel.rb +25 -6
  38. data/app/controllers/mbeditor/application_controller.rb +26 -3
  39. data/app/controllers/mbeditor/editors_controller.rb +125 -465
  40. data/app/services/mbeditor/archive_service.rb +137 -0
  41. data/app/services/mbeditor/collaboration_doc_store.rb +180 -12
  42. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  43. data/app/services/mbeditor/editor_state_service.rb +14 -51
  44. data/app/services/mbeditor/file_history_service.rb +222 -0
  45. data/app/services/mbeditor/git_info_service.rb +6 -0
  46. data/app/services/mbeditor/js_globals_service.rb +12 -1
  47. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  48. data/app/services/mbeditor/lint_service.rb +137 -0
  49. data/app/services/mbeditor/locked_json_file.rb +67 -0
  50. data/app/services/mbeditor/process_runner.rb +32 -0
  51. data/app/services/mbeditor/rubocop_run_service.rb +17 -5
  52. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  53. data/app/services/mbeditor/schema_service.rb +8 -2
  54. data/app/services/mbeditor/search_replace_service.rb +19 -2
  55. data/app/services/mbeditor/test_runner_service.rb +3 -56
  56. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  57. data/lib/mbeditor/audit_log.rb +203 -0
  58. data/lib/mbeditor/configuration.rb +6 -9
  59. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  60. data/lib/mbeditor/route_map.rb +4 -1
  61. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  62. data/lib/mbeditor/version.rb +1 -1
  63. data/lib/mbeditor.rb +1 -0
  64. data/lib/tasks/mbeditor.rake +23 -0
  65. metadata +14 -3
  66. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +0 -312
@@ -239,12 +239,43 @@
239
239
  // Same-URI addExtraLib replaces content in place; that is how both layers
240
240
  // refresh.
241
241
  var PROGRAM_VISIBLE_KINDS = { 'var': 1, 'let': 1, 'const': 1, 'function': 1, 'class': 1 };
242
- var programPaths = {}; // workspace-relative path -> true, for the filter above
242
+ // workspace-relative path -> { content, lib }, where lib is the addExtraLib
243
+ // disposable or null while the file is open. Presence is what the filter
244
+ // above reads: an open file is still in the program, supplied by its model.
245
+ var programPaths = {};
243
246
 
244
247
  function programUri(path) {
245
248
  return 'file:///' + String(path).replace(/^\/+/, '');
246
249
  }
247
250
 
251
+ // An open file reaches the program twice — as its live Monaco model and as
252
+ // this extraLib — and TypeScript reads that as two declarations of everything
253
+ // at its top level, so a lone `const x` in an open .jsx reports TS2451
254
+ // against itself. The model is the edited copy, so it wins and the extraLib
255
+ // stands down until the model is disposed.
256
+ function syncProgramLib(monaco, path) {
257
+ var entry = programPaths[path];
258
+ if (!entry) return;
259
+ var open = monaco.editor.getModels().some(function (m) {
260
+ return m._mbeditorPath === path && !m.isDisposed();
261
+ });
262
+ if (open && entry.lib) {
263
+ entry.lib.dispose();
264
+ entry.lib = null;
265
+ } else if (!open && !entry.lib) {
266
+ entry.lib = monaco.languages.typescript.javascriptDefaults
267
+ .addExtraLib(entry.content, programUri(path));
268
+ }
269
+ }
270
+
271
+ function setProgramFile(monaco, path, content) {
272
+ var entry = programPaths[path];
273
+ if (!entry) entry = programPaths[path] = { content: content, lib: null };
274
+ entry.content = content;
275
+ if (entry.lib) { entry.lib.dispose(); entry.lib = null; }
276
+ syncProgramLib(monaco, path);
277
+ }
278
+
248
279
  function loadWorkspaceProgram(monaco) {
249
280
  if (typeof FileService === 'undefined') return;
250
281
  var mts = monaco && monaco.languages && monaco.languages.typescript;
@@ -255,8 +286,7 @@
255
286
  if (!data || !data.ok || !data.files) return;
256
287
  data.files.forEach(function (f) {
257
288
  if (!f || typeof f.content !== 'string' || !f.path) return;
258
- programPaths[f.path] = true;
259
- mts.javascriptDefaults.addExtraLib(f.content, programUri(f.path));
289
+ setProgramFile(monaco, f.path, f.content);
260
290
  });
261
291
  if (data.skipped && data.skipped.length && window.console) {
262
292
  console.info('[mbeditor] ' + data.fileCount + ' source files (' +
@@ -322,8 +352,7 @@
322
352
  })).then(function (responses) {
323
353
  responses.forEach(function (data) {
324
354
  if (!data || !data.ok || !data.file) return;
325
- programPaths[data.file.path] = true;
326
- mts.javascriptDefaults.addExtraLib(data.file.content, programUri(data.file.path));
355
+ setProgramFile(monaco, data.file.path, data.file.content);
327
356
  });
328
357
  });
329
358
  }
@@ -413,6 +442,22 @@
413
442
  // 6s default: the server's own budget for them is 10s.
414
443
  var LSP_SLOW_METHODS = { diagnostics: 15000, formatting: 15000 };
415
444
 
445
+ // ruby-lsp's wire names are snake_case, the audit legend's are camelCase, and
446
+ // these two differ outright. code() answers 0 ("other") for anything the
447
+ // legend does not list, so a method added later still records.
448
+ function auditLspName(lspMethod) {
449
+ if (lspMethod === 'formatting') return 'format';
450
+ if (lspMethod === 'prepare_rename') return 'rename';
451
+ return lspMethod.replace(/_(\w)/g, function (m, c) { return c.toUpperCase(); });
452
+ }
453
+
454
+ function recLsp(lspMethod, startedAt, ok) {
455
+ var audit = window.MbeditorAudit;
456
+ if (!audit) return;
457
+ audit.rec(audit.EV.LSP, audit.code('lspMethod', auditLspName(lspMethod)),
458
+ Date.now() - startedAt, ok ? 1 : 0);
459
+ }
460
+
416
461
  // `token` is Monaco's CancellationToken, when the calling provider has one.
417
462
  // Every provider fires on a gesture the user can abandon — moving the cursor
418
463
  // off a word, typing another character — and Monaco cancels the outstanding
@@ -432,6 +477,7 @@
432
477
  if (model.getValueLength() === 0) return Promise.resolve(null);
433
478
  if (token && token.isCancellationRequested) return Promise.resolve(null);
434
479
 
480
+ var startedAt = Date.now();
435
481
  var config = LSP_SLOW_METHODS[lspMethod] ? { timeout: LSP_SLOW_METHODS[lspMethod] } : null;
436
482
  var controller = (token && typeof AbortController !== 'undefined') ? new AbortController() : null;
437
483
  var cancelSub = null;
@@ -443,6 +489,7 @@
443
489
  return FileService.rubyLspRequest(lspMethod, model._mbeditorPath, model.getValue(),
444
490
  position.lineNumber, position.column, config, extraBody)
445
491
  .then(function (data) {
492
+ recLsp(lspMethod, startedAt, (!data || data.fallback || data.error) ? 0 : 1);
446
493
  if (!data || data.fallback || data.error) {
447
494
  // A 200 can still carry lspState: 'failed' — the server answered,
448
495
  // the language server did not.
@@ -459,6 +506,9 @@
459
506
  return null;
460
507
  })
461
508
  .catch(function (err) {
509
+ // A cancelled request is Monaco changing its mind, not a failure —
510
+ // documentHighlight alone would otherwise log one per cursor move.
511
+ if (!(token && token.isCancellationRequested)) recLsp(lspMethod, startedAt, 0);
462
512
  noteLspFailure(err);
463
513
  return null;
464
514
  })
@@ -498,6 +548,37 @@
498
548
  }
499
549
  }
500
550
 
551
+ // True when a hover provider should actually run here: real buffer text,
552
+ // not a `#` comment. Route-hint decorations (EditorPanel.js) render their
553
+ // label via a zero-width decoration's `after.content` — text that is never
554
+ // part of the model — anchored at a column past the real end of the line.
555
+ // Monaco still resolves a mouse position over that rendered text, but the
556
+ // column it reports exceeds getLineMaxColumn, which a position over real
557
+ // content can never do. getWordAtPosition doesn't reject that
558
+ // out-of-range column — it just snaps to the nearest real word — so
559
+ // without this check hovering the decoration silently hovers whatever word
560
+ // precedes it. Comment membership comes from Monaco's own tokenizer
561
+ // (getLineTokens/StandardTokenType) rather than a `#` regex, so a `#`
562
+ // inside a string isn't misread as a comment opener. Exposed so both
563
+ // cases can be asserted directly in system tests.
564
+ function isRealHoverPosition(model, position) {
565
+ if (!model || !position) return false;
566
+ if (position.column > model.getLineMaxColumn(position.lineNumber)) return false;
567
+ // Standard token types: Other 0, Comment 1, String 2, RegEx 3. The enum
568
+ // object is not exported by this Monaco build, and getLineTokens lives on
569
+ // model.tokenization, not the model — both misses were swallowed by the
570
+ // catch below and made the guard a no-op.
571
+ var COMMENT = 1;
572
+ try {
573
+ var tk = model.tokenization || model;
574
+ if (tk.forceTokenization) tk.forceTokenization(position.lineNumber);
575
+ var lineTokens = tk.getLineTokens(position.lineNumber);
576
+ var idx = lineTokens.findTokenIndexAtOffset(position.column - 1);
577
+ if (idx >= 0 && lineTokens.getStandardTokenType(idx) === COMMENT) return false;
578
+ } catch (e) { /* tokenizer not ready — treat as real text */ }
579
+ return true;
580
+ }
581
+
501
582
  // Rails view helpers are defined inside the framework, not the workspace, so
502
583
  // looking them up from ERB only ever produces empty round-trips.
503
584
  var RAILS_VIEW_HELPERS = {
@@ -756,14 +837,16 @@
756
837
  var lineNumber = change.range.startLineNumber;
757
838
  var columnBeforeInsert = change.range.startColumn;
758
839
  var lineContent = model.getLineContent(lineNumber);
759
- var textBefore = lineContent.substring(0, columnBeforeInsert - 1);
840
+ var textBefore = textBackTo(model, lineNumber, columnBeforeInsert - 1);
760
841
 
761
842
  if (/\/$/.test(textBefore)) return false;
762
843
 
763
- var tagMatch = textBefore.match(/<([a-zA-Z][a-zA-Z0-9:\-_]*)(?:\s+[^>]*?)?$/);
764
- if (!tagMatch) return false;
844
+ var tags = scanTags(textBefore);
845
+ var opener = tags[tags.length - 1];
846
+ if (!opener || opener.closing || opener.closed || opener.depth !== 0) return false;
847
+ if (opener.end !== textBefore.length) return false;
765
848
 
766
- var tagName = tagMatch[1];
849
+ var tagName = opener.name;
767
850
  if (VOID_HTML_ELEMENTS[tagName.toLowerCase()]) return false;
768
851
 
769
852
  var closingTag = '</' + tagName + '>';
@@ -793,6 +876,164 @@
793
876
  return true;
794
877
  }
795
878
 
879
+ var TAG_SCAN_LINES = 200;
880
+ var TAG_SCAN_LINES_AFTER = 500;
881
+
882
+ // Tags in `text`, brace-aware so JSX attribute expressions like
883
+ // onClick={() => x > y} do not end a tag early.
884
+ function scanTags(text) {
885
+ var tags = [];
886
+ var re = /<(\/?)([A-Za-z][\w:.\-]*)/g;
887
+ var match;
888
+ while ((match = re.exec(text))) {
889
+ var i = re.lastIndex;
890
+ var depth = 0;
891
+ var quote = null;
892
+ var closed = false;
893
+ while (i < text.length) {
894
+ var c = text.charAt(i);
895
+ if (quote) {
896
+ if (c === '\\') { i += 2; continue; }
897
+ if (c === quote) quote = null;
898
+ } else if (c === '"' || c === "'" || c === '`') {
899
+ quote = c;
900
+ } else if (c === '{') {
901
+ depth++;
902
+ } else if (c === '}') {
903
+ if (depth > 0) depth--;
904
+ } else if (depth === 0) {
905
+ if (c === '>') { closed = true; break; }
906
+ if (c === '<') break;
907
+ }
908
+ i++;
909
+ }
910
+ tags.push({
911
+ name: match[2],
912
+ closing: !!match[1],
913
+ closed: closed,
914
+ selfClosing: closed && text.charAt(i - 1) === '/',
915
+ start: match.index,
916
+ end: i,
917
+ depth: depth
918
+ });
919
+ re.lastIndex = closed ? i + 1 : i;
920
+ }
921
+ return tags;
922
+ }
923
+
924
+ // Text from at most TAG_SCAN_LINES lines back up to `endIndex` on `lineNumber`.
925
+ function textBackTo(model, lineNumber, endIndex) {
926
+ var startLine = Math.max(1, lineNumber - TAG_SCAN_LINES);
927
+ return model.getValueInRange(new window.monaco.Range(startLine, 1, lineNumber, endIndex + 1));
928
+ }
929
+
930
+ // Nearest still-open tag before `endIndex` on `lineNumber`, scanning back at
931
+ // most TAG_SCAN_LINES lines. Void and self-closing tags never go on the stack.
932
+ function nearestUnclosedTag(model, lineNumber, endIndex) {
933
+ var stack = [];
934
+ scanTags(textBackTo(model, lineNumber, endIndex)).forEach(function (tag) {
935
+ if (tag.closing) {
936
+ for (var i = stack.length - 1; i >= 0; i--) {
937
+ if (stack[i] === tag.name) { stack.length = i; break; }
938
+ }
939
+ } else if (tag.closed && !tag.selfClosing && !VOID_HTML_ELEMENTS[tag.name.toLowerCase()]) {
940
+ stack.push(tag.name);
941
+ }
942
+ });
943
+ return stack.length ? stack[stack.length - 1] : null;
944
+ }
945
+
946
+ function tagNameRange(model, base, tag) {
947
+ var offset = base + tag.start + (tag.closing ? 2 : 1);
948
+ var start = model.getPositionAt(offset);
949
+ var end = model.getPositionAt(offset + tag.name.length);
950
+ return new window.monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column);
951
+ }
952
+
953
+ function linkedEditingRanges(model, position) {
954
+ if (!model || !position) return null;
955
+ if (model.getLanguageId && model.getLanguageId() === 'erb' && isInsideErbTag(model, position)) return null;
956
+
957
+ var startLine = Math.max(1, position.lineNumber - TAG_SCAN_LINES);
958
+ var endLine = Math.min(model.getLineCount(), position.lineNumber + TAG_SCAN_LINES_AFTER);
959
+ var base = model.getOffsetAt({ lineNumber: startLine, column: 1 });
960
+ var text = model.getValueInRange(new window.monaco.Range(startLine, 1, endLine, model.getLineMaxColumn(endLine)));
961
+ var tags = scanTags(text);
962
+ var cursor = model.getOffsetAt(position) - base;
963
+
964
+ var index = -1;
965
+ for (var i = 0; i < tags.length; i++) {
966
+ var nameStart = tags[i].start + (tags[i].closing ? 2 : 1);
967
+ if (cursor >= nameStart && cursor <= nameStart + tags[i].name.length) { index = i; break; }
968
+ }
969
+ if (index < 0) return null;
970
+
971
+ var tag = tags[index];
972
+ if (VOID_HTML_ELEMENTS[tag.name.toLowerCase()]) return null;
973
+
974
+ var match = null;
975
+ var depth = 0;
976
+ var j;
977
+ if (tag.closing) {
978
+ for (j = index - 1; j >= 0; j--) {
979
+ if (tags[j].name !== tag.name) continue;
980
+ if (tags[j].closing) depth++;
981
+ else if (tags[j].closed && !tags[j].selfClosing) {
982
+ if (depth === 0) { match = tags[j]; break; }
983
+ depth--;
984
+ }
985
+ }
986
+ } else {
987
+ if (!tag.closed || tag.selfClosing) return null;
988
+ for (j = index + 1; j < tags.length; j++) {
989
+ if (tags[j].name !== tag.name) continue;
990
+ if (tags[j].closing) {
991
+ if (depth === 0) { match = tags[j]; break; }
992
+ depth--;
993
+ } else if (tags[j].closed && !tags[j].selfClosing) depth++;
994
+ }
995
+ }
996
+ if (!match) return null;
997
+
998
+ return {
999
+ ranges: [
1000
+ tagNameRange(model, base, tag.closing ? match : tag),
1001
+ tagNameRange(model, base, tag.closing ? tag : match)
1002
+ ],
1003
+ wordPattern: /[\w:.\-]+/
1004
+ };
1005
+ }
1006
+
1007
+ function handleClosingTagAutoClose(editor, model, change) {
1008
+ if (change.rangeLength !== 0 || change.text !== '/') return false;
1009
+
1010
+ var lineNumber = change.range.startLineNumber;
1011
+ var slashColumn = change.range.startColumn;
1012
+ var lineContent = model.getLineContent(lineNumber);
1013
+ if (lineContent.charAt(slashColumn - 2) !== '<') return false;
1014
+
1015
+ var tagName = nearestUnclosedTag(model, lineNumber, slashColumn - 2);
1016
+ if (!tagName) return false;
1017
+
1018
+ var insertAt = slashColumn + 1;
1019
+ window.setTimeout(function () {
1020
+ var activeModel = editor.getModel();
1021
+ if (!activeModel || activeModel !== model) return;
1022
+
1023
+ editor.executeEdits('html-auto-close-end', [{
1024
+ range: new window.monaco.Range(lineNumber, insertAt, lineNumber, insertAt),
1025
+ text: tagName + '>'
1026
+ }]);
1027
+
1028
+ window.setTimeout(function () {
1029
+ editor.setPosition({ lineNumber: lineNumber, column: insertAt + tagName.length + 1 });
1030
+ editor.focus();
1031
+ }, 0);
1032
+ }, 0);
1033
+
1034
+ return true;
1035
+ }
1036
+
796
1037
  function attachEditorFeatures(editor, language) {
797
1038
  var model = editor && editor.getModel ? editor.getModel() : null;
798
1039
  if (!model) {
@@ -955,12 +1196,15 @@
955
1196
 
956
1197
  suppressInternalEdit = true;
957
1198
  try {
958
- if (language === 'html') {
959
- handled = handleMarkupAutoClose(editor, model, change) || handled;
960
- }
1199
+ var isMarkup = language === 'html' || language === 'javascript' || language === 'typescript' ||
1200
+ (language === 'erb' && !isInsideErbTag(model, {
1201
+ lineNumber: change.range.startLineNumber,
1202
+ column: change.range.startColumn
1203
+ }));
961
1204
 
962
- if (language === 'javascript' || language === 'typescript') {
1205
+ if (isMarkup) {
963
1206
  handled = handleMarkupAutoClose(editor, model, change) || handled;
1207
+ handled = handleClosingTagAutoClose(editor, model, change) || handled;
964
1208
  }
965
1209
  } finally {
966
1210
  suppressInternalEdit = false;
@@ -1002,12 +1246,11 @@
1002
1246
  };
1003
1247
  }
1004
1248
 
1005
- function registerGlobalExtensions(monaco) {
1006
- if (globalsRegistered) return;
1007
- if (!monaco || !monaco.languages) return;
1008
-
1009
- globalsRegistered = true;
1010
-
1249
+ // One-time Monaco registration, grouped by provider affinity rather than by
1250
+ // scroll position. Each group is independent — no group's providers read
1251
+ // state another group sets up — so the split only adds seams; nothing here
1252
+ // was a pass-through.
1253
+ function registerJsProviders(monaco) {
1011
1254
  // JavaScript: enable semantic checking (off by default in Monaco) and JSX support.
1012
1255
  // checkJs catches undefined variables, noUnusedLocals catches dead assignments.
1013
1256
  if (monaco.languages.typescript && monaco.languages.typescript.javascriptDefaults) {
@@ -1176,6 +1419,22 @@
1176
1419
  // supply, loaded once now.
1177
1420
  loadWorkspaceProgram(monaco);
1178
1421
 
1422
+ // Opening or closing a file moves it between the two halves of the
1423
+ // program — see syncProgramLib. Deferred a tick because _mbeditorPath is
1424
+ // stamped on the model after createModel returns, and because a disposing
1425
+ // model is still live when the event fires.
1426
+ var _syncPending = false;
1427
+ var syncProgram = function () {
1428
+ if (_syncPending) return;
1429
+ _syncPending = true;
1430
+ setTimeout(function () {
1431
+ _syncPending = false;
1432
+ Object.keys(programPaths).forEach(function (p) { syncProgramLib(monaco, p); });
1433
+ }, 0);
1434
+ };
1435
+ monaco.editor.onDidCreateModel(syncProgram);
1436
+ monaco.editor.onWillDisposeModel(syncProgram);
1437
+
1179
1438
  // On a change: refresh just the touched files' program entries, and
1180
1439
  // re-run the (cheap, cached) globals scan. The whole tree is never
1181
1440
  // re-sent — see refreshProgramPaths.
@@ -1220,16 +1479,29 @@
1220
1479
  // Downgraded to Warning below and auto-resolved against the workspace,
1221
1480
  // since host-app globals are invisible to the language service.
1222
1481
  // • 6133 "declared but never read" — a lint. Downgraded to Warning.
1482
+ // • 2451 "Cannot redeclare block-scoped variable" and 2300 "Duplicate
1483
+ // identifier", but only when the file collides with itself — see
1484
+ // isSelfRedeclaration. Two `const`s or two `class`es of one name in
1485
+ // one file is a SyntaxError under every module system, so this is not
1486
+ // a guess about types at all.
1223
1487
  // • anything below Error severity — hints and suggestions render faint
1224
1488
  // and cost nothing, so they pass through untouched.
1225
1489
  //
1226
- // Deliberately dropped along with the type errors: 2300 "Duplicate
1227
- // identifier" and 2403 "Subsequent variable declarations…", which are
1228
- // structural false positives in the Sprockets model — every open file
1229
- // shares one global script context, so a component in file_a.jsx looks
1230
- // like a redeclaration once file_b.jsx is open, and the ambient
1231
- // `declare var Foo: any` from workspace-globals.d.ts collides with the
1232
- // real `function Foo()` when its defining file is open.
1490
+ // Deliberately dropped along with the type errors: 2403 "Subsequent
1491
+ // variable declarations…", the code TypeScript reaches for when a
1492
+ // declaration collides with a `var`. Every ambient name this module
1493
+ // synthesizes is a `declare var`, so it fires against our own `.d.ts`
1494
+ // files: typing `function Foo()` into an open file collides with the
1495
+ // `declare var Foo: any` that workspace-globals.d.ts still holds until
1496
+ // the next save. That ambient collision reaches 2300 too, which is what
1497
+ // the two-site rule below exists to tell apart — measured on the sample
1498
+ // workspace after the extraLib suppression above, a real duplicate emits
1499
+ // 2300 at both sites and the ambient one emits it at one.
1500
+ //
1501
+ // 2393 "Duplicate function implementation" is kept under the same rule.
1502
+ // Redeclaring a plain `function` is legal JS and TypeScript rightly says
1503
+ // nothing, but it does emit 2393 for a class that declares one method
1504
+ // twice — where the second silently wins and the first is dead code.
1233
1505
  //
1234
1506
  // .ts/.tsx keeps full checking: there the types are hand-written, so a
1235
1507
  // type error is a statement about code the author actually wrote.
@@ -1253,7 +1525,8 @@
1253
1525
  // which is a working idiom. That is the arbitrary-inference category
1254
1526
  // this filter exists to keep out.
1255
1527
  var JS_CALL_CODES = { '2554': true, '2769': true, '2741': true, '2322': true };
1256
- var JS_KEEP_CODES = { '2304': true, '6133': true, '2554': true, '2769': true, '2741': true, '2322': true };
1528
+ var JS_KEEP_CODES = { '2304': true, '6133': true, '2554': true, '2769': true, '2741': true, '2322': true, '2451': true, '2300': true, '2393': true };
1529
+ var JS_DUP_CODES = { '2451': true, '2300': true, '2393': true };
1257
1530
  var JS_SYNTAX_CODE = /^(?:1\d{3}|17\d{3})$/;
1258
1531
  var JS_WARN_CODES = { '2304': true, '6133': true, '2554': true, '2769': true, '2741': true, '2322': true };
1259
1532
  var TS_WARN_CODES = { '6133': true };
@@ -1265,9 +1538,12 @@
1265
1538
  // value of the wrong type flows straight into the component — both of
1266
1539
  // those break something, so they read as errors.
1267
1540
  //
1268
- // TypeScript folds all three JSX cases into 2769 ("No overload matches
1269
- // this call") once React's types are in play, so the distinction has to
1270
- // come from the message chain, which Monaco flattens into the marker.
1541
+ // The code is not a reliable signal, so the distinction comes from the
1542
+ // message chain, which Monaco flattens into the marker. TypeScript
1543
+ // reports the precise 2322/2741 when the component is declared once, but
1544
+ // folds both JSX cases into 2769 ("No overload matches this call") as
1545
+ // soon as it sees two declarations of it — which is what an open file
1546
+ // duplicated into the program as an extraLib used to produce.
1271
1547
  var JS_UNKNOWN_PROPERTY = /does not exist on type/;
1272
1548
  var JS_MISSING_REQUIRED = /is missing in type .* but required in type/;
1273
1549
  function callDiagnosticSeverity(code, message) {
@@ -1278,10 +1554,41 @@
1278
1554
  return monaco.MarkerSeverity.Error;
1279
1555
  }
1280
1556
 
1281
- function keepJsMarker(marker) {
1557
+ // TypeScript flags every colliding declaration site, so a file that really
1558
+ // does declare a name twice gets one marker per site — identical message,
1559
+ // different position. A lone one means the other declaration is elsewhere:
1560
+ // an ambient `.d.ts` this module synthesizes, or a workspace file that
1561
+ // shares a top-level name and is never loaded alongside this one. Neither
1562
+ // is the author's mistake, and neither is visible any other way — Monaco
1563
+ // drops the relatedInformation naming the other file, because it resolves
1564
+ // to no open model.
1565
+ //
1566
+ // The rule is under-inclusive for `function`, never wrong: redeclaring one
1567
+ // is legal JS, so TypeScript says nothing unless the name ALSO has an
1568
+ // ambient declaration, at which point it reports 2300 at both sites and
1569
+ // this keeps them. `function Foo(){}` twice therefore underlines when Foo
1570
+ // is a workspace global and stays quiet otherwise. Both are real
1571
+ // duplicates; only the quiet half is missed.
1572
+ //
1573
+ // This is also the only thing keeping diff tabs quiet. DiffViewer builds
1574
+ // two more javascript models per diff, and every javascript model joins
1575
+ // the one program, so opening a diff of a file that is also open makes
1576
+ // each side collide with the live model — measured, one raw 2451 on the
1577
+ // live model per diff pane. One occurrence, so the rule drops it. Relaxing
1578
+ // the two-site test, or adding a duplicate code to JS_KEEP_CODES without
1579
+ // adding it to JS_DUP_CODES, lights every diff tab up with false errors.
1580
+ function isSelfRedeclaration(marker, markers) {
1581
+ var code = String(marker.code);
1582
+ return markers.filter(function (m) {
1583
+ return String(m.code) === code && m.message === marker.message;
1584
+ }).length > 1;
1585
+ }
1586
+
1587
+ function keepJsMarker(marker, markers) {
1282
1588
  if (marker.severity !== monaco.MarkerSeverity.Error) return true;
1283
1589
  var code = String(marker.code == null ? '' : marker.code);
1284
- return JS_KEEP_CODES[code] === true || JS_SYNTAX_CODE.test(code);
1590
+ if (JS_KEEP_CODES[code] !== true && !JS_SYNTAX_CODE.test(code)) return false;
1591
+ return !JS_DUP_CODES[code] || isSelfRedeclaration(marker, markers);
1285
1592
  }
1286
1593
 
1287
1594
  // A 2304 whose span is an assignment TARGET (`foo = 1` with no
@@ -1326,7 +1633,7 @@
1326
1633
  var markers = (entry.owner === 'javascript' && jsMarkers && jsMarkers[uri.toString()]) ||
1327
1634
  monaco.editor.getModelMarkers({ resource: uri, owner: entry.owner });
1328
1635
  var patched = markers.filter(function(m) {
1329
- return entry.keep ? entry.keep(m) : true;
1636
+ return entry.keep ? entry.keep(m, markers) : true;
1330
1637
  }).map(function(m) {
1331
1638
  var code = String(m.code);
1332
1639
  if (!entry.warn[code]) return m;
@@ -1444,6 +1751,218 @@
1444
1751
  });
1445
1752
  });
1446
1753
 
1754
+ // ── ruby-lsp navigation ──────────────────────────────────────────────────
1755
+
1756
+ // Serves exactly one thing: the candidate list navigateToJsWord hands over
1757
+ // when a name is declared at top level in several other files. It is empty
1758
+ // the rest of the time, so the TypeScript worker remains the only voice on
1759
+ // an ordinary jump and a local definition still goes straight there —
1760
+ // Monaco merges providers without deduping, so a second opinion here would
1761
+ // show a picker listing one definition twice.
1762
+ monaco.languages.registerDefinitionProvider('javascript', {
1763
+ provideDefinition: function (model, position) {
1764
+ var pending = pendingJsDefinitionPeek;
1765
+ pendingJsDefinitionPeek = null;
1766
+ if (!pending) return null;
1767
+ if (pending.uri !== model.uri.toString() ||
1768
+ pending.lineNumber !== position.lineNumber ||
1769
+ pending.column !== position.column) return null;
1770
+ return pending.locations;
1771
+ }
1772
+ });
1773
+
1774
+ // JS/JSX hover provider: looks up workspace definitions for window globals.
1775
+ // Fires for mixed-case identifiers and for any symbol already in discoveredJsGlobals.
1776
+ var JS_HOVER_CACHE_TTL_MS = 60000;
1777
+ monaco.languages.registerHoverProvider('javascript', {
1778
+ provideHover: function(model, position, token) {
1779
+ var wordInfo = model.getWordAtPosition(position);
1780
+ if (!wordInfo || !wordInfo.word || wordInfo.word.length < 2) return null;
1781
+ var word = wordInfo.word;
1782
+ if (!discoveredJsGlobals[word] && !/[A-Z]/.test(word)) return null;
1783
+ if (typeof FileService === 'undefined' || !FileService.getJsDefinition) return null;
1784
+
1785
+ // Build a position-specific range for this hover instance.
1786
+ // The range must NOT be cached because the same symbol can appear on
1787
+ // different lines; a stale cached lineNumber would highlight the wrong place.
1788
+ function makeHoverRange() {
1789
+ return new monaco.Range(position.lineNumber, wordInfo.startColumn, position.lineNumber, wordInfo.endColumn);
1790
+ }
1791
+
1792
+ var parentCtx = extractJsParentContext(model, position.lineNumber, wordInfo);
1793
+ // Parent-qualified hovers cache separately: Parent.myFunction and a
1794
+ // bare myFunction can resolve to different definitions.
1795
+ var cacheKey = (parentCtx ? parentCtx + '.' : '') + word;
1796
+ var cached = jsHoverCache[cacheKey];
1797
+ if (cached && (Date.now() - cached.ts) < JS_HOVER_CACHE_TTL_MS) {
1798
+ if (!cached.contents) return null;
1799
+ return { range: makeHoverRange(), contents: cached.contents };
1800
+ }
1801
+
1802
+ var controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
1803
+ if (controller && token && token.onCancellationRequested) {
1804
+ token.onCancellationRequested(function() { controller.abort(); });
1805
+ }
1806
+ return FileService.getJsDefinition(word, controller ? { signal: controller.signal } : {}, parentCtx)
1807
+ .then(function(data) {
1808
+ if (token && token.isCancellationRequested) return null;
1809
+ var results = data && data.results;
1810
+ if (!results || !results.length) {
1811
+ if (isRuntimeWindowGlobal(word)) {
1812
+ addDiscoveredGlobal(word);
1813
+ var kind = typeof window[word];
1814
+ var rtContents = [{ value: '**' + word + '** — runtime global (`' + kind + '`)' }];
1815
+ jsHoverCache[cacheKey] = { ts: Date.now(), contents: rtContents };
1816
+ return { range: makeHoverRange(), contents: rtContents };
1817
+ }
1818
+ jsHoverCache[cacheKey] = { ts: Date.now(), contents: null };
1819
+ return null;
1820
+ }
1821
+ // Same preference as Ctrl+click, so the file the hover names is
1822
+ // the file the jump would take you to.
1823
+ var r = preferCurrentFile(results, model._mbeditorPath) || results[0];
1824
+ // Only declare as global when the definition is a top-level
1825
+ // (Sprockets-global) declaration in a different file — nested and
1826
+ // member definitions must not get a duplicate declare var.
1827
+ if (r.topLevel && r.file !== model._mbeditorPath) addDiscoveredGlobal(word);
1828
+ var fileRef = r.file + ':' + r.line;
1829
+ var contents = [
1830
+ { value: '```javascript\n' + r.snippet + '\n```', isTrusted: true },
1831
+ { value: '<span style="opacity:0.55;font-size:0.9em;">' + fileRef + '</span>', isTrusted: true, supportHtml: true }
1832
+ ];
1833
+ jsHoverCache[cacheKey] = { ts: Date.now(), contents: contents };
1834
+ return { range: makeHoverRange(), contents: contents };
1835
+ }).catch(function() { return null; });
1836
+ }
1837
+ });
1838
+
1839
+ // JS/JSX member completion provider: suggests properties/methods of workspace globals after '.'.
1840
+ // Only looks up PascalCase/mixed-case identifiers or previously discovered globals.
1841
+ var JS_MEMBERS_CACHE_TTL_MS = 60000;
1842
+ monaco.languages.registerCompletionItemProvider('javascript', {
1843
+ triggerCharacters: ['.'],
1844
+ provideCompletionItems: function(model, position) {
1845
+ var line = model.getLineContent(position.lineNumber);
1846
+ var col = position.column - 2; // index of character just before the '.'
1847
+ var end = col;
1848
+ while (col >= 0 && /[a-zA-Z0-9_$]/.test(line[col])) col--;
1849
+ var symbol = line.slice(col + 1, end + 1);
1850
+ if (!symbol || symbol.length < 2) return { suggestions: [] };
1851
+ if (!discoveredJsGlobals[symbol] && !/^[A-Z]/.test(symbol)) return { suggestions: [] };
1852
+ if (typeof FileService === 'undefined' || !FileService.getJsMembers) return { suggestions: [] };
1853
+
1854
+ // Only the raw member list is cached. Suggestions carry the insert
1855
+ // `range`, which is where the completion is about to be written — the
1856
+ // same trap the hover provider's makeHoverRange avoids. Cached
1857
+ // suggestions inserted at the position of the invocation that filled
1858
+ // the cache, so a hit anywhere else in the file was misapplied or
1859
+ // silently dropped by Monaco.
1860
+ function buildSuggestions(members) {
1861
+ return members.map(function(m) {
1862
+ return {
1863
+ label: m.name,
1864
+ kind: monaco.languages.CompletionItemKind.Method,
1865
+ detail: symbol,
1866
+ documentation: m.snippet,
1867
+ insertText: m.name,
1868
+ range: {
1869
+ startLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
1870
+ startColumn: position.column, endColumn: position.column
1871
+ }
1872
+ };
1873
+ });
1874
+ }
1875
+
1876
+ var cached = jsMembersCache[symbol];
1877
+ if (cached && (Date.now() - cached.ts) < JS_MEMBERS_CACHE_TTL_MS) {
1878
+ return { suggestions: buildSuggestions(cached.members) };
1879
+ }
1880
+
1881
+ return FileService.getJsMembers(symbol)
1882
+ .then(function(data) {
1883
+ var members = (data && data.members) || [];
1884
+ jsMembersCache[symbol] = { ts: Date.now(), members: members };
1885
+ return { suggestions: buildSuggestions(members) };
1886
+ }).catch(function() { return { suggestions: [] }; });
1887
+ }
1888
+ });
1889
+
1890
+ monaco.languages.registerCompletionItemProvider('javascript', jsxPropsProvider);
1891
+ }
1892
+
1893
+ // JSX attribute completion for components whose props are read as `props.x` /
1894
+ // `this.props.x`, which the TS worker types as `any` and so completes nothing
1895
+ // for. Destructured params are deliberately not scanned: the TS worker already
1896
+ // completes those, and a second copy would double every row.
1897
+ // Component name → prop-name list.
1898
+ var jsxPropsProvider = {
1899
+ triggerCharacters: [' '],
1900
+ provideCompletionItems: function(model, position) {
1901
+ var monaco = window.monaco;
1902
+ var before = model.getValueInRange({
1903
+ startLineNumber: position.lineNumber, startColumn: 1,
1904
+ endLineNumber: position.lineNumber, endColumn: position.column
1905
+ });
1906
+ var tag = before.match(/<([A-Z][\w$]*)(?:\s+[^<>]*)?\s+[\w-]*$/);
1907
+ if (!tag) return { suggestions: [] };
1908
+
1909
+ var used = {};
1910
+ (before.slice(tag.index).match(/[\w-]+(?=\s*=)/g) || []).forEach(function(a) { used[a] = 1; });
1911
+
1912
+ var word = model.getWordUntilPosition(position);
1913
+ var range = {
1914
+ startLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
1915
+ startColumn: word.startColumn, endColumn: word.endColumn
1916
+ };
1917
+ return {
1918
+ suggestions: collectComponentProps(monaco, model, tag[1])
1919
+ .filter(function(p) { return !used[p]; })
1920
+ .map(function(p) {
1921
+ return {
1922
+ label: p,
1923
+ kind: monaco.languages.CompletionItemKind.Property,
1924
+ insertText: p,
1925
+ range: range
1926
+ };
1927
+ })
1928
+ };
1929
+ }
1930
+ };
1931
+
1932
+ function collectComponentProps(monaco, model, name) {
1933
+ var sources = [model.getValue()];
1934
+ try {
1935
+ var libs = monaco.languages.typescript.javascriptDefaults.getExtraLibs();
1936
+ Object.keys(libs).forEach(function(uri) {
1937
+ if (libs[uri] && libs[uri].content) sources.push(libs[uri].content);
1938
+ });
1939
+ } catch (e) { /* extraLibs unavailable */ }
1940
+ monaco.editor.getModels().forEach(function(m) { if (m !== model) sources.push(m.getValue()); });
1941
+
1942
+ var decl = new RegExp('(?:function\\s+' + name + '\\s*\\(|(?:const|let|var)\\s+' + name + '\\s*=|class\\s+' + name + '\\b)');
1943
+ var names = [];
1944
+ var seen = {};
1945
+ function add(candidate) {
1946
+ if (/^[A-Za-z_$][\w$]*$/.test(candidate) && !seen[candidate]) { seen[candidate] = 1; names.push(candidate); }
1947
+ }
1948
+
1949
+ for (var i = 0; i < sources.length; i++) {
1950
+ var found = decl.exec(sources[i]);
1951
+ if (!found) continue;
1952
+
1953
+ var body = sources[i].slice(found.index);
1954
+ var end = body.slice(1).search(/\n(?:function |class |const |let |var )/);
1955
+ if (end > 0) body = body.slice(0, end + 1);
1956
+
1957
+ var re = /\b(?:this\.)?props\.([A-Za-z_$][\w$]*)/g;
1958
+ var read;
1959
+ while ((read = re.exec(body))) add(read[1]);
1960
+ break;
1961
+ }
1962
+ return names;
1963
+ }
1964
+
1965
+ function registerRubyProviders(monaco) {
1447
1966
  monaco.languages.setLanguageConfiguration('ruby', {
1448
1967
  comments: { lineComment: '#', blockComment: ['=begin', '=end'] },
1449
1968
  brackets: [['(', ')'], ['{', '}'], ['[', ']']],
@@ -1484,7 +2003,13 @@
1484
2003
  cases: {
1485
2004
  '$2~(?i:SQL)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'sql' },
1486
2005
  '$2~(?i:HTML?|ERB)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'html' },
1487
- '$2~(?i:JS|JAVASCRIPT)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'javascript' },
2006
+ // JSX rides the javascript tokenizer: Monaco registers no
2007
+ // separate jsx language, and its TS-derived grammar lexes JSX
2008
+ // bodies fine. The guard is anchored (^...$), so plain `JS`
2009
+ // never matched `JSX` and it fell through to the generic
2010
+ // heredoc — the whole block painted as one string.
2011
+ '$2~(?i:JSX?|JAVASCRIPT)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'javascript' },
2012
+ '$2~(?i:TSX?|TYPESCRIPT)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'typescript' },
1488
2013
  '$2~(?i:CSS)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'css' },
1489
2014
  '$2~(?i:SCSS)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'scss' },
1490
2015
  '$2~(?i:JSON)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'json' },
@@ -1721,49 +2246,6 @@
1721
2246
  }
1722
2247
  });
1723
2248
 
1724
- var genericLinkedProvider = {
1725
- provideLinkedEditingRanges: function provideLinkedEditingRanges(model, position) {
1726
- var line = model.getLineContent(position.lineNumber);
1727
- var wordInfo = model.getWordAtPosition(position);
1728
- if (!wordInfo) return null;
1729
-
1730
- var word = wordInfo.word;
1731
- var startCol = wordInfo.startColumn;
1732
- var endCol = wordInfo.endColumn;
1733
-
1734
- if (line[startCol - 2] === '<') {
1735
- var closeTagStr = '</' + word + '>';
1736
- var closeIdx = line.indexOf(closeTagStr, endCol - 1);
1737
- if (closeIdx !== -1) {
1738
- return {
1739
- ranges: [new monaco.Range(position.lineNumber, startCol, position.lineNumber, endCol), new monaco.Range(position.lineNumber, closeIdx + 3, position.lineNumber, closeIdx + 3 + word.length)],
1740
- wordPattern: /[a-zA-Z0-9:\-_]+/
1741
- };
1742
- }
1743
- }
1744
-
1745
- if (line[startCol - 3] === '<' && line[startCol - 2] === '/') {
1746
- var openTagRegex = new RegExp('<' + word + '(?:\\s|>)');
1747
- var match = line.match(openTagRegex);
1748
- if (match) {
1749
- var openStart = match.index + 2;
1750
- if (openStart < startCol) {
1751
- return {
1752
- ranges: [new monaco.Range(position.lineNumber, openStart, position.lineNumber, openStart + word.length), new monaco.Range(position.lineNumber, startCol, position.lineNumber, endCol)],
1753
- wordPattern: /[a-zA-Z0-9:\-_]+/
1754
- };
1755
- }
1756
- }
1757
- }
1758
-
1759
- return null;
1760
- }
1761
- };
1762
-
1763
- monaco.languages.registerLinkedEditingRangeProvider('javascript', genericLinkedProvider);
1764
- monaco.languages.registerLinkedEditingRangeProvider('typescript', genericLinkedProvider);
1765
- monaco.languages.registerLinkedEditingRangeProvider('ruby', genericLinkedProvider);
1766
-
1767
2249
  // RuboCop quick-fix code-action provider for Ruby files.
1768
2250
  // Only registers when RuboCop is available in the workspace.
1769
2251
  //
@@ -1869,52 +2351,6 @@
1869
2351
  TabManager.openTab(path, String(path).split('/').pop(), line || 1);
1870
2352
  });
1871
2353
 
1872
- // ── ruby-lsp navigation ──────────────────────────────────────────────────
1873
-
1874
- // Serves exactly one thing: the candidate list navigateToJsWord hands over
1875
- // when a name is declared at top level in several other files. It is empty
1876
- // the rest of the time, so the TypeScript worker remains the only voice on
1877
- // an ordinary jump and a local definition still goes straight there —
1878
- // Monaco merges providers without deduping, so a second opinion here would
1879
- // show a picker listing one definition twice.
1880
- monaco.languages.registerDefinitionProvider('javascript', {
1881
- provideDefinition: function (model, position) {
1882
- var pending = pendingJsDefinitionPeek;
1883
- pendingJsDefinitionPeek = null;
1884
- if (!pending) return null;
1885
- if (pending.uri !== model.uri.toString() ||
1886
- pending.lineNumber !== position.lineNumber ||
1887
- pending.column !== position.column) return null;
1888
- return pending.locations;
1889
- }
1890
- });
1891
-
1892
- // Teaches Monaco how to open a file:// resource in this editor. Without it
1893
- // every provider below can find a location but nothing can go there:
1894
- // peek-definition, the references widget and Ctrl+hover previews all route
1895
- // their "open this" through here.
1896
- monaco.editor.registerEditorOpener({
1897
- openCodeEditor: function (_source, resource, selectionOrPosition) {
1898
- // Only file:// resources name a workspace file. Models are created
1899
- // without an explicit URI, so Monaco gives each one an
1900
- // `inmemory://model/N` identity — and the TS worker returns exactly
1901
- // that when a JS definition resolves inside the file you are already
1902
- // in. Stripping it to a path opened a phantom tab called "57".
1903
- // Handing those back to Monaco lets it reveal the position in the
1904
- // current editor, which is what the gesture meant.
1905
- if (String(resource.scheme || '') !== 'file') return false;
1906
-
1907
- var path = String(resource.path || '').replace(/^\/+/, '');
1908
- if (!path || typeof TabManager === 'undefined' || !TabManager.openTab) return false;
1909
-
1910
- var pos = selectionOrPosition || {};
1911
- var line = pos.startLineNumber || pos.lineNumber || 1;
1912
- var col = pos.startColumn || pos.column || 1;
1913
- TabManager.openTab(path, path.split('/').pop(), line, null, false, col);
1914
- return true;
1915
- }
1916
- });
1917
-
1918
2354
  // Words never worth a definition lookup: language keywords, core methods,
1919
2355
  // and (in ERB) Rails view helpers that live in the framework rather than
1920
2356
  // the workspace. Guarding here rather than in the request means Ctrl+hover
@@ -1936,11 +2372,24 @@
1936
2372
  var isErb = languageId === 'erb';
1937
2373
 
1938
2374
  monaco.languages.registerDefinitionProvider(languageId, {
1939
- provideDefinition: function provideDefinition(model, position) {
2375
+ provideDefinition: function provideDefinition(model, position, token) {
1940
2376
  var word = rubyNavigableWord(model, position, isErb);
1941
2377
  if (!word) return null;
1942
2378
 
1943
- var lsp = isErb ? Promise.resolve(null) : tryRubyLsp('definition', model, position);
2379
+ // Passing token lets tryRubyLsp abort the outstanding request (see
2380
+ // its own comment) when this ctrl-click is abandoned — a second
2381
+ // click before the first answers, most obviously — instead of
2382
+ // leaving an abandoned axios request to run to completion unread.
2383
+ var lsp = isErb ? Promise.resolve(null) : tryRubyLsp('definition', model, position, null, token);
2384
+ // Started alongside ruby-lsp, not inside its .then(): when ruby-lsp
2385
+ // is slow or times out (server-side budget is 3s by default — see
2386
+ // Mbeditor.configuration.ruby_lsp_timeout), waiting for it to
2387
+ // settle before even starting the Ripper-backed fallback tacked a
2388
+ // full extra round trip onto an already-slow answer. The result is
2389
+ // only used when ruby-lsp comes back empty, so a fast ruby-lsp
2390
+ // answer is unaffected — this promise is simply left to resolve
2391
+ // unused.
2392
+ var legacy = legacyRubyDefinition(word);
1944
2393
  return lsp.then(function (data) {
1945
2394
  if (data && data.results && data.results.length) {
1946
2395
  return data.results.map(function (r) {
@@ -1951,7 +2400,7 @@
1951
2400
  };
1952
2401
  });
1953
2402
  }
1954
- return legacyRubyDefinition(word);
2403
+ return legacy;
1955
2404
  });
1956
2405
  }
1957
2406
  });
@@ -2102,82 +2551,6 @@
2102
2551
  }).catch(function () { return []; });
2103
2552
  }
2104
2553
 
2105
- // ── Prettier formatting providers ────────────────────────────────────────
2106
- //
2107
- // `formatOnPaste` has been on by default for a long time and did nothing
2108
- // outside Ruby: Monaco acts on a paste only through a *range* formatting
2109
- // provider, and none was registered. Registering one here is what makes
2110
- // pasted blocks land correctly indented, and what formats the whole thing
2111
- // when the paste fills an empty file (the pasted range is then the file).
2112
- //
2113
- // Prettier's own rangeStart/rangeEnd does the narrowing — it reprints the
2114
- // smallest enclosing statement and leaves the rest byte-identical, so a
2115
- // paste in the middle of a file does not reformat the file.
2116
- var PRETTIER_LANGUAGE_PARSERS = {
2117
- javascript: 'babel', json: 'json', css: 'css',
2118
- scss: 'scss', less: 'less', html: 'html', markdown: 'markdown'
2119
- };
2120
-
2121
- // One edit spanning only what actually changed. A whole-document
2122
- // replacement would work but throws away the cursor position and collapses
2123
- // undo, which on every paste is very noticeable.
2124
- function minimalEdit(model, oldText, newText) {
2125
- if (oldText === newText) return [];
2126
- var start = 0;
2127
- var maxStart = Math.min(oldText.length, newText.length);
2128
- while (start < maxStart && oldText.charCodeAt(start) === newText.charCodeAt(start)) start++;
2129
- var oldEnd = oldText.length;
2130
- var newEnd = newText.length;
2131
- while (oldEnd > start && newEnd > start && oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
2132
- oldEnd--;
2133
- newEnd--;
2134
- }
2135
- return [{
2136
- range: monaco.Range.fromPositions(model.getPositionAt(start), model.getPositionAt(oldEnd)),
2137
- text: newText.slice(start, newEnd)
2138
- }];
2139
- }
2140
-
2141
- function prettierEdits(model, range) {
2142
- var parser = PRETTIER_LANGUAGE_PARSERS[model.getLanguageId()];
2143
- if (!parser || typeof runPrettier !== 'function') return Promise.resolve([]);
2144
-
2145
- var text = model.getValue();
2146
- var prefs = (typeof EditorStore !== 'undefined' && EditorStore.getState().editorPrefs) || {};
2147
- var extra = null;
2148
- if (range) {
2149
- extra = {
2150
- rangeStart: model.getOffsetAt({ lineNumber: range.startLineNumber, column: range.startColumn }),
2151
- rangeEnd: model.getOffsetAt({ lineNumber: range.endLineNumber, column: range.endColumn })
2152
- };
2153
- }
2154
-
2155
- return runPrettier(text, prefs, parser, extra)
2156
- .then(function (formatted) {
2157
- // The model can have moved on while Prettier was working.
2158
- if (model.isDisposed() || model.getValue() !== text) return [];
2159
- return minimalEdit(model, text, formatted);
2160
- })
2161
- // A paste of half an expression will not parse. Leaving it alone is the
2162
- // right answer — the diagnostics path reports the syntax error.
2163
- ["catch"](function () { return []; });
2164
- }
2165
-
2166
- Object.keys(PRETTIER_LANGUAGE_PARSERS).forEach(function (languageId) {
2167
- monaco.languages.registerDocumentRangeFormattingEditProvider(languageId, {
2168
- displayName: 'Prettier',
2169
- provideDocumentRangeFormattingEdits: function (model, range) {
2170
- return prettierEdits(model, range);
2171
- }
2172
- });
2173
- monaco.languages.registerDocumentFormattingEditProvider(languageId, {
2174
- displayName: 'Prettier',
2175
- provideDocumentFormattingEdits: function (model) {
2176
- return prettierEdits(model, null);
2177
- }
2178
- });
2179
- });
2180
-
2181
2554
  // Parameter hints while typing a call's arguments.
2182
2555
  monaco.languages.registerSignatureHelpProvider('ruby', {
2183
2556
  signatureHelpTriggerCharacters: ['(', ','],
@@ -2374,6 +2747,7 @@
2374
2747
  provideHover: function provideHover(model, position, token) {
2375
2748
  var isErb = lang === 'erb';
2376
2749
  if (isErb && !isInsideErbTag(model, position)) return null;
2750
+ if (!isRealHoverPosition(model, position)) return null;
2377
2751
 
2378
2752
  var wordInfo = model.getWordAtPosition(position);
2379
2753
  if (!wordInfo) return null;
@@ -2628,121 +3002,117 @@
2628
3002
  }).catch(function() { return { suggestions: [] }; });
2629
3003
  }
2630
3004
  }
3005
+ }
2631
3006
 
2632
- // JS/JSX hover provider: looks up workspace definitions for window globals.
2633
- // Fires for mixed-case identifiers and for any symbol already in discoveredJsGlobals.
2634
- var JS_HOVER_CACHE_TTL_MS = 60000;
2635
- monaco.languages.registerHoverProvider('javascript', {
2636
- provideHover: function(model, position, token) {
2637
- var wordInfo = model.getWordAtPosition(position);
2638
- if (!wordInfo || !wordInfo.word || wordInfo.word.length < 2) return null;
2639
- var word = wordInfo.word;
2640
- if (!discoveredJsGlobals[word] && !/[A-Z]/.test(word)) return null;
2641
- if (typeof FileService === 'undefined' || !FileService.getJsDefinition) return null;
3007
+ // Cross-language providers: features registered once for several languages
3008
+ // at once (linked editing, the file:// opener, Prettier formatting, vim fold
3009
+ // markers) rather than owned by Ruby or JS specifically.
3010
+ function registerGenericProviders(monaco) {
3011
+ var linkedProvider = { provideLinkedEditingRanges: linkedEditingRanges };
3012
+ ['html', 'erb', 'javascript', 'typescript', 'ruby'].forEach(function (lang) {
3013
+ monaco.languages.registerLinkedEditingRangeProvider(lang, linkedProvider);
3014
+ });
2642
3015
 
2643
- // Build a position-specific range for this hover instance.
2644
- // The range must NOT be cached because the same symbol can appear on
2645
- // different lines; a stale cached lineNumber would highlight the wrong place.
2646
- function makeHoverRange() {
2647
- return new monaco.Range(position.lineNumber, wordInfo.startColumn, position.lineNumber, wordInfo.endColumn);
2648
- }
3016
+ // Teaches Monaco how to open a file:// resource in this editor. Without it
3017
+ // every provider below can find a location but nothing can go there:
3018
+ // peek-definition, the references widget and Ctrl+hover previews all route
3019
+ // their "open this" through here.
3020
+ monaco.editor.registerEditorOpener({
3021
+ openCodeEditor: function (_source, resource, selectionOrPosition) {
3022
+ // Only file:// resources name a workspace file. Models are created
3023
+ // without an explicit URI, so Monaco gives each one an
3024
+ // `inmemory://model/N` identity — and the TS worker returns exactly
3025
+ // that when a JS definition resolves inside the file you are already
3026
+ // in. Stripping it to a path opened a phantom tab called "57".
3027
+ // Handing those back to Monaco lets it reveal the position in the
3028
+ // current editor, which is what the gesture meant.
3029
+ if (String(resource.scheme || '') !== 'file') return false;
2649
3030
 
2650
- var parentCtx = extractJsParentContext(model, position.lineNumber, wordInfo);
2651
- // Parent-qualified hovers cache separately: Parent.myFunction and a
2652
- // bare myFunction can resolve to different definitions.
2653
- var cacheKey = (parentCtx ? parentCtx + '.' : '') + word;
2654
- var cached = jsHoverCache[cacheKey];
2655
- if (cached && (Date.now() - cached.ts) < JS_HOVER_CACHE_TTL_MS) {
2656
- if (!cached.contents) return null;
2657
- return { range: makeHoverRange(), contents: cached.contents };
2658
- }
3031
+ var path = String(resource.path || '').replace(/^\/+/, '');
3032
+ if (!path || typeof TabManager === 'undefined' || !TabManager.openTab) return false;
2659
3033
 
2660
- var controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
2661
- if (controller && token && token.onCancellationRequested) {
2662
- token.onCancellationRequested(function() { controller.abort(); });
2663
- }
2664
- return FileService.getJsDefinition(word, controller ? { signal: controller.signal } : {}, parentCtx)
2665
- .then(function(data) {
2666
- if (token && token.isCancellationRequested) return null;
2667
- var results = data && data.results;
2668
- if (!results || !results.length) {
2669
- if (isRuntimeWindowGlobal(word)) {
2670
- addDiscoveredGlobal(word);
2671
- var kind = typeof window[word];
2672
- var rtContents = [{ value: '**' + word + '** — runtime global (`' + kind + '`)' }];
2673
- jsHoverCache[cacheKey] = { ts: Date.now(), contents: rtContents };
2674
- return { range: makeHoverRange(), contents: rtContents };
2675
- }
2676
- jsHoverCache[cacheKey] = { ts: Date.now(), contents: null };
2677
- return null;
2678
- }
2679
- // Same preference as Ctrl+click, so the file the hover names is
2680
- // the file the jump would take you to.
2681
- var r = preferCurrentFile(results, model._mbeditorPath) || results[0];
2682
- // Only declare as global when the definition is a top-level
2683
- // (Sprockets-global) declaration in a different file — nested and
2684
- // member definitions must not get a duplicate declare var.
2685
- if (r.topLevel && r.file !== model._mbeditorPath) addDiscoveredGlobal(word);
2686
- var fileRef = r.file + ':' + r.line;
2687
- var contents = [
2688
- { value: '```javascript\n' + r.snippet + '\n```', isTrusted: true },
2689
- { value: '<span style="opacity:0.55;font-size:0.9em;">' + fileRef + '</span>', isTrusted: true, supportHtml: true }
2690
- ];
2691
- jsHoverCache[cacheKey] = { ts: Date.now(), contents: contents };
2692
- return { range: makeHoverRange(), contents: contents };
2693
- }).catch(function() { return null; });
3034
+ var pos = selectionOrPosition || {};
3035
+ var line = pos.startLineNumber || pos.lineNumber || 1;
3036
+ var col = pos.startColumn || pos.column || 1;
3037
+ TabManager.openTab(path, path.split('/').pop(), line, null, false, col);
3038
+ return true;
2694
3039
  }
2695
3040
  });
2696
3041
 
2697
- // JS/JSX member completion provider: suggests properties/methods of workspace globals after '.'.
2698
- // Only looks up PascalCase/mixed-case identifiers or previously discovered globals.
2699
- var JS_MEMBERS_CACHE_TTL_MS = 60000;
2700
- monaco.languages.registerCompletionItemProvider('javascript', {
2701
- triggerCharacters: ['.'],
2702
- provideCompletionItems: function(model, position) {
2703
- var line = model.getLineContent(position.lineNumber);
2704
- var col = position.column - 2; // index of character just before the '.'
2705
- var end = col;
2706
- while (col >= 0 && /[a-zA-Z0-9_$]/.test(line[col])) col--;
2707
- var symbol = line.slice(col + 1, end + 1);
2708
- if (!symbol || symbol.length < 2) return { suggestions: [] };
2709
- if (!discoveredJsGlobals[symbol] && !/^[A-Z]/.test(symbol)) return { suggestions: [] };
2710
- if (typeof FileService === 'undefined' || !FileService.getJsMembers) return { suggestions: [] };
3042
+ // ── Prettier formatting providers ────────────────────────────────────────
3043
+ //
3044
+ // `formatOnPaste` has been on by default for a long time and did nothing
3045
+ // outside Ruby: Monaco acts on a paste only through a *range* formatting
3046
+ // provider, and none was registered. Registering one here is what makes
3047
+ // pasted blocks land correctly indented, and what formats the whole thing
3048
+ // when the paste fills an empty file (the pasted range is then the file).
3049
+ //
3050
+ // Prettier's own rangeStart/rangeEnd does the narrowing — it reprints the
3051
+ // smallest enclosing statement and leaves the rest byte-identical, so a
3052
+ // paste in the middle of a file does not reformat the file.
3053
+ var PRETTIER_LANGUAGE_PARSERS = {
3054
+ javascript: 'babel', json: 'json', css: 'css',
3055
+ scss: 'scss', less: 'less', html: 'html', markdown: 'markdown'
3056
+ };
2711
3057
 
2712
- // Only the raw member list is cached. Suggestions carry the insert
2713
- // `range`, which is where the completion is about to be written — the
2714
- // same trap the hover provider's makeHoverRange avoids. Cached
2715
- // suggestions inserted at the position of the invocation that filled
2716
- // the cache, so a hit anywhere else in the file was misapplied or
2717
- // silently dropped by Monaco.
2718
- function buildSuggestions(members) {
2719
- return members.map(function(m) {
2720
- return {
2721
- label: m.name,
2722
- kind: monaco.languages.CompletionItemKind.Method,
2723
- detail: symbol,
2724
- documentation: m.snippet,
2725
- insertText: m.name,
2726
- range: {
2727
- startLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
2728
- startColumn: position.column, endColumn: position.column
2729
- }
2730
- };
2731
- });
2732
- }
3058
+ // One edit spanning only what actually changed. A whole-document
3059
+ // replacement would work but throws away the cursor position and collapses
3060
+ // undo, which on every paste is very noticeable.
3061
+ function minimalEdit(model, oldText, newText) {
3062
+ if (oldText === newText) return [];
3063
+ var start = 0;
3064
+ var maxStart = Math.min(oldText.length, newText.length);
3065
+ while (start < maxStart && oldText.charCodeAt(start) === newText.charCodeAt(start)) start++;
3066
+ var oldEnd = oldText.length;
3067
+ var newEnd = newText.length;
3068
+ while (oldEnd > start && newEnd > start && oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
3069
+ oldEnd--;
3070
+ newEnd--;
3071
+ }
3072
+ return [{
3073
+ range: monaco.Range.fromPositions(model.getPositionAt(start), model.getPositionAt(oldEnd)),
3074
+ text: newText.slice(start, newEnd)
3075
+ }];
3076
+ }
2733
3077
 
2734
- var cached = jsMembersCache[symbol];
2735
- if (cached && (Date.now() - cached.ts) < JS_MEMBERS_CACHE_TTL_MS) {
2736
- return { suggestions: buildSuggestions(cached.members) };
2737
- }
3078
+ function prettierEdits(model, range) {
3079
+ var parser = PRETTIER_LANGUAGE_PARSERS[model.getLanguageId()];
3080
+ if (!parser || typeof runPrettier !== 'function') return Promise.resolve([]);
2738
3081
 
2739
- return FileService.getJsMembers(symbol)
2740
- .then(function(data) {
2741
- var members = (data && data.members) || [];
2742
- jsMembersCache[symbol] = { ts: Date.now(), members: members };
2743
- return { suggestions: buildSuggestions(members) };
2744
- }).catch(function() { return { suggestions: [] }; });
3082
+ var text = model.getValue();
3083
+ var prefs = (typeof EditorStore !== 'undefined' && EditorStore.getState().editorPrefs) || {};
3084
+ var extra = null;
3085
+ if (range) {
3086
+ extra = {
3087
+ rangeStart: model.getOffsetAt({ lineNumber: range.startLineNumber, column: range.startColumn }),
3088
+ rangeEnd: model.getOffsetAt({ lineNumber: range.endLineNumber, column: range.endColumn })
3089
+ };
2745
3090
  }
3091
+
3092
+ return runPrettier(text, prefs, parser, extra)
3093
+ .then(function (formatted) {
3094
+ // The model can have moved on while Prettier was working.
3095
+ if (model.isDisposed() || model.getValue() !== text) return [];
3096
+ return minimalEdit(model, text, formatted);
3097
+ })
3098
+ // A paste of half an expression will not parse. Leaving it alone is the
3099
+ // right answer — the diagnostics path reports the syntax error.
3100
+ ["catch"](function () { return []; });
3101
+ }
3102
+
3103
+ Object.keys(PRETTIER_LANGUAGE_PARSERS).forEach(function (languageId) {
3104
+ monaco.languages.registerDocumentRangeFormattingEditProvider(languageId, {
3105
+ displayName: 'Prettier',
3106
+ provideDocumentRangeFormattingEdits: function (model, range) {
3107
+ return prettierEdits(model, range);
3108
+ }
3109
+ });
3110
+ monaco.languages.registerDocumentFormattingEditProvider(languageId, {
3111
+ displayName: 'Prettier',
3112
+ provideDocumentFormattingEdits: function (model) {
3113
+ return prettierEdits(model, null);
3114
+ }
3115
+ });
2746
3116
  });
2747
3117
 
2748
3118
  // Vim-style fold-marker folding provider.
@@ -2774,6 +3144,17 @@
2774
3144
  });
2775
3145
  }
2776
3146
 
3147
+ function registerGlobalExtensions(monaco) {
3148
+ if (globalsRegistered) return;
3149
+ if (!monaco || !monaco.languages) return;
3150
+
3151
+ globalsRegistered = true;
3152
+
3153
+ registerJsProviders(monaco);
3154
+ registerRubyProviders(monaco);
3155
+ registerGenericProviders(monaco);
3156
+ }
3157
+
2777
3158
  window.MbeditorEditorPlugins = {
2778
3159
  registerGlobalExtensions: registerGlobalExtensions,
2779
3160
  attachEditorFeatures: attachEditorFeatures,
@@ -2787,6 +3168,13 @@
2787
3168
  markerFixKey: markerFixKey,
2788
3169
  // Exposed so the ERB gating can be asserted directly in system tests.
2789
3170
  isInsideErbTag: isInsideErbTag,
3171
+ // Exposed so the hover-provider comment/decoration guard can be asserted
3172
+ // directly in system tests.
3173
+ isRealHoverPosition: isRealHoverPosition,
3174
+ // Exposed so JSX prop completion can be asserted without the suggest widget.
3175
+ jsxPropsProvider: jsxPropsProvider,
3176
+ // Exposed so tag-pair linking can be asserted without driving a rename.
3177
+ linkedEditingRanges: linkedEditingRanges,
2790
3178
  runRubyEnter: function runRubyEnter(editor) {
2791
3179
  if (!editor || !editor.getModel) return false;
2792
3180
  return handleRubyEnter(editor, editor.getModel());