mbeditor 0.12.8 → 0.13.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +207 -0
  3. data/app/assets/javascripts/mbeditor/application.js +7 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
  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 +135 -40
  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 +216 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +629 -137
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +218 -13
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
  18. data/app/assets/javascripts/mbeditor/editor_plugins.js +444 -116
  19. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  20. data/app/assets/javascripts/mbeditor/file_service.js +122 -18
  21. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  22. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  23. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  24. data/app/assets/javascripts/mbeditor/search_service.js +30 -2
  25. data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
  26. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  27. data/app/assets/stylesheets/mbeditor/editor.css +302 -55
  28. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  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 +173 -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 +7 -0
  35. data/app/services/mbeditor/editor_state_service.rb +36 -26
  36. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  37. data/app/services/mbeditor/file_operation_service.rb +38 -3
  38. data/app/services/mbeditor/file_tree_service.rb +30 -2
  39. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  40. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  41. data/app/services/mbeditor/git_diff_service.rb +5 -1
  42. data/app/services/mbeditor/git_info_service.rb +20 -8
  43. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  44. data/app/services/mbeditor/git_service.rb +65 -15
  45. data/app/services/mbeditor/js_definition_service.rb +3 -1
  46. data/app/services/mbeditor/js_globals_service.rb +7 -4
  47. data/app/services/mbeditor/js_members_service.rb +3 -2
  48. data/app/services/mbeditor/js_program_service.rb +15 -5
  49. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  50. data/app/services/mbeditor/process_runner.rb +42 -12
  51. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  52. data/app/services/mbeditor/route_service.rb +45 -8
  53. data/app/services/mbeditor/rubocop_run_service.rb +86 -0
  54. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  55. data/app/services/mbeditor/schema_service.rb +65 -64
  56. data/app/services/mbeditor/search_replace_service.rb +75 -10
  57. data/app/services/mbeditor/test_runner_service.rb +98 -10
  58. data/lib/mbeditor/cable_log_filter.rb +8 -2
  59. data/lib/mbeditor/configuration.rb +12 -1
  60. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  61. data/lib/mbeditor/engine.rb +22 -3
  62. data/lib/mbeditor/exception_log.rb +2 -2
  63. data/lib/mbeditor/pending_migrations.rb +33 -0
  64. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  65. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  66. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  67. data/lib/mbeditor/route_map.rb +2 -0
  68. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  69. data/lib/mbeditor/version.rb +1 -1
  70. metadata +7 -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,9 +89,24 @@ 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) {
@@ -96,6 +118,34 @@ var ProblemsPanel = (function () {
96
118
  var _filter = React.useState('');
97
119
  var filter = _filter[0], setFilter = _filter[1];
98
120
 
121
+ // Which severities the list shows. Multi-select rather than a single
122
+ // dropdown: "errors and warnings, but not the convention noise" is the
123
+ // view people actually want, and a one-of-three picker can't express it.
124
+ // Persisted, because a filter you have to re-set every time you open the
125
+ // panel is one you stop using.
126
+ var _severities = React.useState(function () {
127
+ try {
128
+ var saved = JSON.parse(window.localStorage.getItem('mbeditorProblemsSeverities'));
129
+ if (saved && typeof saved === 'object') {
130
+ return { error: saved.error !== false, warning: saved.warning !== false, info: saved.info !== false };
131
+ }
132
+ } catch (e) { /* unparseable or storage blocked — fall through to the default */ }
133
+ return { error: true, warning: true, info: true };
134
+ });
135
+ var severities = _severities[0], setSeverities = _severities[1];
136
+
137
+ var toggleSeverity = function (kind) {
138
+ setSeverities(function (prev) {
139
+ var next = Object.assign({}, prev);
140
+ next[kind] = !next[kind];
141
+ // Turning the last one off would show an empty panel with no hint as
142
+ // to why, so the final active severity stays latched on.
143
+ if (!next.error && !next.warning && !next.info) return prev;
144
+ try { window.localStorage.setItem('mbeditorProblemsSeverities', JSON.stringify(next)); } catch (e) {}
145
+ return next;
146
+ });
147
+ };
148
+
99
149
  // Exceptions raised by the host app. Unlike markers these are not per-model
100
150
  // — a runtime failure isn't a property of a file you happen to have open —
101
151
  // so they live in their own section above the marker list.
@@ -128,6 +178,47 @@ var ProblemsPanel = (function () {
128
178
  if (FileService.clearExceptions) FileService.clearExceptions()["catch"](function () {});
129
179
  };
130
180
 
181
+ // Whole-workspace rubocop. Separate from `problems` above: markers only
182
+ // exist for models Monaco has loaded, so this is the only view that can
183
+ // see a file nobody has opened.
184
+ var _workspace = React.useState(null);
185
+ var workspace = _workspace[0], setWorkspace = _workspace[1];
186
+ var _running = React.useState(null);
187
+ var running = _running[0], setRunning = _running[1];
188
+
189
+ var runRubocop = function (mode) {
190
+ if (running) return;
191
+ setRunning(mode);
192
+ FileService.runRubocop(mode)
193
+ .then(function (data) { setWorkspace(data); })
194
+ ["catch"](function (e) {
195
+ var res = e && e.response && e.response.data;
196
+ setWorkspace(res || { ok: false, error: (e && e.message) || 'RuboCop failed', files: [] });
197
+ })
198
+ .then(function () { setRunning(null); });
199
+ };
200
+
201
+ // The workspace list is a snapshot of one run against one working tree.
202
+ // After a checkout it describes files that may no longer have those
203
+ // offenses — or no longer exist — so it is dropped rather than left to
204
+ // look current.
205
+ React.useEffect(function () {
206
+ var onBranchChanged = function () { setWorkspace(null); };
207
+ window.addEventListener('mbeditor:branch-changed', onBranchChanged);
208
+ return function () { window.removeEventListener('mbeditor:branch-changed', onBranchChanged); };
209
+ }, []);
210
+
211
+ var openAllOffending = function () {
212
+ if (!workspace || !onOpenFile) return;
213
+ var files = workspace.files || [];
214
+ // Each open is a Monaco model plus a tab; a default-config run over a
215
+ // real app returns hundreds, which would wedge the editor silently.
216
+ if (files.length > 20 && !window.confirm('Open ' + files.length + ' files?')) return;
217
+ files.forEach(function (f) {
218
+ onOpenFile(f.path, (f.offenses[0] && f.offenses[0].line) || 1, 1);
219
+ });
220
+ };
221
+
131
222
  var MIN_HEIGHT = 120;
132
223
  var _height = React.useState(function () {
133
224
  var saved = parseInt(window.localStorage.getItem('mbeditorProblemsHeight'), 10);
@@ -158,18 +249,33 @@ var ProblemsPanel = (function () {
158
249
 
159
250
  React.useEffect(function () {
160
251
  if (!window.monaco || !window.monaco.editor) return;
161
- var refresh = function () { setProblems(collect()); };
252
+ // Debounced for the same reason the status-bar tally is: markers change
253
+ // on every keystroke in a JS file, and collect() walks every model.
254
+ var timer = null;
255
+ var refresh = function () {
256
+ if (timer) clearTimeout(timer);
257
+ timer = setTimeout(function () {
258
+ timer = null;
259
+ setProblems(collect());
260
+ }, 250);
261
+ };
162
262
  var sub = window.monaco.editor.onDidChangeMarkers(refresh);
163
- refresh();
164
- return function () { sub.dispose(); };
263
+ setProblems(collect());
264
+ return function () {
265
+ if (timer) clearTimeout(timer);
266
+ sub.dispose();
267
+ };
165
268
  }, []);
166
269
 
167
270
  var needle = filter.trim().toLowerCase();
168
- var shown = needle
271
+ var allSeverities = severities.error && severities.warning && severities.info;
272
+ var shown = (needle || !allSeverities)
169
273
  ? problems.byFile.map(function (entry) {
170
274
  return {
171
275
  path: entry.path,
172
276
  markers: entry.markers.filter(function (item) {
277
+ if (!severities[SEVERITY_KIND[item.marker.severity] || 'info']) return false;
278
+ if (!needle) return true;
173
279
  return (item.marker.message + ' ' + item.code + ' ' + entry.path)
174
280
  .toLowerCase().indexOf(needle) !== -1;
175
281
  })
@@ -192,13 +298,65 @@ var ProblemsPanel = (function () {
192
298
  { className: 'ide-problems-header' },
193
299
  React.createElement('i', { className: 'fas fa-bug' }),
194
300
  React.createElement('span', { className: 'ide-problems-title' }, 'Problems'),
301
+ // No prose summary: the severity chips below carry the same three
302
+ // counts, and restating them was the longest thing in the header.
195
303
  React.createElement(
196
- 'span',
197
- { className: 'ide-problems-summary' },
198
- problems.errors.length + ' error' + (problems.errors.length === 1 ? '' : 's') +
199
- ', ' + problems.warnings.length + ' warning' + (problems.warnings.length === 1 ? '' : 's') +
200
- ', ' + problems.infos.length + ' info' +
201
- ' in open files'
304
+ 'div',
305
+ { className: 'ide-problems-severity-filter' },
306
+ [
307
+ { kind: 'error', count: problems.errors.length },
308
+ { kind: 'warning', count: problems.warnings.length },
309
+ { kind: 'info', count: problems.infos.length }
310
+ ].map(function (s) {
311
+ var on = severities[s.kind];
312
+ return React.createElement(
313
+ 'button',
314
+ {
315
+ key: s.kind,
316
+ type: 'button',
317
+ className: 'ide-problems-sev-chip ide-problems-sev-' + s.kind + (on ? ' is-on' : ''),
318
+ title: (on ? 'Hide' : 'Show') + ' ' + SEVERITY_LABEL[s.kind].toLowerCase() + 's',
319
+ 'aria-pressed': on ? 'true' : 'false',
320
+ onClick: function () { toggleSeverity(s.kind); }
321
+ },
322
+ React.createElement('i', { className: 'fas ' + SEVERITY_ICON[s.kind] }),
323
+ React.createElement('span', null, s.count)
324
+ );
325
+ })
326
+ ),
327
+ React.createElement(
328
+ 'div',
329
+ { className: 'ide-problems-actions' },
330
+ React.createElement('button', {
331
+ type: 'button', className: 'ide-problems-btn',
332
+ disabled: !!running,
333
+ title: 'Run rubocop over the whole workspace',
334
+ onClick: function () { runRubocop('check'); }
335
+ },
336
+ React.createElement('i', { className: running === 'check' ? 'fas fa-spinner fa-spin' : 'fas fa-play' }),
337
+ React.createElement('span', null, ' RuboCop')),
338
+ React.createElement('button', {
339
+ type: 'button', className: 'ide-problems-btn',
340
+ // Nothing to correct until a run says so, and rerunning `-a` on a
341
+ // clean tree is a slow no-op.
342
+ disabled: !!running || !workspace || !workspace.correctable,
343
+ title: workspace && workspace.correctable
344
+ ? 'Safe-autocorrect ' + workspace.correctable + ' offense(s) and rerun'
345
+ : 'Run RuboCop first',
346
+ onClick: function () { runRubocop('autocorrect'); }
347
+ },
348
+ React.createElement('i', { className: running === 'autocorrect' ? 'fas fa-spinner fa-spin' : 'fas fa-magic' }),
349
+ React.createElement('span', null, ' Fix')),
350
+ React.createElement('button', {
351
+ type: 'button', className: 'ide-problems-btn',
352
+ disabled: !workspace || !(workspace.files || []).length,
353
+ title: workspace && (workspace.files || []).length
354
+ ? 'Open all ' + workspace.files.length + ' file(s) with offenses'
355
+ : 'Run RuboCop first',
356
+ onClick: openAllOffending
357
+ },
358
+ React.createElement('i', { className: 'fas fa-folder-open' }),
359
+ React.createElement('span', null, ' Open all'))
202
360
  ),
203
361
  React.createElement('input', {
204
362
  className: 'ide-problems-filter',
@@ -215,6 +373,48 @@ var ProblemsPanel = (function () {
215
373
  React.createElement(
216
374
  'div',
217
375
  { className: 'ide-problems-body' },
376
+ workspace && React.createElement(
377
+ 'div',
378
+ { className: 'ide-problems-workspace' },
379
+ workspace.error
380
+ ? React.createElement('div', { className: 'ide-problems-empty' }, 'RuboCop: ' + workspace.error)
381
+ : (workspace.files || []).length === 0
382
+ ? React.createElement('div', { className: 'ide-problems-empty' }, 'RuboCop found no offenses in the workspace')
383
+ : workspace.files.map(function (file) {
384
+ return React.createElement(
385
+ 'div',
386
+ { className: 'ide-problems-file', key: 'ws:' + file.path },
387
+ React.createElement(
388
+ 'div',
389
+ { className: 'ide-problems-file-name' },
390
+ React.createElement('i', { className: 'fas fa-gavel', 'aria-hidden': 'true' }),
391
+ ' ' + file.path,
392
+ React.createElement('span', { className: 'ide-problems-file-count' }, file.offenses.length)
393
+ ),
394
+ file.offenses.map(function (o, i) {
395
+ var kind = COP_SEVERITY_KIND[o.severity] || 'info';
396
+ return React.createElement(
397
+ 'button',
398
+ {
399
+ type: 'button',
400
+ className: 'ide-problems-item ide-problems-item-' + kind,
401
+ key: 'ws:' + file.path + ':' + i,
402
+ title: o.message + '\n' + file.path + ':' + o.line,
403
+ 'aria-label': SEVERITY_LABEL[kind] + ': ' + o.message + ', ' + file.path + ' line ' + o.line,
404
+ onClick: function () { if (onOpenFile) onOpenFile(file.path, o.line, o.column); }
405
+ },
406
+ React.createElement('i', {
407
+ className: 'fas ' + SEVERITY_ICON[kind] + ' ide-problems-icon', 'aria-hidden': 'true'
408
+ }),
409
+ React.createElement('span', { className: 'ide-problems-msg' }, o.message),
410
+ React.createElement('span', { className: 'ide-problems-source' }, o.copName),
411
+ o.correctable && React.createElement('span', { className: 'ide-problems-source' }, 'fixable'),
412
+ React.createElement('span', { className: 'ide-problems-loc' }, '[' + o.line + ', ' + o.column + ']')
413
+ );
414
+ })
415
+ );
416
+ })
417
+ ),
218
418
  exceptions.length > 0 && React.createElement(
219
419
  'div',
220
420
  { className: 'ide-problems-file ide-problems-exceptions' },
@@ -253,6 +453,7 @@ var ProblemsPanel = (function () {
253
453
  type: 'button',
254
454
  className: 'ide-problems-item ide-problems-frame',
255
455
  key: ex.id + ':' + i,
456
+ title: 'Open ' + f.file + ':' + f.line,
256
457
  'aria-label': 'Open ' + f.file + ' line ' + f.line,
257
458
  onClick: function () { if (onOpenFile) onOpenFile(f.file, f.line, 1); }
258
459
  },
@@ -263,7 +464,7 @@ var ProblemsPanel = (function () {
263
464
  );
264
465
  })
265
466
  ),
266
- total === 0 && exceptions.length > 0
467
+ total === 0 && (exceptions.length > 0 || workspace)
267
468
  ? null
268
469
  : total === 0
269
470
  ? React.createElement('div', { className: 'ide-problems-empty' }, 'No problems in the open files')
@@ -291,6 +492,10 @@ var ProblemsPanel = (function () {
291
492
  type: 'button',
292
493
  className: 'ide-problems-item ide-problems-item-' + kind,
293
494
  key: entry.path + ':' + marker.startLineNumber + ':' + index,
495
+ // The message ellipsises in a short panel, so the full
496
+ // text is on hover as well as on the label.
497
+ title: marker.message + '\n' + entry.path + ':' + marker.startLineNumber +
498
+ (item.code ? ' — ' + item.code : ''),
294
499
  'aria-label': SEVERITY_LABEL[kind] + ': ' + marker.message +
295
500
  ', ' + entry.path + ' line ' + marker.startLineNumber +
296
501
  (item.code ? ', source: ' + item.code : ''),
@@ -139,39 +139,45 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
139
139
  setSelectedIndex(0);
140
140
  return;
141
141
  }
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);
142
+ // Debounced: a MiniSearch query plus a linear pass and a sort over every
143
+ // path in the workspace ran on every keystroke, between the keypress and
144
+ // the character appearing.
145
+ var timer = setTimeout(function () {
146
+ var res = SearchService.searchFiles(query);
147
+ // Filter by type: always include files; include dirs only when showFolders is on
148
+ var filtered = showFolders ? res : res.filter(function(r) { return r.type !== 'dir'; });
149
+ // Sort, in order of precedence:
150
+ // 1. match quality — exact basename > prefix > substring > other, so a
151
+ // worse match can never jump the queue however recently it was opened
152
+ // 2. how recently the file was opened, most recent first
153
+ // 3. the static file-type tier (controller > model > > noise)
154
+ //
155
+ // Recency sits above the type tier deliberately: when two files match a
156
+ // query equally well, the one you were just working in is almost always
157
+ // the one you meant, and that beats a guess made from the directory name.
158
+ // Files never opened all tie here and fall through to the type tier, which
159
+ // is what orders the bulk of a cold result list.
160
+ //
161
+ // Directories keep their +100 penalty inside the type tier so files still
162
+ // come first. JS sort is stable in modern engines, so MiniSearch's own
163
+ // relevance order remains the final tiebreaker.
164
+ var ranks = recentRanks();
165
+ var NEVER_OPENED = Infinity;
166
+ filtered.sort(function(a, b) {
167
+ var aRelevance = getMatchRelevance(a, query);
168
+ var bRelevance = getMatchRelevance(b, query);
169
+ if (aRelevance !== bRelevance) return aRelevance - bRelevance;
170
+ var aRecent = a.path in ranks ? ranks[a.path] : NEVER_OPENED;
171
+ var bRecent = b.path in ranks ? ranks[b.path] : NEVER_OPENED;
172
+ if (aRecent !== bRecent) return aRecent - bRecent;
173
+ var aPriority = getFilePriority(a.path) + (a.type === 'dir' ? 100 : 0);
174
+ var bPriority = getFilePriority(b.path) + (b.type === 'dir' ? 100 : 0);
175
+ return aPriority - bPriority;
176
+ });
177
+ setResults(filtered.slice(0, 200));
178
+ setSelectedIndex(0);
179
+ }, 120);
180
+ return function () { clearTimeout(timer); };
175
181
  }, [query, showFolders]);
176
182
 
177
183
  // ── Keyboard ─────────────────────────────────────────────────────────────
@@ -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);