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
@@ -79,13 +79,31 @@ var SearchService = (function () {
79
79
  });
80
80
  }
81
81
 
82
+ // Fuzziness per term: edit distance is `fuzzy * term.length` inside
83
+ // MiniSearch, so short terms get none (at 3 chars every 3-letter token in the
84
+ // tree is within one edit and the list becomes noise) and longer ones get
85
+ // roughly one typo per 4 characters.
86
+ function _fuzzyFor(term) {
87
+ return term.length > 3 ? 0.25 : false;
88
+ }
89
+
82
90
  // Search files (and optionally folders) in the local MiniSearch index.
91
+ // Fuzzy + prefix, so a misspelled or half-typed query still finds the file;
92
+ // ranking back in QuickOpenDialog puts exact matches first, so loosening the
93
+ // match here cannot demote one.
83
94
  // Also performs a case-insensitive substring scan so that partial-word
84
- // queries like "project" reliably find "projects_controller.rb".
95
+ // queries like "project" reliably find "projects_controller.rb" — the
96
+ // tokenizer splits on punctuation, so a query spanning a separator
97
+ // ("models/user") tokenizes into terms no single token can satisfy.
85
98
  // Returns merged results; MiniSearch scored entries come first.
86
99
  function searchFiles(query) {
87
100
  if (!query) return [];
88
- var msResults = _miniSearch.search(query, { prefix: true, fuzzy: false, combineWith: 'AND' });
101
+ var msResults = _miniSearch.search(query, {
102
+ prefix: true,
103
+ fuzzy: _fuzzyFor,
104
+ combineWith: 'AND',
105
+ boost: { name: 2 }
106
+ });
89
107
  // Substring fallback — catch anything MiniSearch missed
90
108
  var q = query.toLowerCase();
91
109
  var msIds = new Set(msResults.map(function(r) { return r.id; }));
@@ -159,20 +177,40 @@ var SearchService = (function () {
159
177
 
160
178
  // Fetch a specific page by index without touching EditorStore.
161
179
  // Used by the random-access virtual scroll loader.
180
+ //
181
+ // Shares projectSearch's cache (same query, same page, same default options →
182
+ // same key) and de-dupes in flight: the loader asks for whatever page the
183
+ // scroll position lands on, and a scroll that crosses one page boundary
184
+ // several times used to stack several identical requests.
185
+ var _pageRequests = {};
186
+
162
187
  function fetchPage(query, pageIndex) {
163
188
  if (!query) return Promise.resolve({ results: [], hasMore: false });
164
189
  var offset = pageIndex * SEARCH_PAGE_SIZE;
165
- return axios.get(window.mbeditorBasePath() + '/search', {
190
+ var key = _cacheKey(query, {}, offset);
191
+
192
+ var cached = _cacheGet(key);
193
+ if (cached) return Promise.resolve(cached);
194
+ if (_pageRequests[key]) return _pageRequests[key];
195
+
196
+ var request = axios.get(window.mbeditorBasePath() + '/search', {
166
197
  params: { q: query, offset: offset, limit: SEARCH_PAGE_SIZE }
167
198
  }).then(function(res) {
199
+ delete _pageRequests[key];
168
200
  var data = res.data;
169
201
  var results = Array.isArray(data) ? data : (data && data.results || []);
170
202
  var hasMore = !Array.isArray(data) && !!(data && data.has_more);
171
- return { results: results, hasMore: hasMore };
203
+ var payload = { results: results, hasMore: hasMore, totalCount: null };
204
+ _cacheSet(key, payload);
205
+ return payload;
172
206
  }).catch(function(err) {
207
+ delete _pageRequests[key];
173
208
  EditorStore.setStatus("Search failed: " + err.message, "error");
174
209
  return { results: [], hasMore: false };
175
210
  });
211
+
212
+ _pageRequests[key] = request;
213
+ return request;
176
214
  }
177
215
 
178
216
  // Re-scan a single changed file for the active query and splice its rows
@@ -236,8 +274,16 @@ var SearchService = (function () {
236
274
  });
237
275
  }
238
276
 
277
+ // The flattened, exclusion-filtered tree behind the index. Read-only for
278
+ // callers that need every known path (the import dialog's destination
279
+ // suggestions) rather than a query.
280
+ function allDocs() {
281
+ return _allDocs;
282
+ }
283
+
239
284
  return {
240
285
  buildIndex: buildIndex,
286
+ allDocs: allDocs,
241
287
  searchFiles: searchFiles,
242
288
  projectSearch: projectSearch,
243
289
  fetchPage: fetchPage,
@@ -169,7 +169,10 @@ var TabManager = (function () {
169
169
  openTab(target.path, target.name, target.line, null, false, target.col);
170
170
  }
171
171
 
172
- function openTab(path, name, line, forcePaneId, isSoftOpen, col) {
172
+ // col/endCol are the 1-based bounds of the thing being jumped to. Pass both
173
+ // and the editor selects it, leaving the cursor at its end; pass col alone
174
+ // and it just parks the cursor there.
175
+ function openTab(path, name, line, forcePaneId, isSoftOpen, col, endCol) {
173
176
  if (line) _jumpOrigin = _snapshotPosition() || _jumpOrigin;
174
177
  var state = EditorStore.getState();
175
178
  var paneId = forcePaneId || state.focusedPaneId;
@@ -191,7 +194,7 @@ var TabManager = (function () {
191
194
  var existing = pane.tabs.find(function(t) { return t.path === path; });
192
195
 
193
196
  if (existing) {
194
- if (line) _updateTab(paneId, path, { gotoLine: line, gotoCol: col || null });
197
+ if (line) _updateTab(paneId, path, { gotoLine: line, gotoCol: col || null, gotoEndCol: endCol || null });
195
198
  switchTab(paneId, path);
196
199
  if (_isMarkdownPath(path)) {
197
200
  _ensureMarkdownPreview(paneId, path, existing.name || name, existing.content || "");
@@ -217,7 +220,7 @@ var TabManager = (function () {
217
220
  isSoftOpen: isSoftOpen ? true : false,
218
221
  loading: true
219
222
  };
220
- if (line) { newTab.gotoLine = line; newTab.gotoCol = col || null; }
223
+ if (line) { newTab.gotoLine = line; newTab.gotoCol = col || null; newTab.gotoEndCol = endCol || null; }
221
224
 
222
225
  var newPanes = state.panes.map(function(p) {
223
226
  if (p.id === paneId) {
@@ -418,6 +421,8 @@ var TabManager = (function () {
418
421
  }
419
422
 
420
423
  function closeTab(paneId, path) {
424
+ // A throttled content write must not outlive the tab it belongs to.
425
+ flushContent();
421
426
  if (typeof HistoryService !== 'undefined') {
422
427
  HistoryService.flushForPath(path);
423
428
  }
@@ -539,23 +544,73 @@ var TabManager = (function () {
539
544
  EditorStore.setState({ focusedPaneId: paneId, activeTabId: newActive });
540
545
  }
541
546
 
547
+ // Typing calls markDirty/markClean per keystroke, and every store write
548
+ // re-renders the whole app (MbeditorApp subscribes to the entire store). Two
549
+ // rules keep that off the keystroke path:
550
+ //
551
+ // * the dirty flag is only written on a clean<->dirty transition;
552
+ // * `content` is written on a 250 ms trailing edge, so the store sees at
553
+ // most four content updates a second.
554
+ //
555
+ // FLUSH CONTRACT: anything that reads `tab.content` immediately after an edit
556
+ // — save, save-all, format, format-all — must call TabManager.flushContent()
557
+ // first and then re-read the tab from the store, or it works on text up to
558
+ // 250 ms old. Everything else (lint, EOL indicator, markdown preview, the
559
+ // external-change poll) tolerates the lag by design.
560
+ var CONTENT_WRITE_MS = 250;
561
+ var _pendingContent = {}; // "<paneId> <tabId>" -> { paneId, path, content, markdown }
562
+ var _contentTimer = null;
563
+
564
+ function _queueContent(paneId, path, content, updates, markdown) {
565
+ var key = paneId + '' + path;
566
+ if (updates) {
567
+ // A state transition has to land now, and it takes the content with it.
568
+ delete _pendingContent[key];
569
+ updates.content = content;
570
+ _updateTab(paneId, path, updates);
571
+ if (markdown) _syncMarkdownPreviewContent(path, content);
572
+ return;
573
+ }
574
+ _pendingContent[key] = { paneId: paneId, path: path, content: content, markdown: markdown };
575
+ if (_contentTimer === null) {
576
+ _contentTimer = setTimeout(flushContent, CONTENT_WRITE_MS);
577
+ }
578
+ }
579
+
580
+ function flushContent() {
581
+ if (_contentTimer !== null) {
582
+ clearTimeout(_contentTimer);
583
+ _contentTimer = null;
584
+ }
585
+ var pending = _pendingContent;
586
+ _pendingContent = {};
587
+ Object.keys(pending).forEach(function (k) {
588
+ var p = pending[k];
589
+ _updateTab(p.paneId, p.path, { content: p.content });
590
+ if (p.markdown) _syncMarkdownPreviewContent(p.path, p.content);
591
+ });
592
+ }
593
+
542
594
  function markDirty(paneId, path, content) {
543
595
  // Auto-harden any soft-open tab on first edit.
544
596
  var st = EditorStore.getState();
545
597
  var paneForTab = st.panes.find(function(p) { return p.id === paneId; });
546
598
  var existingTab = paneForTab && paneForTab.tabs.find(function(t) { return t.path === path; });
547
- var updates = { content: content, dirty: true };
599
+ var updates = null;
600
+ if (!existingTab || !existingTab.dirty) updates = { dirty: true };
548
601
  if (existingTab && existingTab.isSoftOpen) {
602
+ updates = updates || {};
549
603
  updates.isSoftOpen = false;
550
604
  }
551
- _updateTab(paneId, path, updates);
552
- if (_isMarkdownPath(path)) {
553
- _syncMarkdownPreviewContent(path, content);
554
- }
605
+ _queueContent(paneId, path, content, updates, _isMarkdownPath(path));
555
606
  }
556
607
 
557
608
  function markClean(paneId, path, content) {
558
- _updateTab(paneId, path, { content: content, dirty: false });
609
+ var st = EditorStore.getState();
610
+ var paneForTab = st.panes.find(function(p) { return p.id === paneId; });
611
+ var existingTab = paneForTab && paneForTab.tabs.find(function(t) { return t.path === path; });
612
+ var updates = (existingTab && !existingTab.dirty) ? null : { dirty: false };
613
+ _queueContent(paneId, path, content, updates, _isMarkdownPath(path));
559
614
  }
560
615
 
561
616
  function hardenTab(paneId, path) {
@@ -647,7 +702,7 @@ var TabManager = (function () {
647
702
  }
648
703
 
649
704
  function clearGotoLine(paneId, path) {
650
- _updateTab(paneId, path, { gotoLine: null, gotoCol: null });
705
+ _updateTab(paneId, path, { gotoLine: null, gotoCol: null, gotoEndCol: null });
651
706
  }
652
707
 
653
708
  // VS Code-style scratch buffer: a tab with no file behind it. Nothing is
@@ -694,6 +749,7 @@ var TabManager = (function () {
694
749
  focusPane: focusPane,
695
750
  markDirty: markDirty,
696
751
  markClean: markClean,
752
+ flushContent: flushContent,
697
753
  hardenTab: hardenTab,
698
754
  saveTabViewState: saveTabViewState,
699
755
  reorderTabInPane: reorderTabInPane,
@@ -8,8 +8,14 @@
8
8
  // otherwise prevent reconnection.
9
9
  var WebSocketService = (function () {
10
10
  var _consumer = null;
11
+ // True only when _consumer is one we created ourselves; a consumer borrowed
12
+ // from the host app (window.App.cable) is not ours to disconnect.
13
+ var _ownsConsumer = false;
11
14
  var _subscription = null;
12
15
  var _connected = false;
16
+ // Distinguishes the first successful handshake from a reconnect, which is the
17
+ // point at which collaboration rooms have to be re-established.
18
+ var _everConnected = false;
13
19
  // Why collaboration is or is not working, so the editor can say so instead of
14
20
  // failing silently. A rejected subscription used to just schedule a reconnect
15
21
  // and tell nobody, which made a blocked cable indistinguishable from an empty
@@ -62,8 +68,10 @@ var WebSocketService = (function () {
62
68
  // Reuse an existing consumer the host app may have already created (App.cable
63
69
  // is the Rails default). Fall back to creating our own.
64
70
  if (typeof window.App !== 'undefined' && window.App.cable) {
71
+ _ownsConsumer = false;
65
72
  return window.App.cable;
66
73
  }
74
+ _ownsConsumer = true;
67
75
  return window.ActionCable.createConsumer(_cableUrl());
68
76
  }
69
77
 
@@ -72,10 +80,14 @@ var WebSocketService = (function () {
72
80
  try { _subscription.unsubscribe(); } catch (e) { /* ignore */ }
73
81
  _subscription = null;
74
82
  }
75
- if (_consumer && typeof _consumer.disconnect === 'function') {
83
+ // Only ever disconnect a consumer we created. When the host app already had
84
+ // one (window.App.cable) it is shared: disconnecting it on any mbeditor
85
+ // cable blip took down the host's own channels with it.
86
+ if (_ownsConsumer && _consumer && typeof _consumer.disconnect === 'function') {
76
87
  try { _consumer.disconnect(); } catch (e) { /* ignore */ }
77
88
  }
78
89
  _consumer = null;
90
+ _ownsConsumer = false;
79
91
  _connected = false;
80
92
  }
81
93
 
@@ -139,11 +151,21 @@ var WebSocketService = (function () {
139
151
  { channel: 'Mbeditor::EditorChannel' },
140
152
  {
141
153
  connected: function () {
154
+ var reconnected = _everConnected;
155
+ _everConnected = true;
142
156
  _connected = true;
143
157
  _status = 'connected';
144
158
  // Back to fast retries for the next blip.
145
159
  _reconnectAttempts = 0;
146
160
  if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
161
+ // The drop took the old consumer down and every CollaborationChannel
162
+ // subscription made on it with it. Nothing else notices — cable
163
+ // availability is about support, not liveness — so the rooms would
164
+ // stay silently dead until each file was closed and reopened.
165
+ if (reconnected && typeof CollaborationService !== 'undefined' &&
166
+ typeof CollaborationService.rejoinRooms === 'function') {
167
+ try { CollaborationService.rejoinRooms(); } catch (e) { /* ignore */ }
168
+ }
147
169
  },
148
170
  disconnected: function () {
149
171
  _status = _status === 'rejected' ? 'rejected' : 'dropped';
@@ -178,12 +200,71 @@ var WebSocketService = (function () {
178
200
  }
179
201
  }
180
202
 
181
- function _emitFilesChanged(data) {
182
- _filesChangedCallbacks.forEach(function (fn) {
183
- try { fn(data); } catch (e) { /* ignore */ }
203
+ // ── files_changed coalescing ───────────────────────────────────────────────
204
+ //
205
+ // The server broadcasts once per written file, and each subscriber turns a
206
+ // broadcast into HTTP work: the app refreshes the tree and git status, the
207
+ // Monaco plugin refreshes the TypeScript program, every open editor pane
208
+ // re-runs git line-diff. Saving several files in a row — paste, save, next
209
+ // file — therefore multiplied one write into a fan-out per save, and past
210
+ // the point where the dev server could keep up the queue simply grew: saves
211
+ // got slower and slower until requests hit the axios timeout.
212
+ //
213
+ // MbeditorApp already debounced its own handler for exactly this reason, but
214
+ // a debounce there cannot help the other subscribers. Coalescing here fixes
215
+ // every subscriber at once, including any added later.
216
+ var FILES_CHANGED_COALESCE_MS = 150;
217
+ var _filesChangedTimer = null;
218
+ var _filesChangedAcc = null;
219
+
220
+ // Merge a broadcast into the accumulator. Pure so it can be tested directly;
221
+ // the two invariants are easy to get wrong and both have teeth:
222
+ //
223
+ // - A broadcast with no paths means "something changed, re-check
224
+ // everything". It must not be narrowed by paths that happen to land in
225
+ // the same window, or the re-check silently covers only those files.
226
+ // - `structural` is a union: one structural broadcast in the burst makes
227
+ // the whole flush structural, because a tree walk that gets skipped
228
+ // leaves a created or deleted file missing from the explorer.
229
+ function _mergeFilesChanged(acc, data) {
230
+ var next = acc || { paths: [], pathless: false, structural: false };
231
+ if (data && data.paths) next.paths = next.paths.concat(data.paths);
232
+ else next.pathless = true;
233
+ // Absent `structural` means an older server — the conservative read is true.
234
+ if (!data || data.structural !== false) next.structural = true;
235
+ return next;
236
+ }
237
+
238
+ function _coalescedPayload(acc) {
239
+ var payload = { type: 'files_changed', structural: acc.structural };
240
+ if (!acc.pathless && acc.paths.length) {
241
+ var seen = {};
242
+ payload.paths = acc.paths.filter(function (p) {
243
+ if (seen['$' + p]) return false;
244
+ seen['$' + p] = true;
245
+ return true;
246
+ });
247
+ }
248
+ return payload;
249
+ }
250
+
251
+ function _flushFilesChanged() {
252
+ _filesChangedTimer = null;
253
+ var acc = _filesChangedAcc;
254
+ _filesChangedAcc = null;
255
+ if (!acc) return;
256
+ var payload = _coalescedPayload(acc);
257
+ _filesChangedCallbacks.slice().forEach(function (fn) {
258
+ try { fn(payload); } catch (e) { /* ignore */ }
184
259
  });
185
260
  }
186
261
 
262
+ function _emitFilesChanged(data) {
263
+ _filesChangedAcc = _mergeFilesChanged(_filesChangedAcc, data);
264
+ if (_filesChangedTimer) return;
265
+ _filesChangedTimer = setTimeout(_flushFilesChanged, FILES_CHANGED_COALESCE_MS);
266
+ }
267
+
187
268
  function _emitFileSaved(data) {
188
269
  _fileSavedCallbacks.forEach(function (fn) {
189
270
  try { fn(data); } catch (e) { /* ignore */ }
@@ -342,6 +423,9 @@ var WebSocketService = (function () {
342
423
  subscribeCollaboration: subscribeCollaboration,
343
424
  perform: perform,
344
425
  onFilesChanged: onFilesChanged,
426
+ // Exposed for tests — the burst-merge invariants above are worth pinning.
427
+ _mergeFilesChanged: _mergeFilesChanged,
428
+ _coalescedPayload: _coalescedPayload,
345
429
  offFilesChanged: offFilesChanged,
346
430
  onFileSaved: onFileSaved,
347
431
  offFileSaved: offFileSaved,