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
@@ -682,6 +682,13 @@ var ModelGraph = (function () {
682
682
  var centreRef = React.useRef(centreOn);
683
683
  centreRef.current = centreOn;
684
684
 
685
+ // Same reason as centreRef: MbeditorApp passes a fresh closure on every one
686
+ // of its renders, and listing onOpenModel in the scene memo's deps rebuilt
687
+ // all ~10,000 SVG elements each time — once per keystroke, whenever this
688
+ // tab happened to be open.
689
+ var openModelRef = React.useRef(onOpenModel);
690
+ openModelRef.current = onOpenModel;
691
+
685
692
  var fitRef = React.useRef(fitToPane);
686
693
  fitRef.current = fitToPane;
687
694
 
@@ -762,7 +769,6 @@ var ModelGraph = (function () {
762
769
  var b = placed.positions[e.to];
763
770
  if (!a || !b || e.from === e.to) return null;
764
771
  var d = edgePath(a, b);
765
- var isHovered = hovered && hovered.index === i;
766
772
  return React.createElement(
767
773
  'g',
768
774
  // Tagged so a node hover can find its own edges with one DOM query
@@ -774,16 +780,29 @@ var ModelGraph = (function () {
774
780
  React.createElement('path', {
775
781
  d: d,
776
782
  className: 'mg-edge-hit',
777
- onMouseEnter: function () { setHovered({ index: i, edge: e }); },
783
+ // The lit class goes on the DOM node directly, the way
784
+ // enterModel/litRef do. `hovered` is React state read from
785
+ // inside this memo, and it is deliberately not a dep — putting
786
+ // it there would rebuild the whole scene on every edge hover —
787
+ // so a className derived from it never updated at all.
788
+ onMouseEnter: function (ev) {
789
+ var line = ev.currentTarget.nextElementSibling;
790
+ if (line) line.classList.add('mg-edge-hovered');
791
+ setHovered({ index: i, edge: e });
792
+ },
778
793
  onMouseMove: function (ev) {
779
794
  var rect = svgRef.current.getBoundingClientRect();
780
795
  setPointer({ x: ev.clientX - rect.left, y: ev.clientY - rect.top });
781
796
  },
782
- onMouseLeave: function () { setHovered(null); }
797
+ onMouseLeave: function (ev) {
798
+ var line = ev.currentTarget.nextElementSibling;
799
+ if (line) line.classList.remove('mg-edge-hovered');
800
+ setHovered(null);
801
+ }
783
802
  }),
784
803
  React.createElement('path', {
785
804
  d: d,
786
- className: 'mg-edge ' + (MACRO_CLASS[e.macro] || '') + (isHovered ? ' mg-edge-hovered' : ''),
805
+ className: 'mg-edge ' + (MACRO_CLASS[e.macro] || ''),
787
806
  markerEnd: 'url(#mg-arrow)'
788
807
  })
789
808
  );
@@ -831,7 +850,7 @@ var ModelGraph = (function () {
831
850
  onClick: function (ev) {
832
851
  ev.stopPropagation();
833
852
  if (dragRef.current && dragRef.current.moved) return;
834
- if (onOpenModel) onOpenModel(m);
853
+ if (openModelRef.current) openModelRef.current(m);
835
854
  }
836
855
  },
837
856
  React.createElement('title', null, 'Open the full schema for ' + m.name),
@@ -868,7 +887,7 @@ var ModelGraph = (function () {
868
887
  );
869
888
  })
870
889
  ];
871
- }, [placed, graph, focused, onOpenModel]);
890
+ }, [placed, graph, focused]);
872
891
 
873
892
  if (loading) {
874
893
  return React.createElement('div', { className: 'ide-model-graph-empty' }, 'Building the model graph…');
@@ -23,6 +23,13 @@ var ProblemsPanel = (function () {
23
23
  info: 'fa-info-circle'
24
24
  };
25
25
  var SEVERITY_LABEL = { error: 'Error', warning: 'Warning', info: 'Info' };
26
+ // RuboCop's own severity names (the /rubocop JSON carries strings, not the
27
+ // numeric Monaco severities the markers use).
28
+ var COP_SEVERITY_KIND = {
29
+ fatal: 'error', error: 'error', warning: 'warning',
30
+ convention: 'info', refactor: 'info', info: 'info'
31
+ };
32
+
26
33
  // Long enough to recognise the statement, short enough not to push the
27
34
  // location off the end of the row.
28
35
  var CODE_PREVIEW_LIMIT = 120;
@@ -82,14 +89,33 @@ var ProblemsPanel = (function () {
82
89
 
83
90
  // The status bar deliberately tallies only errors and warnings: a count that
84
91
  // included every convention offense would be a number nobody acts on.
92
+ //
93
+ // Same scope as collect(), none of its work: the full walk sorts every marker
94
+ // and reads a source line per marker to build previews the caller then throws
95
+ // away, and it runs on every marker change — for a JS file, every keystroke.
85
96
  function counts() {
86
- var all = collect();
87
- return { errors: all.errors.length, warnings: all.warnings.length };
97
+ var monaco = window.monaco;
98
+ if (!monaco || !monaco.editor) return { errors: 0, warnings: 0 };
99
+
100
+ var errors = 0;
101
+ var warnings = 0;
102
+ monaco.editor.getModels().forEach(function (model) {
103
+ if (!model._mbeditorPath || model.isDisposed()) return;
104
+ monaco.editor.getModelMarkers({ resource: model.uri }).forEach(function (m) {
105
+ if (m.severity === SEVERITY_ERROR) errors += 1;
106
+ else if (m.severity === SEVERITY_WARNING) warnings += 1;
107
+ });
108
+ });
109
+ return { errors: errors, warnings: warnings };
88
110
  }
89
111
 
90
112
  var Panel = function ProblemsPanelComponent(_ref) {
91
113
  var onClose = _ref.onClose;
92
114
  var onOpenFile = _ref.onOpenFile;
115
+ // Called after `-a` has rewritten files on disk, so the app can re-read
116
+ // every open tab. Nothing else would: the write came from a subprocess,
117
+ // not from the editor, so the buffers and their markers are stale.
118
+ var onFilesRewritten = _ref.onFilesRewritten;
93
119
 
94
120
  var _problems = React.useState(collect);
95
121
  var problems = _problems[0], setProblems = _problems[1];
@@ -156,6 +182,50 @@ var ProblemsPanel = (function () {
156
182
  if (FileService.clearExceptions) FileService.clearExceptions()["catch"](function () {});
157
183
  };
158
184
 
185
+ // Whole-workspace rubocop. Separate from `problems` above: markers only
186
+ // exist for models Monaco has loaded, so this is the only view that can
187
+ // see a file nobody has opened.
188
+ var _workspace = React.useState(null);
189
+ var workspace = _workspace[0], setWorkspace = _workspace[1];
190
+ var _running = React.useState(null);
191
+ var running = _running[0], setRunning = _running[1];
192
+
193
+ var runRubocop = function (mode) {
194
+ if (running) return;
195
+ setRunning(mode);
196
+ FileService.runRubocop(mode)
197
+ .then(function (data) {
198
+ setWorkspace(data);
199
+ if (mode === 'autocorrect' && data && data.ok && onFilesRewritten) onFilesRewritten();
200
+ })
201
+ ["catch"](function (e) {
202
+ var res = e && e.response && e.response.data;
203
+ setWorkspace(res || { ok: false, error: (e && e.message) || 'RuboCop failed', files: [] });
204
+ })
205
+ .then(function () { setRunning(null); });
206
+ };
207
+
208
+ // The workspace list is a snapshot of one run against one working tree.
209
+ // After a checkout it describes files that may no longer have those
210
+ // offenses — or no longer exist — so it is dropped rather than left to
211
+ // look current.
212
+ React.useEffect(function () {
213
+ var onBranchChanged = function () { setWorkspace(null); };
214
+ window.addEventListener('mbeditor:branch-changed', onBranchChanged);
215
+ return function () { window.removeEventListener('mbeditor:branch-changed', onBranchChanged); };
216
+ }, []);
217
+
218
+ var openAllOffending = function () {
219
+ if (!workspace || !onOpenFile) return;
220
+ var files = workspace.files || [];
221
+ // Each open is a Monaco model plus a tab; a default-config run over a
222
+ // real app returns hundreds, which would wedge the editor silently.
223
+ if (files.length > 20 && !window.confirm('Open ' + files.length + ' files?')) return;
224
+ files.forEach(function (f) {
225
+ onOpenFile(f.path, (f.offenses[0] && f.offenses[0].line) || 1, 1);
226
+ });
227
+ };
228
+
159
229
  var MIN_HEIGHT = 120;
160
230
  var _height = React.useState(function () {
161
231
  var saved = parseInt(window.localStorage.getItem('mbeditorProblemsHeight'), 10);
@@ -186,10 +256,22 @@ var ProblemsPanel = (function () {
186
256
 
187
257
  React.useEffect(function () {
188
258
  if (!window.monaco || !window.monaco.editor) return;
189
- var refresh = function () { setProblems(collect()); };
259
+ // Debounced for the same reason the status-bar tally is: markers change
260
+ // on every keystroke in a JS file, and collect() walks every model.
261
+ var timer = null;
262
+ var refresh = function () {
263
+ if (timer) clearTimeout(timer);
264
+ timer = setTimeout(function () {
265
+ timer = null;
266
+ setProblems(collect());
267
+ }, 250);
268
+ };
190
269
  var sub = window.monaco.editor.onDidChangeMarkers(refresh);
191
- refresh();
192
- return function () { sub.dispose(); };
270
+ setProblems(collect());
271
+ return function () {
272
+ if (timer) clearTimeout(timer);
273
+ sub.dispose();
274
+ };
193
275
  }, []);
194
276
 
195
277
  var needle = filter.trim().toLowerCase();
@@ -208,6 +290,25 @@ var ProblemsPanel = (function () {
208
290
  }).filter(function (entry) { return entry.markers.length > 0; })
209
291
  : problems.byFile;
210
292
 
293
+ // The workspace run and the marker set are different scopes, but they render
294
+ // in one panel under one set of severity chips. Counting only the markers
295
+ // made the header disagree with its own body: click RuboCop, get 400
296
+ // offenses listed under chips reading 0 / 0 / 0, and hiding "warning" left
297
+ // every workspace warning on screen. The chips describe what the panel is
298
+ // showing, so they have to cover both — and the filter has to reach both.
299
+ var wsFiles = (workspace && workspace.files) || [];
300
+ var wsCounts = { error: 0, warning: 0, info: 0 };
301
+ var wsShown = [];
302
+ wsFiles.forEach(function (file) {
303
+ var kept = [];
304
+ file.offenses.forEach(function (o) {
305
+ var kind = COP_SEVERITY_KIND[o.severity] || 'info';
306
+ wsCounts[kind] += 1;
307
+ if (severities[kind]) kept.push(o);
308
+ });
309
+ if (kept.length) wsShown.push({ path: file.path, offenses: kept });
310
+ });
311
+
211
312
  var total = problems.errors.length + problems.warnings.length + problems.infos.length;
212
313
 
213
314
  return React.createElement(
@@ -229,9 +330,9 @@ var ProblemsPanel = (function () {
229
330
  'div',
230
331
  { className: 'ide-problems-severity-filter' },
231
332
  [
232
- { kind: 'error', count: problems.errors.length },
233
- { kind: 'warning', count: problems.warnings.length },
234
- { kind: 'info', count: problems.infos.length }
333
+ { kind: 'error', count: problems.errors.length + wsCounts.error },
334
+ { kind: 'warning', count: problems.warnings.length + wsCounts.warning },
335
+ { kind: 'info', count: problems.infos.length + wsCounts.info }
235
336
  ].map(function (s) {
236
337
  var on = severities[s.kind];
237
338
  return React.createElement(
@@ -249,6 +350,42 @@ var ProblemsPanel = (function () {
249
350
  );
250
351
  })
251
352
  ),
353
+ React.createElement(
354
+ 'div',
355
+ { className: 'ide-problems-actions' },
356
+ React.createElement('button', {
357
+ type: 'button', className: 'ide-problems-btn',
358
+ disabled: !!running,
359
+ title: 'Run rubocop over the whole workspace',
360
+ onClick: function () { runRubocop('check'); }
361
+ },
362
+ React.createElement('i', { className: running === 'check' ? 'fas fa-spinner fa-spin' : 'fas fa-play' }),
363
+ React.createElement('span', null, ' RuboCop')),
364
+ React.createElement('button', {
365
+ type: 'button', className: 'ide-problems-btn',
366
+ // Nothing to correct until a run says so, and rerunning `-a` on a
367
+ // clean tree is a slow no-op.
368
+ disabled: !!running || !workspace || !workspace.correctable,
369
+ title: workspace && workspace.correctable
370
+ ? 'Safe-autocorrect ' + workspace.correctable + ' offense(s) and rerun'
371
+ : workspace
372
+ ? 'Nothing left that safe autocorrect can fix'
373
+ : 'Run RuboCop first',
374
+ onClick: function () { runRubocop('autocorrect'); }
375
+ },
376
+ React.createElement('i', { className: running === 'autocorrect' ? 'fas fa-spinner fa-spin' : 'fas fa-magic' }),
377
+ React.createElement('span', null, ' Fix')),
378
+ React.createElement('button', {
379
+ type: 'button', className: 'ide-problems-btn',
380
+ disabled: !workspace || !(workspace.files || []).length,
381
+ title: workspace && (workspace.files || []).length
382
+ ? 'Open all ' + workspace.files.length + ' file(s) with offenses'
383
+ : 'Run RuboCop first',
384
+ onClick: openAllOffending
385
+ },
386
+ React.createElement('i', { className: 'fas fa-folder-open' }),
387
+ React.createElement('span', null, ' Open all'))
388
+ ),
252
389
  React.createElement('input', {
253
390
  className: 'ide-problems-filter',
254
391
  type: 'text',
@@ -264,6 +401,48 @@ var ProblemsPanel = (function () {
264
401
  React.createElement(
265
402
  'div',
266
403
  { className: 'ide-problems-body' },
404
+ workspace && React.createElement(
405
+ 'div',
406
+ { className: 'ide-problems-workspace' },
407
+ workspace.error
408
+ ? React.createElement('div', { className: 'ide-problems-empty' }, 'RuboCop: ' + workspace.error)
409
+ : wsFiles.length === 0
410
+ ? React.createElement('div', { className: 'ide-problems-empty' }, 'RuboCop found no offenses in the workspace')
411
+ : wsShown.map(function (file) {
412
+ return React.createElement(
413
+ 'div',
414
+ { className: 'ide-problems-file', key: 'ws:' + file.path },
415
+ React.createElement(
416
+ 'div',
417
+ { className: 'ide-problems-file-name' },
418
+ React.createElement('i', { className: 'fas fa-gavel', 'aria-hidden': 'true' }),
419
+ ' ' + file.path,
420
+ React.createElement('span', { className: 'ide-problems-file-count' }, file.offenses.length)
421
+ ),
422
+ file.offenses.map(function (o, i) {
423
+ var kind = COP_SEVERITY_KIND[o.severity] || 'info';
424
+ return React.createElement(
425
+ 'button',
426
+ {
427
+ type: 'button',
428
+ className: 'ide-problems-item ide-problems-item-' + kind,
429
+ key: 'ws:' + file.path + ':' + i,
430
+ title: o.message + '\n' + file.path + ':' + o.line,
431
+ 'aria-label': SEVERITY_LABEL[kind] + ': ' + o.message + ', ' + file.path + ' line ' + o.line,
432
+ onClick: function () { if (onOpenFile) onOpenFile(file.path, o.line, o.column); }
433
+ },
434
+ React.createElement('i', {
435
+ className: 'fas ' + SEVERITY_ICON[kind] + ' ide-problems-icon', 'aria-hidden': 'true'
436
+ }),
437
+ React.createElement('span', { className: 'ide-problems-msg' }, o.message),
438
+ React.createElement('span', { className: 'ide-problems-source' }, o.copName),
439
+ o.correctable && React.createElement('span', { className: 'ide-problems-source' }, 'fixable'),
440
+ React.createElement('span', { className: 'ide-problems-loc' }, '[' + o.line + ', ' + o.column + ']')
441
+ );
442
+ })
443
+ );
444
+ })
445
+ ),
267
446
  exceptions.length > 0 && React.createElement(
268
447
  'div',
269
448
  { className: 'ide-problems-file ide-problems-exceptions' },
@@ -302,6 +481,7 @@ var ProblemsPanel = (function () {
302
481
  type: 'button',
303
482
  className: 'ide-problems-item ide-problems-frame',
304
483
  key: ex.id + ':' + i,
484
+ title: 'Open ' + f.file + ':' + f.line,
305
485
  'aria-label': 'Open ' + f.file + ' line ' + f.line,
306
486
  onClick: function () { if (onOpenFile) onOpenFile(f.file, f.line, 1); }
307
487
  },
@@ -312,7 +492,7 @@ var ProblemsPanel = (function () {
312
492
  );
313
493
  })
314
494
  ),
315
- total === 0 && exceptions.length > 0
495
+ total === 0 && (exceptions.length > 0 || workspace)
316
496
  ? null
317
497
  : total === 0
318
498
  ? React.createElement('div', { className: 'ide-problems-empty' }, 'No problems in the open files')
@@ -340,6 +520,10 @@ var ProblemsPanel = (function () {
340
520
  type: 'button',
341
521
  className: 'ide-problems-item ide-problems-item-' + kind,
342
522
  key: entry.path + ':' + marker.startLineNumber + ':' + index,
523
+ // The message ellipsises in a short panel, so the full
524
+ // text is on hover as well as on the label.
525
+ title: marker.message + '\n' + entry.path + ':' + marker.startLineNumber +
526
+ (item.code ? ' — ' + item.code : ''),
343
527
  'aria-label': SEVERITY_LABEL[kind] + ': ' + marker.message +
344
528
  ', ' + entry.path + ' line ' + marker.startLineNumber +
345
529
  (item.code ? ', source: ' + item.code : ''),
@@ -23,6 +23,100 @@ function saveFavourites(list) {
23
23
  try { localStorage.setItem(FAVS_KEY, JSON.stringify(list)); } catch (e) {}
24
24
  }
25
25
 
26
+ // ── Ranking ────────────────────────────────────────────────────────────────
27
+ // Module scope, not component scope, so `rankResults` can be exercised without
28
+ // rendering — see script/check-quick-open-ranking.mjs.
29
+
30
+ // Priority tier for a file path: lower = shown first.
31
+ // Order: controller > model > helper > concern > view > job > other > noise
32
+ function getFilePriority(path) {
33
+ var p = (path || '').toLowerCase();
34
+ if (p.indexOf('/controllers/') >= 0) return 1;
35
+ if (p.indexOf('/models/') >= 0) return 2;
36
+ if (p.indexOf('/helpers/') >= 0) return 3;
37
+ if (p.indexOf('/concerns/') >= 0) return 4;
38
+ if (p.indexOf('/views/') >= 0) return 5;
39
+ if (p.indexOf('/jobs/') >= 0) return 6;
40
+ // Deprioritise: migrations, schema, compiled assets, vendor, lock files
41
+ if (p.indexOf('/migrate/') >= 0 || p.indexOf('schema.rb') >= 0) return 90;
42
+ if (p.indexOf('/public/') >= 0 || p.indexOf('/vendor/') >= 0) return 91;
43
+ if (p.slice(-7) === '.min.js' || p.slice(-8) === '.min.css' ||
44
+ p.slice(-4) === '.map' || p.slice(-5) === '.lock') return 92;
45
+ return 50;
46
+ }
47
+
48
+ // Recently opened files, most recent first, as a path -> rank lookup. Built
49
+ // once per query rather than per comparison, since the sort calls this for
50
+ // every pair.
51
+ function recentRanks() {
52
+ var ranks = {};
53
+ var recent = (typeof TabManager !== 'undefined' && TabManager.getRecentFiles)
54
+ ? TabManager.getRecentFiles() : [];
55
+ recent.forEach(function (entry, index) {
56
+ var path = typeof entry === 'string' ? entry : (entry && entry.path);
57
+ if (path && !(path in ranks)) ranks[path] = index;
58
+ });
59
+ return ranks;
60
+ }
61
+
62
+ // Match relevance within a priority tier:
63
+ // exact basename > basename prefix > basename substring > path substring > fuzzy-only.
64
+ // FUZZY_ONLY is the bucket everything MiniSearch matched approximately lands
65
+ // in — a typo'd or transposed query hits nothing literally, so an exact match
66
+ // can never be demoted by loosening the index search.
67
+ var FUZZY_ONLY = 4;
68
+ function getMatchRelevance(result, q) {
69
+ if (!q) return FUZZY_ONLY;
70
+ var name = (result.name || (result.path || '').split('/').pop() || '').toLowerCase();
71
+ var lq = q.toLowerCase();
72
+ if (name === lq) return 0;
73
+ if (name.slice(0, lq.length) === lq) return 1;
74
+ if (name.indexOf(lq) >= 0) return 2;
75
+ if ((result.path || '').toLowerCase().indexOf(lq) >= 0) return 3;
76
+ return FUZZY_ONLY;
77
+ }
78
+
79
+ // Filter SearchService hits by type and order them for display.
80
+ // Precedence:
81
+ // 1. files before folders — a folder is never what Ctrl+P is for, and as a
82
+ // tier below match quality an exactly-named folder used to outrank every
83
+ // file that matched
84
+ // 2. match quality (see getMatchRelevance), so a worse match can never jump
85
+ // the queue however recently it was opened
86
+ // 3. MiniSearch's own score, but only within the fuzzy-only bucket: nothing
87
+ // there matched literally, so edit distance is the only signal for which
88
+ // near-miss the user meant
89
+ // 4. how recently the file was opened, most recent first
90
+ // 5. the static file-type tier (controller > model > … > noise)
91
+ //
92
+ // Recency sits above the type tier deliberately: when two files match a query
93
+ // equally well, the one you were just working in is almost always the one you
94
+ // meant, and that beats a guess made from the directory name. Files never
95
+ // opened all tie here and fall through to the type tier, which is what orders
96
+ // the bulk of a cold result list.
97
+ //
98
+ // JS sort is stable in modern engines, so MiniSearch's own relevance order
99
+ // remains the final tiebreaker.
100
+ function rankResults(res, query, showFolders) {
101
+ var filtered = showFolders ? res.slice() : res.filter(function (r) { return r.type !== 'dir'; });
102
+ var ranks = recentRanks();
103
+ var NEVER_OPENED = Infinity;
104
+ filtered.sort(function (a, b) {
105
+ var aDir = a.type === 'dir' ? 1 : 0;
106
+ var bDir = b.type === 'dir' ? 1 : 0;
107
+ if (aDir !== bDir) return aDir - bDir;
108
+ var aRelevance = getMatchRelevance(a, query);
109
+ var bRelevance = getMatchRelevance(b, query);
110
+ if (aRelevance !== bRelevance) return aRelevance - bRelevance;
111
+ if (aRelevance === FUZZY_ONLY && a.score !== b.score) return (b.score || 0) - (a.score || 0);
112
+ var aRecent = a.path in ranks ? ranks[a.path] : NEVER_OPENED;
113
+ var bRecent = b.path in ranks ? ranks[b.path] : NEVER_OPENED;
114
+ if (aRecent !== bRecent) return aRecent - bRecent;
115
+ return getFilePriority(a.path) - getFilePriority(b.path);
116
+ });
117
+ return filtered;
118
+ }
119
+
26
120
  // ── Component ──────────────────────────────────────────────────────────────
27
121
 
28
122
  var QuickOpenDialog = function QuickOpenDialog(_ref) {
@@ -76,49 +170,6 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
76
170
  if (inputRef.current) inputRef.current.focus();
77
171
  };
78
172
 
79
- // Priority tier for a file path: lower = shown first.
80
- // Order: controller > model > helper > concern > view > job > other > noise
81
- function getFilePriority(path) {
82
- var p = (path || '').toLowerCase();
83
- if (p.indexOf('/controllers/') >= 0) return 1;
84
- if (p.indexOf('/models/') >= 0) return 2;
85
- if (p.indexOf('/helpers/') >= 0) return 3;
86
- if (p.indexOf('/concerns/') >= 0) return 4;
87
- if (p.indexOf('/views/') >= 0) return 5;
88
- if (p.indexOf('/jobs/') >= 0) return 6;
89
- // Deprioritise: migrations, schema, compiled assets, vendor, lock files
90
- if (p.indexOf('/migrate/') >= 0 || p.indexOf('schema.rb') >= 0) return 90;
91
- if (p.indexOf('/public/') >= 0 || p.indexOf('/vendor/') >= 0) return 91;
92
- if (p.slice(-7) === '.min.js' || p.slice(-8) === '.min.css' ||
93
- p.slice(-4) === '.map' || p.slice(-5) === '.lock') return 92;
94
- return 50;
95
- }
96
-
97
- // Recently opened files, most recent first, as a path -> rank lookup. Built
98
- // once per query rather than per comparison, since the sort calls this for
99
- // every pair.
100
- function recentRanks() {
101
- var ranks = {};
102
- var recent = (typeof TabManager !== 'undefined' && TabManager.getRecentFiles)
103
- ? TabManager.getRecentFiles() : [];
104
- recent.forEach(function (entry, index) {
105
- var path = typeof entry === 'string' ? entry : (entry && entry.path);
106
- if (path && !(path in ranks)) ranks[path] = index;
107
- });
108
- return ranks;
109
- }
110
-
111
- // Match relevance within a priority tier: exact basename > prefix > substring > other.
112
- function getMatchRelevance(result, q) {
113
- if (!q) return 3;
114
- var name = (result.name || (result.path || '').split('/').pop() || '').toLowerCase();
115
- var lq = q.toLowerCase();
116
- if (name === lq) return 0;
117
- if (name.slice(0, lq.length) === lq) return 1;
118
- if (name.indexOf(lq) >= 0) return 2;
119
- return 3;
120
- }
121
-
122
173
  var getQuickOpenIcon = function getQuickOpenIcon(path, name, type) {
123
174
  if (type === 'dir') {
124
175
  return React.createElement('i', { className: 'fas fa-folder quick-open-result-icon quick-open-folder-icon', 'aria-hidden': 'true' });
@@ -139,39 +190,15 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
139
190
  setSelectedIndex(0);
140
191
  return;
141
192
  }
142
- var res = SearchService.searchFiles(query);
143
- // Filter by type: always include files; include dirs only when showFolders is on
144
- var filtered = showFolders ? res : res.filter(function(r) { return r.type !== 'dir'; });
145
- // Sort, in order of precedence:
146
- // 1. match quality exact basename > prefix > substring > other, so a
147
- // worse match can never jump the queue however recently it was opened
148
- // 2. how recently the file was opened, most recent first
149
- // 3. the static file-type tier (controller > model > … > noise)
150
- //
151
- // Recency sits above the type tier deliberately: when two files match a
152
- // query equally well, the one you were just working in is almost always
153
- // the one you meant, and that beats a guess made from the directory name.
154
- // Files never opened all tie here and fall through to the type tier, which
155
- // is what orders the bulk of a cold result list.
156
- //
157
- // Directories keep their +100 penalty inside the type tier so files still
158
- // come first. JS sort is stable in modern engines, so MiniSearch's own
159
- // relevance order remains the final tiebreaker.
160
- var ranks = recentRanks();
161
- var NEVER_OPENED = Infinity;
162
- filtered.sort(function(a, b) {
163
- var aRelevance = getMatchRelevance(a, query);
164
- var bRelevance = getMatchRelevance(b, query);
165
- if (aRelevance !== bRelevance) return aRelevance - bRelevance;
166
- var aRecent = a.path in ranks ? ranks[a.path] : NEVER_OPENED;
167
- var bRecent = b.path in ranks ? ranks[b.path] : NEVER_OPENED;
168
- if (aRecent !== bRecent) return aRecent - bRecent;
169
- var aPriority = getFilePriority(a.path) + (a.type === 'dir' ? 100 : 0);
170
- var bPriority = getFilePriority(b.path) + (b.type === 'dir' ? 100 : 0);
171
- return aPriority - bPriority;
172
- });
173
- setResults(filtered.slice(0, 200));
174
- setSelectedIndex(0);
193
+ // Debounced: a MiniSearch query plus a linear pass and a sort over every
194
+ // path in the workspace ran on every keystroke, between the keypress and
195
+ // the character appearing.
196
+ var timer = setTimeout(function () {
197
+ var filtered = rankResults(SearchService.searchFiles(query), query, showFolders);
198
+ setResults(filtered.slice(0, 200));
199
+ setSelectedIndex(0);
200
+ }, 120);
201
+ return function () { clearTimeout(timer); };
175
202
  }, [query, showFolders]);
176
203
 
177
204
  // ── Keyboard ─────────────────────────────────────────────────────────────
@@ -389,7 +416,7 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
389
416
  }),
390
417
  results.length === 0 && React.createElement(
391
418
  'div',
392
- { style: { padding: '12px 16px', color: '#666', fontSize: '12px' } },
419
+ { style: { padding: '14px 18px', color: '#666', fontSize: '14px' } },
393
420
  'No matching files.'
394
421
  )
395
422
  )
@@ -398,4 +425,5 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
398
425
  );
399
426
  };
400
427
 
428
+ QuickOpenDialog.rankResults = rankResults;
401
429
  window.QuickOpenDialog = QuickOpenDialog;
@@ -22,6 +22,7 @@ var TabBar = function TabBar(_ref) {
22
22
  var onCloseSaved = _ref.onCloseSaved;
23
23
  var onCloseAll = _ref.onCloseAll;
24
24
  var onNewFile = _ref.onNewFile;
25
+ var markers = _ref.markers || {};
25
26
  var tabDisplayMode = _ref.tabDisplayMode || 'scroll';
26
27
 
27
28
  var containerRef = useRef(null);
@@ -51,7 +52,7 @@ var TabBar = function TabBar(_ref) {
51
52
  var setDropTargetSide = _useState8[1];
52
53
 
53
54
  var getTabMarkerClass = function getTabMarkerClass(tab) {
54
- var tabMarkers = tab.markers || [];
55
+ var tabMarkers = markers[tab.id] || [];
55
56
  if (!tabMarkers.length) return '';
56
57
 
57
58
  var hasError = tabMarkers.some(function (marker) {
@@ -79,7 +80,10 @@ var TabBar = function TabBar(_ref) {
79
80
  activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
80
81
  }
81
82
  }
82
- }, [activeId, tabs]);
83
+ // tabs.length, not tabs: the array is fresh on every content change, so
84
+ // depending on it fired a smooth scroll per keystroke. Only opening, closing
85
+ // or switching a tab can move the active one out of view.
86
+ }, [activeId, tabs.length]);
83
87
 
84
88
  // Close context menu on outside click (bubble phase so onMouseDown on the menu can stop it)
85
89
  useEffect(function () {
@@ -189,6 +193,11 @@ var TabBar = function TabBar(_ref) {
189
193
  'div',
190
194
  {
191
195
  className: 'tab-close',
196
+ // No role="button" here: the host app's Pico CSS skins
197
+ // [role=button] with a primary background and form padding, which
198
+ // turns this into a blue square. It is a plain div with a tooltip,
199
+ // as it was — Ctrl+W is the keyboard path.
200
+ title: 'Close ' + tab.name + (tab.dirty ? ' (unsaved changes)' : ''),
192
201
  onClick: function (e) {
193
202
  e.stopPropagation();
194
203
  onClose(tab.id);