mbeditor 0.12.9 → 0.13.1

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +216 -1
  3. data/app/assets/javascripts/mbeditor/application.js +6 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +52 -1
  5. data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +104 -103
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
  9. data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
  10. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
  11. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +224 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +704 -270
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +193 -9
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +105 -77
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +434 -112
  18. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +112 -18
  20. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  21. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  22. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  23. data/app/assets/javascripts/mbeditor/search_service.js +50 -4
  24. data/app/assets/javascripts/mbeditor/tab_manager.js +66 -10
  25. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  26. data/app/assets/stylesheets/mbeditor/editor.css +389 -103
  27. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  28. data/app/channels/mbeditor/collaboration_channel.rb +8 -2
  29. data/app/channels/mbeditor/editor_channel.rb +6 -3
  30. data/app/controllers/mbeditor/application_controller.rb +5 -0
  31. data/app/controllers/mbeditor/editors_controller.rb +155 -29
  32. data/app/controllers/mbeditor/git_controller.rb +3 -3
  33. data/app/controllers/mbeditor/logs_controller.rb +3 -1
  34. data/app/services/mbeditor/collaboration_doc_store.rb +49 -3
  35. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  36. data/app/services/mbeditor/editor_state_service.rb +36 -26
  37. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  38. data/app/services/mbeditor/file_operation_service.rb +38 -3
  39. data/app/services/mbeditor/file_tree_service.rb +30 -2
  40. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  41. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  42. data/app/services/mbeditor/git_diff_service.rb +5 -1
  43. data/app/services/mbeditor/git_info_service.rb +20 -8
  44. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  45. data/app/services/mbeditor/git_service.rb +65 -15
  46. data/app/services/mbeditor/js_definition_service.rb +3 -1
  47. data/app/services/mbeditor/js_globals_service.rb +18 -4
  48. data/app/services/mbeditor/js_members_service.rb +3 -2
  49. data/app/services/mbeditor/js_program_service.rb +15 -5
  50. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  51. data/app/services/mbeditor/process_runner.rb +42 -12
  52. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  53. data/app/services/mbeditor/route_service.rb +45 -8
  54. data/app/services/mbeditor/rubocop_run_service.rb +98 -0
  55. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  56. data/app/services/mbeditor/schema_service.rb +73 -66
  57. data/app/services/mbeditor/search_replace_service.rb +42 -7
  58. data/app/services/mbeditor/test_runner_service.rb +45 -10
  59. data/lib/mbeditor/cable_log_filter.rb +8 -2
  60. data/lib/mbeditor/configuration.rb +4 -1
  61. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  62. data/lib/mbeditor/engine.rb +22 -3
  63. data/lib/mbeditor/exception_log.rb +2 -2
  64. data/lib/mbeditor/pending_migrations.rb +33 -0
  65. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  66. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  67. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  68. data/lib/mbeditor/route_map.rb +1 -0
  69. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  70. data/lib/mbeditor/version.rb +1 -1
  71. data/lib/tasks/mbeditor.rake +23 -0
  72. metadata +8 -2
@@ -58,6 +58,35 @@
58
58
  var jsHoverCache = {};
59
59
  var jsMembersCache = {};
60
60
 
61
+ // Symbols the workspace lookup came back empty on — a component or variable
62
+ // that genuinely does not exist. Their TS2304 is graded Error rather than
63
+ // Warning: "Cannot find name" is only ever a guess until the lookup answers,
64
+ // because host-app globals are invisible to the language service, but once
65
+ // it has answered there is nothing tentative left about it.
66
+ //
67
+ // Held per page rather than per model: the answer is about the workspace,
68
+ // and every open file referencing the name is equally wrong.
69
+ var unknownJsSymbols = {};
70
+ // Assigned by registerGlobalExtensions, which owns the marker rules. A
71
+ // negative lookup changes no model and no extra lib, so nothing would
72
+ // re-fire onDidChangeMarkers on its own and the grade would not be applied
73
+ // until the next keystroke.
74
+ var repatchJsMarkerSeverities = null;
75
+ var _unknownRepatchTimer = null;
76
+
77
+ function noteUnknownJsSymbol(sym) {
78
+ if (unknownJsSymbols[sym]) return;
79
+ unknownJsSymbols[sym] = true;
80
+ // Coalesced for the same reason addExtraLib is: a file full of unknown
81
+ // names resolves them one after another, and re-grading every open model
82
+ // per answer is the same wasted pass repeated.
83
+ if (_unknownRepatchTimer) return;
84
+ _unknownRepatchTimer = setTimeout(function () {
85
+ _unknownRepatchTimer = null;
86
+ if (repatchJsMarkerSeverities) repatchJsMarkerSeverities();
87
+ }, 300);
88
+ }
89
+
61
90
  // Enumerate window for user-defined globals and return a TypeScript declaration string.
62
91
  // Sprockets exposes every top-level var/function as a window property before Monaco
63
92
  // initialises, so scanning at registration time captures all components and helpers.
@@ -150,10 +179,22 @@
150
179
  FileService.getJsDefinition(job.sym)
151
180
  .then(function (data) {
152
181
  var results = data && data.results;
153
- if (results && results.length && results[0].file !== job.modelPath) {
182
+ var hit = results && results[0];
183
+ // `topLevel` matters, and its absence was a hole: any match anywhere
184
+ // counted, so a `var x` nested inside a function in an unrelated file
185
+ // earned `x` an ambient `declare var` and the reference here stopped
186
+ // being reported at all. A local in another file is not in scope in
187
+ // this one. The hover and goto-definition paths always required
188
+ // top-level; this one is now consistent with them.
189
+ if (hit && hit.topLevel && hit.file !== job.modelPath) {
154
190
  addDiscoveredGlobal(job.sym);
155
- } else if (!results || !results.length) {
156
- if (isRuntimeWindowGlobal(job.sym)) addDiscoveredGlobal(job.sym);
191
+ } else if (isRuntimeWindowGlobal(job.sym)) {
192
+ addDiscoveredGlobal(job.sym);
193
+ } else {
194
+ // Nothing declares this name: not the TypeScript program, not the
195
+ // workspace globals, not a top-level definition anywhere on disk,
196
+ // not a live window property. The answer is in — it does not exist.
197
+ noteUnknownJsSymbol(job.sym);
157
198
  }
158
199
  })
159
200
  .then(done, done);
@@ -161,6 +202,11 @@
161
202
 
162
203
  function queueJsGlobalLookup(sym, modelPath) {
163
204
  if (jsLookupQueue.length >= JS_LOOKUP_QUEUE_MAX) return;
205
+ // Marked here rather than at the call site: marking before the cap check
206
+ // spent the symbol's one attempt on a job that was then dropped, so a name
207
+ // unlucky enough to arrive while the queue was full was never looked up at
208
+ // all — not on the next keystroke, not for the rest of the page's life.
209
+ attemptedJsGlobals[sym] = true;
164
210
  jsLookupQueue.push({ sym: sym, modelPath: modelPath });
165
211
  pumpJsLookupQueue();
166
212
  }
@@ -233,6 +279,11 @@
233
279
  data.symbols.forEach(function (s) {
234
280
  var name = s && s.name;
235
281
  if (!name || !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) return;
282
+ // This name exists now, whatever an earlier lookup concluded. Writing
283
+ // the component you were being told was missing is the normal way that
284
+ // happens, and the verdict has to expire with the evidence — this list
285
+ // is refreshed on every files_changed.
286
+ delete unknownJsSymbols[name];
236
287
  if (REACT_MINI_UMD_GLOBALS[name]) return;
237
288
  if (discoveredJsGlobals[name]) return; // already in discovered-globals.d.ts
238
289
  // The program already declares this one, with a real type.
@@ -257,13 +308,23 @@
257
308
  var mts = monaco && monaco.languages && monaco.languages.typescript;
258
309
  if (!mts || !mts.javascriptDefaults) return;
259
310
  if (typeof FileService === 'undefined' || !FileService.getJsProgramFile) return;
260
- (paths || []).forEach(function (path) {
261
- if (!path || !/\.(js|jsx|ts|tsx)$/i.test(path)) return;
262
- FileService.getJsProgramFile(path).then(function (data) {
311
+ var wanted = (paths || []).filter(function (path) {
312
+ return path && /\.(js|jsx|ts|tsx)$/i.test(path);
313
+ });
314
+ if (!wanted.length) return;
315
+
316
+ // Collected first, applied in one synchronous pass. addExtraLib invalidates
317
+ // the TypeScript worker and re-validates every open model, so applying a
318
+ // burst of saves one response at a time paid that cost per file — the same
319
+ // reason flushDiscoveredGlobals coalesces.
320
+ Promise.all(wanted.map(function (path) {
321
+ return FileService.getJsProgramFile(path).catch(function () { return null; });
322
+ })).then(function (responses) {
323
+ responses.forEach(function (data) {
263
324
  if (!data || !data.ok || !data.file) return;
264
325
  programPaths[data.file.path] = true;
265
326
  mts.javascriptDefaults.addExtraLib(data.file.content, programUri(data.file.path));
266
- }).catch(function () {});
327
+ });
267
328
  });
268
329
  }
269
330
 
@@ -344,21 +405,41 @@
344
405
 
345
406
  // Send a raw-passthrough request and hand back result.result, or null when
346
407
  // the legacy path should run instead.
347
- function rawRubyLsp(lspMethod, model, position) {
348
- return tryRubyLsp(lspMethod, model, position || { lineNumber: 1, column: 1 });
408
+ function rawRubyLsp(lspMethod, model, position, token) {
409
+ return tryRubyLsp(lspMethod, model, position || { lineNumber: 1, column: 1 }, null, token);
349
410
  }
350
411
 
351
412
  // Requests that go through RuboCop rather than just Prism need more than the
352
413
  // 6s default: the server's own budget for them is 10s.
353
414
  var LSP_SLOW_METHODS = { diagnostics: 15000, formatting: 15000 };
354
415
 
355
- function tryRubyLsp(lspMethod, model, position, extraBody) {
416
+ // `token` is Monaco's CancellationToken, when the calling provider has one.
417
+ // Every provider fires on a gesture the user can abandon — moving the cursor
418
+ // off a word, typing another character — and Monaco cancels the outstanding
419
+ // request when that happens. Without this the request ran to completion
420
+ // anyway: a subprocess on the server, and the answer thrown away here.
421
+ function tryRubyLsp(lspMethod, model, position, extraBody, token) {
356
422
  try {
357
423
  if (!window.MBEDITOR_RUBY_LSP_AVAILABLE || lspBackedOff()) return Promise.resolve(null);
358
424
  if (typeof FileService === 'undefined' || !FileService.rubyLspRequest) return Promise.resolve(null);
359
425
  if (!model || !model._mbeditorPath || !position) return Promise.resolve(null);
360
426
  if (model.getValueLength() > 5 * 1024 * 1024) return Promise.resolve(null);
427
+ // Nothing in the buffer means nothing to fold, outline, highlight or
428
+ // resolve, so every one of these is a round trip that can only come back
429
+ // empty. A newly created file is opened while empty, so this is the
430
+ // common case rather than an edge one. null is already this function's
431
+ // "no result" answer, so callers need no change.
432
+ if (model.getValueLength() === 0) return Promise.resolve(null);
433
+ if (token && token.isCancellationRequested) return Promise.resolve(null);
434
+
361
435
  var config = LSP_SLOW_METHODS[lspMethod] ? { timeout: LSP_SLOW_METHODS[lspMethod] } : null;
436
+ var controller = (token && typeof AbortController !== 'undefined') ? new AbortController() : null;
437
+ var cancelSub = null;
438
+ if (controller && token.onCancellationRequested) {
439
+ cancelSub = token.onCancellationRequested(function () { controller.abort(); });
440
+ config = Object.assign({}, config, { signal: controller.signal });
441
+ }
442
+
362
443
  return FileService.rubyLspRequest(lspMethod, model._mbeditorPath, model.getValue(),
363
444
  position.lineNumber, position.column, config, extraBody)
364
445
  .then(function (data) {
@@ -380,6 +461,12 @@
380
461
  .catch(function (err) {
381
462
  noteLspFailure(err);
382
463
  return null;
464
+ })
465
+ // Settled either way: drop the cancellation listener (Monaco's token
466
+ // outlives the request) and answer nothing to a cancelled caller.
467
+ .then(function (result) {
468
+ if (cancelSub && cancelSub.dispose) cancelSub.dispose();
469
+ return (token && token.isCancellationRequested) ? null : result;
383
470
  });
384
471
  } catch (e) {
385
472
  return Promise.resolve(null);
@@ -459,6 +546,43 @@
459
546
  return null;
460
547
  }
461
548
 
549
+ // The definition in the file you are already in wins. Sprockets puts every
550
+ // top-level name in one global scope, so the same helper defined in two
551
+ // files is ordinary rather than exceptional — and the server returns them in
552
+ // scan order, which is alphabetical by path and has nothing to do with which
553
+ // one you meant. Taking the first sent you to whichever file happened to
554
+ // sort earliest: Monaco's own provider would reveal the local definition,
555
+ // then this lookup landed and navigated away from it.
556
+ function preferCurrentFile(results, currentPath) {
557
+ if (!currentPath) return null;
558
+ for (var i = 0; i < results.length; i++) {
559
+ if (results[i].file === currentPath) return results[i];
560
+ }
561
+ return null;
562
+ }
563
+
564
+ // Candidates for the peek list, one entry per file. Several hits in the same
565
+ // file are the same answer as far as "which file did you mean" goes, and a
566
+ // picker that lists the same path three times is a worse question.
567
+ function distinctByFile(results) {
568
+ var seen = Object.create(null);
569
+ var out = [];
570
+ results.forEach(function (r) {
571
+ if (seen[r.file]) return;
572
+ seen[r.file] = true;
573
+ out.push(r);
574
+ });
575
+ return out;
576
+ }
577
+
578
+ // Monaco's peek list is fed by a DefinitionProvider, and it does NOT dedupe
579
+ // across providers — measured: a provider returning the same location the
580
+ // TypeScript worker resolves turns an ordinary local jump into a two-entry
581
+ // picker for one definition. So this provider stays silent except for the
582
+ // one case that actually needs it, which navigateToJsWord hands over here
583
+ // immediately before asking Monaco to reveal.
584
+ var pendingJsDefinitionPeek = null;
585
+
462
586
  function navigateToJsWord(editor, word, parent) {
463
587
  if (typeof FileService === 'undefined' || !FileService.getJsDefinition) return Promise.resolve(false);
464
588
  var currentPath = editor.getModel && editor.getModel() && editor.getModel()._mbeditorPath;
@@ -469,11 +593,40 @@
469
593
  if (isRuntimeWindowGlobal(word)) addDiscoveredGlobal(word);
470
594
  return false;
471
595
  }
472
- var r = results[0];
596
+ var here = preferCurrentFile(results, currentPath);
597
+ var elsewhere = distinctByFile(results.filter(function (x) { return x.file !== currentPath; }));
598
+
599
+ // Defined right here: that is the answer, and asking which one you
600
+ // meant would be noise.
601
+ var r = here || elsewhere[0];
473
602
  // Only declare as a global when the definition is itself a top-level
474
603
  // (Sprockets-global) declaration in a different file. Nested/member
475
604
  // definitions must not get an ambient declare var.
476
605
  if (r.topLevel && r.file !== currentPath) addDiscoveredGlobal(word);
606
+
607
+ // Genuinely ambiguous — the same name declared at top level in more
608
+ // than one other file — so choose rather than guess.
609
+ if (!here && elsewhere.length > 1) {
610
+ // Stamped with the model and cursor position the reveal below will
611
+ // ask about, so a candidate list whose reveal never fired cannot
612
+ // surface on an unrelated gesture later.
613
+ var gestureModel = editor.getModel && editor.getModel();
614
+ var gesturePos = editor.getPosition && editor.getPosition();
615
+ pendingJsDefinitionPeek = {
616
+ uri: gestureModel ? gestureModel.uri.toString() : '',
617
+ lineNumber: gesturePos ? gesturePos.lineNumber : 0,
618
+ column: gesturePos ? gesturePos.column : 0,
619
+ locations: elsewhere.map(function (x) {
620
+ return {
621
+ uri: lspUri(x.file),
622
+ range: new window.monaco.Range(x.line || 1, 1, x.line || 1, 1)
623
+ };
624
+ })
625
+ };
626
+ editor.trigger('mbeditor', 'editor.action.revealDefinition', null);
627
+ return true;
628
+ }
629
+
477
630
  if (typeof TabManager !== 'undefined' && TabManager.openTab) {
478
631
  TabManager.openTab(r.file, r.file.split('/').pop(), r.line);
479
632
  }
@@ -816,47 +969,34 @@
816
969
  return handled;
817
970
  });
818
971
 
819
- // ── Indent on paste ──────────────────────────────────────────────────────
972
+ // ── Paste is left alone, deliberately ────────────────────────────────────
820
973
  //
821
- // Pasted code is re-indented to match where it landed, and nothing else is
822
- // touched. This deliberately does NOT run the formatter: routing paste
823
- // through Prettier reprinted the whole enclosing statement (and, for a
824
- // paste that filled the document, every line of it), so pasting a snippet
825
- // rewrote code the user never touched and buried the paste in an unrelated
826
- // diff.
974
+ // There was an indent-on-paste handler here that ran
975
+ // `editor.action.reindentselectedlines` over the pasted range. It did not
976
+ // adjust the paste it recomputed every line's indent from the language's
977
+ // indentation rules, which model bracket depth and nothing else. Any
978
+ // structure those rules cannot derive was therefore destroyed on arrival:
827
979
  //
828
- // `editor.action.reindentselectedlines` is Monaco's own re-indenter — it
829
- // uses the language's indentation rules, the same ones that already run
830
- // when you press Enter, and it only ever changes leading whitespace.
831
- // Monaco's `formatOnPaste` option stays off (EditorPanel passes false):
832
- // its contribution declines to run here anyway.
833
- var pasteDisposable = editor.onDidPaste(function (e) {
834
- var prefs = (typeof EditorStore !== 'undefined' && EditorStore.getState().editorPrefs) || {};
835
- if (prefs.indentOnPaste === false) return;
836
-
837
- var pasteModel = editor.getModel();
838
- if (!pasteModel || pasteModel.isDisposed()) return;
839
-
840
- var action = editor.getAction('editor.action.reindentselectedlines');
841
- if (!action) return;
842
-
843
- // The action works on the selection, so point it at the pasted range and
844
- // put the cursor back where the paste left it.
845
- var restore = editor.getSelections();
846
- editor.setSelection(e.range);
847
- action.run()["catch"](function () {})["finally"](function () {
848
- var m = editor.getModel();
849
- if (restore && restore.length && m && !m.isDisposed()) editor.setSelections(restore);
850
- });
851
- });
852
-
980
+ // <Row> pasted <Row>
981
+ // <Cell a={1} /> ────► <Cell a={1} />
982
+ // <Cell b={2} /> <Cell b={2} />
983
+ // </Row> </Row>
984
+ //
985
+ // JSX nesting flattened; the same happened to method chains, ternaries and
986
+ // wrapped arguments. It was right only for brace-structured code, which in
987
+ // a React codebase is the minority of what gets pasted.
988
+ //
989
+ // Preserving what was copied is what every other editor does by default,
990
+ // and it is never destructive: the worst case is a block at the wrong
991
+ // depth, which Tab / Shift+Tab on the still-selected paste fixes, and
992
+ // Format fixes properly. Monaco's own `formatOnPaste` stays off too
993
+ // (EditorPanel passes false) — its contribution declines to run here.
853
994
  return {
854
995
  dispose: function dispose() {
855
996
  if (keydownDisposable) keydownDisposable.dispose();
856
997
  if (emmetTabDisposable) emmetTabDisposable.dispose();
857
998
  if (jsGotoMouseDisposable) jsGotoMouseDisposable.dispose();
858
999
  if (jsGotoActionDisposable) jsGotoActionDisposable.dispose();
859
- if (pasteDisposable) pasteDisposable.dispose();
860
1000
  contentDisposable.dispose();
861
1001
  }
862
1002
  };
@@ -874,7 +1014,15 @@
874
1014
  monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
875
1015
  noSemanticValidation: false,
876
1016
  noSyntaxValidation: false,
877
- noSuggestionDiagnostics: false
1017
+ // Suggestion diagnostics are TypeScript's advice to a TypeScript
1018
+ // author: nearly all of them on plain JS/JSX are 7044, "Parameter 'x'
1019
+ // implicitly has an 'any' type, but a better type may be inferred from
1020
+ // usage" — one per untyped parameter in the file, in a language that
1021
+ // has no types to annotate. They arrive as Hint/Info, which the marker
1022
+ // patcher below never inspected (it only ever filtered Errors), so
1023
+ // every one of them reached the Problems panel. Semantic and syntax
1024
+ // checking, which is what actually catches mistakes here, stay on.
1025
+ noSuggestionDiagnostics: true
878
1026
  // No diagnosticCodesToIgnore here on purpose: JS/JSX markers are
879
1027
  // filtered by category in the marker patcher below (see JS_KEEP_CODES),
880
1028
  // which is a closed rule rather than a list that grows by one code
@@ -1037,7 +1185,12 @@
1037
1185
  }
1038
1186
  if (typeof WebSocketService !== 'undefined' && WebSocketService.onFilesChanged) {
1039
1187
  WebSocketService.onFilesChanged(function (payload) {
1040
- if (payload && payload.paths) refreshProgramPaths(monaco, payload.paths);
1188
+ if (payload && payload.paths) {
1189
+ refreshProgramPaths(monaco, payload.paths);
1190
+ // An edited file's include list is wrong the moment it is saved,
1191
+ // and the TTL alone would keep serving it for another 30 s.
1192
+ payload.paths.forEach(function (p) { delete includesCache[p]; });
1193
+ }
1041
1194
  refreshWorkspaceGlobals();
1042
1195
  });
1043
1196
  }
@@ -1080,11 +1233,51 @@
1080
1233
  //
1081
1234
  // .ts/.tsx keeps full checking: there the types are hand-written, so a
1082
1235
  // type error is a statement about code the author actually wrote.
1083
- var JS_KEEP_CODES = { '2304': true, '6133': true };
1236
+ //
1237
+ // ── Call-shape diagnostics ───────────────────────────────────────────
1238
+ //
1239
+ // These are kept even though the blanket rule above drops type
1240
+ // diagnostics, because they are not inference guesses: they compare a
1241
+ // call against a signature the author wrote. 2554 counts declared
1242
+ // parameters. The JSX codes only have anything to say when the
1243
+ // component carries a JSDoc `@param {{...}} props` annotation — with a
1244
+ // bare `function Card(props)` the props type is `any` and nothing is
1245
+ // reported at all, so this can only ever fire against a contract
1246
+ // someone stated on purpose.
1247
+ //
1248
+ // Measured before enabling, over 49 real files (the gem's own 39 JS/JSX
1249
+ // sources plus the sample workspace): zero occurrences of any of them,
1250
+ // including in spread_props_test.jsx — the spread case that defeated
1251
+ // the old denylist. 2345 (argument type) is deliberately NOT here: it
1252
+ // fired 3 times on that corpus, every one of them on `isNaN(dateObj)`,
1253
+ // which is a working idiom. That is the arbitrary-inference category
1254
+ // this filter exists to keep out.
1255
+ 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 };
1084
1257
  var JS_SYNTAX_CODE = /^(?:1\d{3}|17\d{3})$/;
1085
- var JS_WARN_CODES = { '2304': true, '6133': true };
1258
+ var JS_WARN_CODES = { '2304': true, '6133': true, '2554': true, '2769': true, '2741': true, '2322': true };
1086
1259
  var TS_WARN_CODES = { '6133': true };
1087
1260
 
1261
+ // Graded by what the mistake does when the code runs, which is the only
1262
+ // distinction worth drawing here. An argument past the end of the
1263
+ // parameter list is dropped; a prop the component never reads is inert.
1264
+ // A required prop that was never passed arrives as undefined, and a
1265
+ // value of the wrong type flows straight into the component — both of
1266
+ // those break something, so they read as errors.
1267
+ //
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.
1271
+ var JS_UNKNOWN_PROPERTY = /does not exist on type/;
1272
+ var JS_MISSING_REQUIRED = /is missing in type .* but required in type/;
1273
+ function callDiagnosticSeverity(code, message) {
1274
+ if (code === '2554') return monaco.MarkerSeverity.Warning;
1275
+ var text = message || '';
1276
+ if (JS_MISSING_REQUIRED.test(text)) return monaco.MarkerSeverity.Error;
1277
+ if (JS_UNKNOWN_PROPERTY.test(text)) return monaco.MarkerSeverity.Warning;
1278
+ return monaco.MarkerSeverity.Error;
1279
+ }
1280
+
1088
1281
  function keepJsMarker(marker) {
1089
1282
  if (marker.severity !== monaco.MarkerSeverity.Error) return true;
1090
1283
  var code = String(marker.code == null ? '' : marker.code);
@@ -1109,8 +1302,17 @@
1109
1302
  return JS_ASSIGN_AFTER.test(rest);
1110
1303
  }
1111
1304
 
1305
+ // "Cannot find name 'Foo'." → "Foo"
1306
+ function missingNameOf(message) {
1307
+ var match = message && message.match(/Cannot find name '([^']+)'/);
1308
+ return match ? match[1] : null;
1309
+ }
1310
+
1112
1311
  var _severityPatchActive = false;
1113
- monaco.editor.onDidChangeMarkers(function(uris) {
1312
+ // jsMarkers, when given, is a uri-string -> javascript markers map the
1313
+ // caller has already fetched; getModelMarkers filters the whole marker
1314
+ // store per call and the TS2304 sweep below wants the same list.
1315
+ function patchSeverities(uris, jsMarkers) {
1114
1316
  if (_severityPatchActive) return;
1115
1317
  _severityPatchActive = true;
1116
1318
  try {
@@ -1121,15 +1323,48 @@
1121
1323
  { owner: 'javascript', keep: keepJsMarker, warn: JS_WARN_CODES },
1122
1324
  { owner: 'typescript', keep: null, warn: TS_WARN_CODES }
1123
1325
  ].forEach(function(entry) {
1124
- var markers = monaco.editor.getModelMarkers({ resource: uri, owner: entry.owner });
1326
+ var markers = (entry.owner === 'javascript' && jsMarkers && jsMarkers[uri.toString()]) ||
1327
+ monaco.editor.getModelMarkers({ resource: uri, owner: entry.owner });
1125
1328
  var patched = markers.filter(function(m) {
1126
1329
  return entry.keep ? entry.keep(m) : true;
1127
1330
  }).map(function(m) {
1128
- if (m.severity !== monaco.MarkerSeverity.Error || !entry.warn[String(m.code)]) return m;
1129
- if (String(m.code) === '2304' && isUndeclaredAssignment(model, m)) {
1130
- return m.message.indexOf(JS_IMPLICIT_GLOBAL_HINT) !== -1 ? m
1131
- : Object.assign({}, m, { message: m.message + JS_IMPLICIT_GLOBAL_HINT });
1331
+ var code = String(m.code);
1332
+ if (!entry.warn[code]) return m;
1333
+
1334
+ // 2304 is graded from the evidence rather than from what the
1335
+ // marker currently says, because the verdict can move in both
1336
+ // directions: it starts as a Warning and becomes an Error once
1337
+ // the workspace lookup reports the name does not exist. A rule
1338
+ // that only ever fired on an incoming Error could never make
1339
+ // that second move — it had already downgraded the marker on
1340
+ // the first pass.
1341
+ if (code === '2304') {
1342
+ if (isUndeclaredAssignment(model, m)) {
1343
+ return m.message.indexOf(JS_IMPLICIT_GLOBAL_HINT) !== -1 ? m
1344
+ : Object.assign({}, m, {
1345
+ severity: monaco.MarkerSeverity.Error,
1346
+ message: m.message + JS_IMPLICIT_GLOBAL_HINT
1347
+ });
1348
+ }
1349
+ var sym = missingNameOf(m.message);
1350
+ // Lenient only while the answer is outstanding: host-app
1351
+ // globals are invisible to the language service, so an
1352
+ // unresolved name is a question until the lookup answers it.
1353
+ var want = (sym && unknownJsSymbols[sym])
1354
+ ? monaco.MarkerSeverity.Error
1355
+ : monaco.MarkerSeverity.Warning;
1356
+ return m.severity === want ? m : Object.assign({}, m, { severity: want });
1357
+ }
1358
+
1359
+ // Graded from the evidence for the same reason 2304 is: the
1360
+ // verdict is a property of the message, not of whatever the
1361
+ // marker happens to say on this pass.
1362
+ if (JS_CALL_CODES[code] && entry.owner === 'javascript') {
1363
+ var callWant = callDiagnosticSeverity(code, m.message);
1364
+ return m.severity === callWant ? m : Object.assign({}, m, { severity: callWant });
1132
1365
  }
1366
+
1367
+ if (m.severity !== monaco.MarkerSeverity.Error) return m;
1133
1368
  return Object.assign({}, m, { severity: monaco.MarkerSeverity.Warning });
1134
1369
  });
1135
1370
  // Re-applying an unchanged set would re-enter this handler
@@ -1143,23 +1378,38 @@
1143
1378
  } finally {
1144
1379
  _severityPatchActive = false;
1145
1380
  }
1381
+ }
1382
+
1383
+ // A lookup answering "no such symbol" changes no model, so it produces no
1384
+ // marker event of its own — the new grade has to be pushed over the open
1385
+ // models explicitly.
1386
+ repatchJsMarkerSeverities = function () {
1387
+ patchSeverities(monaco.editor.getModels().map(function (m) { return m.uri; }));
1388
+ };
1389
+
1390
+ monaco.editor.onDidChangeMarkers(function(uris) {
1391
+ // One fetch per uri, shared by the severity patch and the TS2304 sweep.
1392
+ var jsMarkers = {};
1393
+ uris.forEach(function (uri) {
1394
+ jsMarkers[uri.toString()] = monaco.editor.getModelMarkers({ resource: uri, owner: 'javascript' });
1395
+ });
1396
+
1397
+ patchSeverities(uris, jsMarkers);
1146
1398
 
1147
1399
  // Auto-resolve TS2304 ("Cannot find name 'X'") for JS files by
1148
1400
  // looking up the symbol in the workspace. If found, addDiscoveredGlobal
1149
- // declares it via addExtraLib and Monaco re-validates, removing the warning.
1401
+ // declares it via addExtraLib and Monaco re-validates, removing the
1402
+ // warning; if not found, the name is recorded as unknown and its
1403
+ // marker is re-graded to an Error.
1150
1404
  if (typeof FileService !== 'undefined' && FileService.getJsDefinition) {
1151
1405
  uris.forEach(function(uri) {
1152
1406
  var model = monaco.editor.getModel(uri);
1153
1407
  if (!model) return;
1154
- var markers = monaco.editor.getModelMarkers({ resource: uri, owner: 'javascript' });
1155
- markers.forEach(function(m) {
1408
+ (jsMarkers[uri.toString()] || []).forEach(function(m) {
1156
1409
  if (String(m.code) !== '2304') return;
1157
- // Extract symbol name from message: "Cannot find name 'ReactWindow'."
1158
- var match = m.message && m.message.match(/Cannot find name '([^']+)'/);
1159
- if (!match) return;
1160
- var sym = match[1];
1410
+ var sym = missingNameOf(m.message);
1411
+ if (!sym) return;
1161
1412
  if (attemptedJsGlobals[sym]) return;
1162
- attemptedJsGlobals[sym] = true;
1163
1413
  queueJsGlobalLookup(sym, model._mbeditorPath);
1164
1414
  });
1165
1415
  });
@@ -1177,11 +1427,14 @@
1177
1427
  });
1178
1428
  }
1179
1429
 
1180
- // Monaco ships no indentationRules for JavaScript, which makes
1181
- // `editor.action.reindentselectedlines`what indent-on-paste runs a
1182
- // silent no-op in JS/JSX files. These are VS Code's own JS rules.
1183
- // setLanguageConfiguration merges, so this adds indentation without
1184
- // disturbing the brackets/comments config the bundle already registers.
1430
+ // These drive `editor.action.reindentLines`, which the Format command
1431
+ // falls back to for .ts/.tsxthe only open languages with no Prettier
1432
+ // parser. They do NOT affect typing: Monaco's built-in bracket handling
1433
+ // already produces the same indent on Enter with or without them, verified
1434
+ // case by case. Nothing else should be routed through them, because
1435
+ // bracket depth is all they model — JSX nesting and continuation lines
1436
+ // (method chains, ternaries, wrapped arguments) are invisible to them, and
1437
+ // re-indenting from these rules alone flattens both.
1185
1438
  ['javascript', 'typescript'].forEach(function (langId) {
1186
1439
  monaco.languages.setLanguageConfiguration(langId, {
1187
1440
  indentationRules: {
@@ -1231,7 +1484,13 @@
1231
1484
  cases: {
1232
1485
  '$2~(?i:SQL)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'sql' },
1233
1486
  '$2~(?i:HTML?|ERB)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'html' },
1234
- '$2~(?i:JS|JAVASCRIPT)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'javascript' },
1487
+ // JSX rides the javascript tokenizer: Monaco registers no
1488
+ // separate jsx language, and its TS-derived grammar lexes JSX
1489
+ // bodies fine. The guard is anchored (^...$), so plain `JS`
1490
+ // never matched `JSX` and it fell through to the generic
1491
+ // heredoc — the whole block painted as one string.
1492
+ '$2~(?i:JSX?|JAVASCRIPT)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'javascript' },
1493
+ '$2~(?i:TSX?|TYPESCRIPT)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'typescript' },
1235
1494
  '$2~(?i:CSS)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'css' },
1236
1495
  '$2~(?i:SCSS)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'scss' },
1237
1496
  '$2~(?i:JSON)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'json' },
@@ -1570,7 +1829,7 @@
1570
1829
  command: {
1571
1830
  id: 'mbeditor.applyRubocopFix',
1572
1831
  title: 'Apply RuboCop fix for ' + cop,
1573
- arguments: [model, cop, model.getValue(), modelPath]
1832
+ arguments: [model, cop, modelPath]
1574
1833
  }
1575
1834
  });
1576
1835
  });
@@ -1591,9 +1850,14 @@
1591
1850
  });
1592
1851
 
1593
1852
  // Command handler that fetches the fix from the backend and applies it.
1594
- monaco.editor.registerCommand('mbeditor.applyRubocopFix', function(_accessor, model, copName, code, modelPath) {
1853
+ // The buffer is read here, at click time, rather than captured in the
1854
+ // action's arguments: a whole copy of the document per marker per lightbulb
1855
+ // pass is wasteful, and the copy went stale — the server autocorrected text
1856
+ // the buffer had already moved past, and the returned edit landed on the
1857
+ // new one.
1858
+ monaco.editor.registerCommand('mbeditor.applyRubocopFix', function(_accessor, model, copName, modelPath) {
1595
1859
  if (typeof FileService === 'undefined' || !FileService.quickFixOffense) return;
1596
- FileService.quickFixOffense(modelPath, code, copName).then(function(data) {
1860
+ FileService.quickFixOffense(modelPath, model.getValue(), copName).then(function(data) {
1597
1861
  if (!data || !data.fix) return;
1598
1862
  var fix = data.fix;
1599
1863
  model.pushEditOperations([], [{
@@ -1613,6 +1877,24 @@
1613
1877
 
1614
1878
  // ── ruby-lsp navigation ──────────────────────────────────────────────────
1615
1879
 
1880
+ // Serves exactly one thing: the candidate list navigateToJsWord hands over
1881
+ // when a name is declared at top level in several other files. It is empty
1882
+ // the rest of the time, so the TypeScript worker remains the only voice on
1883
+ // an ordinary jump and a local definition still goes straight there —
1884
+ // Monaco merges providers without deduping, so a second opinion here would
1885
+ // show a picker listing one definition twice.
1886
+ monaco.languages.registerDefinitionProvider('javascript', {
1887
+ provideDefinition: function (model, position) {
1888
+ var pending = pendingJsDefinitionPeek;
1889
+ pendingJsDefinitionPeek = null;
1890
+ if (!pending) return null;
1891
+ if (pending.uri !== model.uri.toString() ||
1892
+ pending.lineNumber !== position.lineNumber ||
1893
+ pending.column !== position.column) return null;
1894
+ return pending.locations;
1895
+ }
1896
+ });
1897
+
1616
1898
  // Teaches Monaco how to open a file:// resource in this editor. Without it
1617
1899
  // every provider below can find a location but nothing can go there:
1618
1900
  // peek-definition, the references widget and Ctrl+hover previews all route
@@ -1707,10 +1989,10 @@
1707
1989
  // text grep, not a reference index — so an empty list is the honest answer
1708
1990
  // when ruby-lsp can't help.
1709
1991
  monaco.languages.registerReferenceProvider('ruby', {
1710
- provideReferences: function provideReferences(model, position) {
1992
+ provideReferences: function provideReferences(model, position, _context, token) {
1711
1993
  if (!rubyNavigableWord(model, position, false)) return null;
1712
1994
 
1713
- return rawRubyLsp('references', model, position).then(function (locations) {
1995
+ return rawRubyLsp('references', model, position, token).then(function (locations) {
1714
1996
  if (!Array.isArray(locations)) return null;
1715
1997
  return locations.filter(function (loc) { return loc && loc.uri; }).map(function (loc) {
1716
1998
  return { uri: lspUri(loc.uri), range: lspRange(loc.range) };
@@ -1722,13 +2004,28 @@
1722
2004
  // Occurrences of the symbol under the cursor. Monaco has a built-in
1723
2005
  // word-match highlighter, but it cannot tell a local named `id` from a
1724
2006
  // method named `id`; ruby-lsp resolves the actual symbol.
2007
+ //
2008
+ // This one fires on every cursor move, so a caret walking through a word
2009
+ // would otherwise cost a request per keypress. One memo entry is enough:
2010
+ // the repeat is always the immediately preceding position. Keyed on the
2011
+ // exact occurrence rather than the word text — the same name in two scopes
2012
+ // is two different symbols with two different highlight sets — and on the
2013
+ // model version, so an edit invalidates it.
2014
+ var _highlightMemo = null;
1725
2015
  monaco.languages.registerDocumentHighlightProvider('ruby', {
1726
- provideDocumentHighlights: function provideDocumentHighlights(model, position) {
1727
- return rawRubyLsp('document_highlight', model, position).then(function (highlights) {
2016
+ provideDocumentHighlights: function provideDocumentHighlights(model, position, token) {
2017
+ var wordInfo = model.getWordAtPosition(position);
2018
+ var key = wordInfo && (model.uri.toString() + '@' + model.getVersionId() + ':' +
2019
+ position.lineNumber + ':' + wordInfo.startColumn + ':' + wordInfo.word);
2020
+ if (key && _highlightMemo && _highlightMemo.key === key) return _highlightMemo.result;
2021
+
2022
+ return rawRubyLsp('document_highlight', model, position, token).then(function (highlights) {
1728
2023
  if (!Array.isArray(highlights)) return null;
1729
- return highlights.filter(Boolean).map(function (h) {
2024
+ var result = highlights.filter(Boolean).map(function (h) {
1730
2025
  return { range: lspRange(h.range), kind: h.kind };
1731
2026
  });
2027
+ if (key) _highlightMemo = { key: key, result: result };
2028
+ return result;
1732
2029
  });
1733
2030
  }
1734
2031
  });
@@ -1768,8 +2065,8 @@
1768
2065
 
1769
2066
  monaco.languages.registerDocumentSymbolProvider('ruby', {
1770
2067
  displayName: 'Ruby',
1771
- provideDocumentSymbols: function provideDocumentSymbols(model) {
1772
- return rawRubyLsp('document_symbol', model).then(function (symbols) {
2068
+ provideDocumentSymbols: function provideDocumentSymbols(model, token) {
2069
+ return rawRubyLsp('document_symbol', model, null, token).then(function (symbols) {
1773
2070
  if (!Array.isArray(symbols)) return null;
1774
2071
  return toMonacoSymbols(symbols, 0);
1775
2072
  });
@@ -1782,11 +2079,11 @@
1782
2079
  // for when ruby-lsp is unavailable.
1783
2080
  monaco.languages.registerDocumentFormattingEditProvider('ruby', {
1784
2081
  displayName: 'RuboCop',
1785
- provideDocumentFormattingEdits: function provideDocumentFormattingEdits(model, options) {
2082
+ provideDocumentFormattingEdits: function provideDocumentFormattingEdits(model, options, token) {
1786
2083
  var opts = { tab_size: (options && options.tabSize) || 2,
1787
2084
  insert_spaces: !options || options.insertSpaces !== false };
1788
2085
 
1789
- return tryRubyLsp('formatting', model, { lineNumber: 1, column: 1 }, opts)
2086
+ return tryRubyLsp('formatting', model, { lineNumber: 1, column: 1 }, opts, token)
1790
2087
  .then(function (edits) {
1791
2088
  if (Array.isArray(edits)) {
1792
2089
  return edits.map(function (e) {
@@ -1890,8 +2187,8 @@
1890
2187
  // Parameter hints while typing a call's arguments.
1891
2188
  monaco.languages.registerSignatureHelpProvider('ruby', {
1892
2189
  signatureHelpTriggerCharacters: ['(', ','],
1893
- provideSignatureHelp: function provideSignatureHelp(model, position) {
1894
- return rawRubyLsp('signature_help', model, position).then(function (help) {
2190
+ provideSignatureHelp: function provideSignatureHelp(model, position, token) {
2191
+ return rawRubyLsp('signature_help', model, position, token).then(function (help) {
1895
2192
  if (!help || !Array.isArray(help.signatures) || !help.signatures.length) return null;
1896
2193
  // LSP's SignatureHelp is structurally identical to Monaco's, and
1897
2194
  // MarkupContent {kind, value} is accepted where an IMarkdownString is.
@@ -1902,12 +2199,12 @@
1902
2199
 
1903
2200
  // Smart expand/shrink selection (Shift+Alt+Right / Left).
1904
2201
  monaco.languages.registerSelectionRangeProvider('ruby', {
1905
- provideSelectionRanges: function provideSelectionRanges(model, positions) {
2202
+ provideSelectionRanges: function provideSelectionRanges(model, positions, token) {
1906
2203
  // LSP takes a list of positions and answers one linked list per
1907
2204
  // position; the bridge sends a single position, so ask per position and
1908
2205
  // flatten each chain into the array Monaco wants.
1909
2206
  return Promise.all(positions.map(function (position) {
1910
- return rawRubyLsp('selection_range', model, position).then(function (result) {
2207
+ return rawRubyLsp('selection_range', model, position, token).then(function (result) {
1911
2208
  var node = Array.isArray(result) ? result[0] : null;
1912
2209
  var ranges = [];
1913
2210
  // Bounded: the chain comes from a subprocess and a cycle would spin.
@@ -1927,8 +2224,8 @@
1927
2224
  // declines anything else up front — otherwise F2 on a method name would
1928
2225
  // open the input box and then fail after you'd typed a new name.
1929
2226
  monaco.languages.registerRenameProvider('ruby', {
1930
- resolveRenameLocation: function resolveRenameLocation(model, position) {
1931
- return rawRubyLsp('prepare_rename', model, position).then(function (result) {
2227
+ resolveRenameLocation: function resolveRenameLocation(model, position, token) {
2228
+ return rawRubyLsp('prepare_rename', model, position, token).then(function (result) {
1932
2229
  if (!result) {
1933
2230
  return { rejectReason: 'Only Ruby constants can be renamed here.' };
1934
2231
  }
@@ -2006,8 +2303,8 @@
2006
2303
  // folding provider, so the vim-marker provider registered further down
2007
2304
  // keeps working alongside this one.
2008
2305
  monaco.languages.registerFoldingRangeProvider('ruby', {
2009
- provideFoldingRanges: function provideFoldingRanges(model) {
2010
- return rawRubyLsp('folding_range', model).then(function (ranges) {
2306
+ provideFoldingRanges: function provideFoldingRanges(model, _context, token) {
2307
+ return rawRubyLsp('folding_range', model, null, token).then(function (ranges) {
2011
2308
  if (!Array.isArray(ranges)) return null;
2012
2309
  return ranges.filter(Boolean).map(function (r) {
2013
2310
  return {
@@ -2031,6 +2328,20 @@
2031
2328
  var HOVER_CACHE_TTL_MS = 60000;
2032
2329
  var HOVER_MEMBER_LIMIT = 20;
2033
2330
 
2331
+ // Store an entry, stamping it and sweeping expired keys as it goes. The TTL
2332
+ // was only ever consulted on read, and nothing else ever removed a key, so
2333
+ // every symbol hovered in a session stayed resident for the life of the
2334
+ // page. The sweep is O(entries) on a miss, which at these sizes is free.
2335
+ function cacheWrite(cache, ttlMs, key, entry) {
2336
+ var now = Date.now();
2337
+ Object.keys(cache).forEach(function (k) {
2338
+ if (now - cache[k].ts >= ttlMs) delete cache[k];
2339
+ });
2340
+ entry.ts = now;
2341
+ cache[key] = entry;
2342
+ return entry;
2343
+ }
2344
+
2034
2345
  // ruby-lsp's hover for a constant is a title, a Definitions link and any
2035
2346
  // doc comments — it never lists what the class/module defines. Keep the
2036
2347
  // /module_members breakdown that the legacy hover showed, appended below.
@@ -2056,7 +2367,7 @@
2056
2367
  }
2057
2368
  markdown = '\n\n' + lines.join('\n');
2058
2369
  }
2059
- hoverCache[key] = { ts: Date.now(), markdown: markdown };
2370
+ cacheWrite(hoverCache, HOVER_CACHE_TTL_MS, key, { markdown: markdown });
2060
2371
  return markdown;
2061
2372
  }).catch(function() { return ''; });
2062
2373
  }
@@ -2099,11 +2410,11 @@
2099
2410
  range: new monaco.Range(position.lineNumber, wordInfo.startColumn, position.lineNumber, wordInfo.endColumn),
2100
2411
  contents: [{ value: lsp.markdown + members, isTrusted: true }]
2101
2412
  };
2102
- hoverCache[lspKey] = { ts: Date.now(), result: lspResult };
2413
+ cacheWrite(hoverCache, HOVER_CACHE_TTL_MS, lspKey, { result: lspResult });
2103
2414
  return lspResult;
2104
2415
  });
2105
2416
  }
2106
- hoverCache[lspKey] = { ts: Date.now(), result: null };
2417
+ cacheWrite(hoverCache, HOVER_CACHE_TTL_MS, lspKey, { result: null });
2107
2418
  return legacyRubyHover(model, position, token, wordInfo);
2108
2419
  });
2109
2420
  }
@@ -2132,7 +2443,7 @@
2132
2443
  return FileService.getModuleMembers(word, modController ? { signal: modController.signal } : {}).then(function(data) {
2133
2444
  if (token && token.isCancellationRequested) return null;
2134
2445
  if (!data || !data.methods || data.methods.length === 0) {
2135
- hoverCache[modKey] = { ts: Date.now(), result: null };
2446
+ cacheWrite(hoverCache, HOVER_CACHE_TTL_MS, modKey, { result: null });
2136
2447
  return null;
2137
2448
  }
2138
2449
  var lines = ['**' + data.name + '** `' + (data.file || '') + '`\n'];
@@ -2140,7 +2451,7 @@
2140
2451
  lines.push('- `' + (m.signature || m.name) + '`');
2141
2452
  });
2142
2453
  var result = { contents: [{ value: lines.join('\n') }] };
2143
- hoverCache[modKey] = { ts: Date.now(), result: result };
2454
+ cacheWrite(hoverCache, HOVER_CACHE_TTL_MS, modKey, { result: result });
2144
2455
  return result;
2145
2456
  }).catch(function() { return null; });
2146
2457
  }
@@ -2173,7 +2484,7 @@
2173
2484
 
2174
2485
  var results = data && Array.isArray(data.results) ? data.results : [];
2175
2486
  // Cache the raw results (before current-file filter).
2176
- hoverCache[word] = { ts: Date.now(), results: results };
2487
+ cacheWrite(hoverCache, HOVER_CACHE_TTL_MS, word, { results: results });
2177
2488
 
2178
2489
  if (currentFile) {
2179
2490
  results = results.filter(function(r) { return r.file !== currentFile; });
@@ -2318,7 +2629,7 @@
2318
2629
  }
2319
2630
 
2320
2631
  return FileService.getFileIncludes(path).then(function(result) {
2321
- includesCache[path] = { ts: Date.now(), data: result };
2632
+ cacheWrite(includesCache, INCLUDES_CACHE_TTL_MS, path, { data: result });
2322
2633
  return buildSuggestions(result);
2323
2634
  }).catch(function() { return { suggestions: [] }; });
2324
2635
  }
@@ -2371,7 +2682,9 @@
2371
2682
  jsHoverCache[cacheKey] = { ts: Date.now(), contents: null };
2372
2683
  return null;
2373
2684
  }
2374
- var r = results[0];
2685
+ // Same preference as Ctrl+click, so the file the hover names is
2686
+ // the file the jump would take you to.
2687
+ var r = preferCurrentFile(results, model._mbeditorPath) || results[0];
2375
2688
  // Only declare as global when the definition is a top-level
2376
2689
  // (Sprockets-global) declaration in a different file — nested and
2377
2690
  // member definitions must not get a duplicate declare var.
@@ -2402,29 +2715,38 @@
2402
2715
  if (!discoveredJsGlobals[symbol] && !/^[A-Z]/.test(symbol)) return { suggestions: [] };
2403
2716
  if (typeof FileService === 'undefined' || !FileService.getJsMembers) return { suggestions: [] };
2404
2717
 
2718
+ // Only the raw member list is cached. Suggestions carry the insert
2719
+ // `range`, which is where the completion is about to be written — the
2720
+ // same trap the hover provider's makeHoverRange avoids. Cached
2721
+ // suggestions inserted at the position of the invocation that filled
2722
+ // the cache, so a hit anywhere else in the file was misapplied or
2723
+ // silently dropped by Monaco.
2724
+ function buildSuggestions(members) {
2725
+ return members.map(function(m) {
2726
+ return {
2727
+ label: m.name,
2728
+ kind: monaco.languages.CompletionItemKind.Method,
2729
+ detail: symbol,
2730
+ documentation: m.snippet,
2731
+ insertText: m.name,
2732
+ range: {
2733
+ startLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
2734
+ startColumn: position.column, endColumn: position.column
2735
+ }
2736
+ };
2737
+ });
2738
+ }
2739
+
2405
2740
  var cached = jsMembersCache[symbol];
2406
2741
  if (cached && (Date.now() - cached.ts) < JS_MEMBERS_CACHE_TTL_MS) {
2407
- return { suggestions: cached.suggestions };
2742
+ return { suggestions: buildSuggestions(cached.members) };
2408
2743
  }
2409
2744
 
2410
2745
  return FileService.getJsMembers(symbol)
2411
2746
  .then(function(data) {
2412
2747
  var members = (data && data.members) || [];
2413
- var suggestions = members.map(function(m) {
2414
- return {
2415
- label: m.name,
2416
- kind: monaco.languages.CompletionItemKind.Method,
2417
- detail: symbol,
2418
- documentation: m.snippet,
2419
- insertText: m.name,
2420
- range: {
2421
- startLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
2422
- startColumn: position.column, endColumn: position.column
2423
- }
2424
- };
2425
- });
2426
- jsMembersCache[symbol] = { ts: Date.now(), suggestions: suggestions };
2427
- return { suggestions: suggestions };
2748
+ jsMembersCache[symbol] = { ts: Date.now(), members: members };
2749
+ return { suggestions: buildSuggestions(members) };
2428
2750
  }).catch(function() { return { suggestions: [] }; });
2429
2751
  }
2430
2752
  });