mbeditor 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -1
  3. data/app/assets/javascripts/mbeditor/application.js +3 -1
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +264 -22
  6. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
  7. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
  8. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
  9. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
  10. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  11. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +440 -334
  12. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  13. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  14. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  15. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  16. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
  17. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  18. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1322 -1431
  19. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  20. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +82 -72
  21. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +121 -81
  22. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  23. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  24. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  25. data/app/assets/javascripts/mbeditor/editor_plugins.js +694 -306
  26. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  27. data/app/assets/javascripts/mbeditor/file_service.js +46 -57
  28. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  29. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  30. data/app/assets/javascripts/mbeditor/search_service.js +29 -2
  31. data/app/assets/javascripts/mbeditor/tab_manager.js +135 -54
  32. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  33. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  34. data/app/assets/stylesheets/mbeditor/editor.css +762 -297
  35. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  36. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  37. data/app/channels/mbeditor/collaboration_channel.rb +25 -6
  38. data/app/controllers/mbeditor/application_controller.rb +26 -3
  39. data/app/controllers/mbeditor/editors_controller.rb +125 -465
  40. data/app/services/mbeditor/archive_service.rb +137 -0
  41. data/app/services/mbeditor/collaboration_doc_store.rb +180 -12
  42. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  43. data/app/services/mbeditor/editor_state_service.rb +14 -51
  44. data/app/services/mbeditor/file_history_service.rb +222 -0
  45. data/app/services/mbeditor/git_info_service.rb +6 -0
  46. data/app/services/mbeditor/js_globals_service.rb +12 -1
  47. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  48. data/app/services/mbeditor/lint_service.rb +137 -0
  49. data/app/services/mbeditor/locked_json_file.rb +67 -0
  50. data/app/services/mbeditor/process_runner.rb +32 -0
  51. data/app/services/mbeditor/rubocop_run_service.rb +17 -5
  52. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  53. data/app/services/mbeditor/schema_service.rb +8 -2
  54. data/app/services/mbeditor/search_replace_service.rb +19 -2
  55. data/app/services/mbeditor/test_runner_service.rb +3 -56
  56. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  57. data/lib/mbeditor/audit_log.rb +203 -0
  58. data/lib/mbeditor/configuration.rb +6 -9
  59. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  60. data/lib/mbeditor/route_map.rb +4 -1
  61. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  62. data/lib/mbeditor/version.rb +1 -1
  63. data/lib/mbeditor.rb +1 -0
  64. data/lib/tasks/mbeditor.rake +23 -0
  65. metadata +14 -3
  66. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +0 -312
@@ -13,6 +13,16 @@ var FileImport = (function () {
13
13
  // otherwise reject the request as a 500 before the server guard can run.
14
14
  var MAX_ENTRIES = 100;
15
15
 
16
+ // Every collector returns this same shape; `truncated` is read off the full
17
+ // list, so it must be computed before the slice.
18
+ function capResult(list, foldersSkipped) {
19
+ return {
20
+ entries: list.slice(0, MAX_ENTRIES),
21
+ truncated: list.length > MAX_ENTRIES,
22
+ foldersSkipped: !!foldersSkipped
23
+ };
24
+ }
25
+
16
26
  function hasExternalFiles(dataTransfer) {
17
27
  if (!dataTransfer || !dataTransfer.types) return false;
18
28
  return Array.prototype.indexOf.call(dataTransfer.types, 'Files') !== -1;
@@ -41,20 +51,12 @@ var FileImport = (function () {
41
51
  for (var j = 0; j < files.length; j++) {
42
52
  flat.push({ file: files[j], relativePath: files[j].name });
43
53
  }
44
- return Promise.resolve({
45
- entries: flat.slice(0, MAX_ENTRIES),
46
- truncated: flat.length > MAX_ENTRIES,
47
- foldersSkipped: !!(items && items.length > flat.length)
48
- });
54
+ return Promise.resolve(capResult(flat, items && items.length > flat.length));
49
55
  }
50
56
 
51
57
  var collected = [];
52
58
  return walkAll(roots, collected).then(function () {
53
- return {
54
- entries: collected.slice(0, MAX_ENTRIES),
55
- truncated: collected.length > MAX_ENTRIES,
56
- foldersSkipped: false
57
- };
59
+ return capResult(collected, false);
58
60
  });
59
61
  }
60
62
 
@@ -99,11 +101,7 @@ var FileImport = (function () {
99
101
  var f = fileList[i];
100
102
  out.push({ file: f, relativePath: stripLeadingSlash(f.webkitRelativePath || f.name) });
101
103
  }
102
- return {
103
- entries: out.slice(0, MAX_ENTRIES),
104
- truncated: out.length > MAX_ENTRIES,
105
- foldersSkipped: false
106
- };
104
+ return capResult(out, false);
107
105
  }
108
106
 
109
107
  // Where in the workspace does this set of files already live?
@@ -140,56 +140,47 @@ axios.interceptors.response.use(function (response) {
140
140
  //
141
141
  // The editor keeps working while migrations are pending — the server-side
142
142
  // bypass (Mbeditor::Rack::PendingMigrationBypass) is what makes that true — so
143
- // this is purely informational now. It is driven by a response *header* rather
144
- // than by a failed request, for two reasons: after the bypass there is no
145
- // failed request left to hang it on, and a header rides every response, so a
146
- // migration created mid-session raises the banner too, and running the
147
- // migration clears it without a reload.
143
+ // this is purely informational. It is driven by a response *header* rather than
144
+ // by a failed request, for two reasons: after the bypass there is no failed
145
+ // request left to hang it on, and a header rides every response, so a migration
146
+ // created mid-session raises the warning too, and running the migration clears
147
+ // it without a reload.
148
148
  //
149
- // The old failed-response trigger is kept alongside: a PendingMigrationError
150
- // raised somewhere the bypass does not cover still shows the banner.
151
- var MIGRATION_BANNER_ID = 'mbeditor-migration-banner';
152
-
153
- function _showMigrationBanner() {
154
- if (!document.body || document.getElementById(MIGRATION_BANNER_ID)) return;
155
- var banner = document.createElement('div');
156
- banner.id = MIGRATION_BANNER_ID;
157
- banner.style.cssText = [
158
- 'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99999',
159
- 'background:#f1c40f', 'color:#1e1e1e', 'font-family:system-ui,sans-serif',
160
- 'font-size:13px', 'padding:8px 16px', 'display:flex',
161
- 'align-items:center', 'gap:12px'
162
- ].join(';');
163
- banner.innerHTML =
164
- '<strong>Pending migrations detected.</strong>' +
165
- ' Run <code style="background:rgba(0,0,0,.15);padding:1px 5px;border-radius:3px">rails db:migrate</code>' +
166
- ' then reload — editing still works in the meantime.' +
167
- '<button onclick="this.parentNode.remove()" style="margin-left:auto;background:none;border:none;' +
168
- 'cursor:pointer;font-size:16px;line-height:1;padding:0 4px" aria-label="Dismiss">\u00d7</button>';
169
- document.body.prepend(banner);
170
- }
171
-
172
- function _hideMigrationBanner() {
173
- var existing = document.getElementById(MIGRATION_BANNER_ID);
174
- if (existing) existing.remove();
149
+ // Only an explicit "1"/"0" counts. A response with no header at all is no
150
+ // evidence either way: an error page rendered by ActionDispatch::DebugExceptions
151
+ // never carries one (that middleware sits above the bypass), and treating its
152
+ // absence as "cleared" made the warning flicker off and back on.
153
+ //
154
+ // The state lives as a class on <body> so the CSS can recolour the status bar
155
+ // without React; the event is what the status bar's warning listens to.
156
+ var MIGRATION_PENDING_CLASS = 'mbeditor-migration-pending';
157
+ var MIGRATION_PENDING_EVENT = 'mbeditor:pending-migration';
158
+
159
+ function _setPendingMigration(pending) {
160
+ if (!document.body) return;
161
+ if (document.body.classList.contains(MIGRATION_PENDING_CLASS) === pending) return;
162
+ document.body.classList.toggle(MIGRATION_PENDING_CLASS, pending);
163
+ window.dispatchEvent(new CustomEvent(MIGRATION_PENDING_EVENT, { detail: pending }));
175
164
  }
176
165
 
177
- // Mirrors Mbeditor::Rack::PendingMigrationBypass::PENDING_HEADER. Absent means
178
- // the check passed, so the banner is cleared as soon as the migration is run —
179
- // no reload needed.
166
+ // Mirrors Mbeditor::Rack::PendingMigrationBypass::PENDING_HEADER.
180
167
  function _notePendingMigrationHeader(response) {
181
168
  if (!response || !response.headers) return;
182
- if (response.headers['x-mbeditor-pending-migration']) _showMigrationBanner();
183
- else _hideMigrationBanner();
169
+ var value = response.headers['x-mbeditor-pending-migration'];
170
+ if (value === undefined || value === null || value === '') return;
171
+ _setPendingMigration(value !== '0');
184
172
  }
185
173
 
186
174
  axios.interceptors.response.use(function (response) {
187
175
  _notePendingMigrationHeader(response);
188
176
  return response;
189
177
  }, function (error) {
178
+ // A PendingMigrationError raised somewhere the bypass does not cover.
190
179
  if (error && error.response && error.response.data &&
191
180
  error.response.data.pending_migration_error) {
192
- _showMigrationBanner();
181
+ _setPendingMigration(true);
182
+ } else if (error && error.response) {
183
+ _notePendingMigrationHeader(error.response);
193
184
  }
194
185
  return Promise.reject(error);
195
186
  });
@@ -242,9 +233,17 @@ var FileService = (function () {
242
233
  // Multipart import of files dragged in from outside the browser. The
243
234
  // default 30 s axios timeout is too tight for a large drop on a slow disk,
244
235
  // so this one call gets a longer leash.
245
- function importFiles(formData) {
236
+ // onProgress(percentOrNull) fires as the body uploads, then once more with
237
+ // null when the bytes are away and the server is still writing. A big drop
238
+ // on a slow link otherwise sits on one static message long enough to look
239
+ // like it has hung.
240
+ function importFiles(formData, onProgress) {
246
241
  return axios.post(window.mbeditorBasePath() + '/import', formData, {
247
- timeout: 120000
242
+ timeout: 120000,
243
+ onUploadProgress: onProgress ? function (e) {
244
+ var done = e.loaded === e.total;
245
+ onProgress(!done && e.total ? Math.round((e.loaded / e.total) * 100) : null);
246
+ } : undefined
248
247
  }).then(function (res) { return res.data; });
249
248
  }
250
249
 
@@ -276,30 +275,21 @@ var FileService = (function () {
276
275
 
277
276
  // line (1-based, optional) narrows the run to the single test at that line;
278
277
  // the server ignores it unless `path` IS the test file.
279
- // Server-reported ceilings (seconds), seeded from /workspace. The request
280
- // timeout is derived from them rather than hard-coded: two independent
278
+ // Server-reported ceiling (seconds), seeded from /workspace. The request
279
+ // timeout is derived from it rather than hard-coded: two independent
281
280
  // numbers meant that raising config.test_timeout past the client's own cap
282
281
  // made the browser abort a run the server was still executing, and the user
283
282
  // saw a generic network error instead of the server's message.
284
- var testTimeouts = { test: 180, testAll: 1800 };
285
- function setTestTimeouts(t) {
286
- if (t && t.test > 0) testTimeouts.test = t.test;
287
- if (t && t.testAll > 0) testTimeouts.testAll = t.testAll;
288
- }
289
- // Margin for boot, JSON encoding and the trip back, so the server's own
290
- // timeout is always the one that fires first and reports why.
291
- function requestTimeout(seconds) { return (seconds + 30) * 1000; }
283
+ var testTimeout = 180;
284
+ function setTestTimeout(seconds) { if (seconds > 0) testTimeout = seconds; }
292
285
 
293
286
  function runTests(path, line) {
294
287
  var payload = { path: path };
295
288
  if (line) payload.line = line;
289
+ // Margin for boot, JSON encoding and the trip back, so the server's own
290
+ // timeout is always the one that fires first and reports why.
296
291
  return axios.post(window.mbeditorBasePath() + '/test', payload,
297
- { timeout: requestTimeout(testTimeouts.test) }).then(function(res) { return res.data; });
298
- }
299
-
300
- function runAllTests() {
301
- return axios.post(window.mbeditorBasePath() + '/test_all', {},
302
- { timeout: requestTimeout(testTimeouts.testAll) }).then(function(res) { return res.data; });
292
+ { timeout: (testTimeout + 30) * 1000 }).then(function(res) { return res.data; });
303
293
  }
304
294
 
305
295
  function ping() {
@@ -585,8 +575,7 @@ var FileService = (function () {
585
575
  rubyRename: rubyRename,
586
576
  getModelGraph: getModelGraph,
587
577
  runRubocop: runRubocop,
588
- runAllTests: runAllTests,
589
- setTestTimeouts: setTestTimeouts,
578
+ setTestTimeout: setTestTimeout,
590
579
  getExceptions: getExceptions,
591
580
  clearExceptions: clearExceptions,
592
581
  getRelatedFiles: getRelatedFiles,
@@ -1,3 +1,10 @@
1
+ // Git's seven unmerged porcelain codes. Defined here because git_service.js
2
+ // loads before both consumers (GitPanel's status badges and the status bar's
3
+ // conflict chip) and one list is the point: testing the first character
4
+ // instead reads AA as "added" and DD as "deleted", so four of the seven
5
+ // conflicts get mislabelled.
6
+ window.MBEDITOR_UNMERGED_STATUSES = { DD: 1, AU: 1, UD: 1, UA: 1, DU: 1, AA: 1, UU: 1 };
7
+
1
8
  var GitService = (function () {
2
9
  function applyGitInfo(data) {
3
10
  var files = data.workingTree || data.files || [];
@@ -71,6 +78,7 @@ var GitService = (function () {
71
78
  // unreachable rather than failing over and over in the console.
72
79
  function fetchStatusLite(opts) {
73
80
  var cfg = (opts && opts.background) ? { mbeditorBackground: true } : {};
81
+ var startedAt = Date.now();
74
82
  return axios.get(window.mbeditorBasePath() + '/git_status', cfg)
75
83
  .then(function(res) {
76
84
  var data = res.data;
@@ -79,6 +87,8 @@ var GitService = (function () {
79
87
  var st = EditorStore.getState();
80
88
  var prevInfo = st.gitInfo;
81
89
  var files = data.files || [];
90
+ var audit = window.MbeditorAudit;
91
+ if (audit) audit.rec(audit.EV.GIT_POLL, Date.now() - startedAt, files.length);
82
92
  var gitSig = function(arr) { return arr.map(function(f) { return f.path + '\x00' + f.status; }).join('\x01'); };
83
93
  var branchChanged = (data.branch || "") !== (st.gitBranch || "");
84
94
  var treeChanged = gitSig(files) !== gitSig(st.gitFiles || []);
@@ -123,7 +133,11 @@ var GitService = (function () {
123
133
 
124
134
  return data;
125
135
  })
126
- .catch(function () {}); // transient poll errors retry on the next tick
136
+ .catch(function () {
137
+ // transient poll errors retry on the next tick
138
+ var audit = window.MbeditorAudit;
139
+ if (audit) audit.rec(audit.EV.ERR, audit.EV.GIT_POLL);
140
+ });
127
141
  }
128
142
 
129
143
  function fetchStatus() {
@@ -13,6 +13,10 @@ var HistoryService = (function () {
13
13
  var _idleTimers = {};
14
14
 
15
15
  var IDLE_MS = 30000;
16
+ // Server-side history format. 2 means tracking starts after the file load, so
17
+ // the load is the base rather than an insert-at-origin op (see #92/#93). The
18
+ // server uses this to tell a legacy payload from a current one.
19
+ var FORMAT_VERSION = 2;
16
20
 
17
21
  function _key(branch, filePath) {
18
22
  return branch + ':' + filePath;
@@ -40,16 +44,6 @@ var HistoryService = (function () {
40
44
  }
41
45
  }
42
46
 
43
- function stopTracking(filePath) {
44
- var rec = _tracking[filePath];
45
- if (!rec) return;
46
- var k = _key(rec.branch, filePath);
47
- clearTimeout(_idleTimers[k]);
48
- delete _idleTimers[k];
49
- flush(rec.branch, filePath);
50
- delete _tracking[filePath];
51
- }
52
-
53
47
  function setReplayInProgress(filePath, inProgress) {
54
48
  if (inProgress) {
55
49
  _replayingPaths[filePath] = true;
@@ -86,7 +80,7 @@ var HistoryService = (function () {
86
80
  clearTimeout(_idleTimers[k]);
87
81
  delete _idleTimers[k];
88
82
 
89
- var body = { branch: branch, path: filePath, ops: ops };
83
+ var body = { branch: branch, path: filePath, ops: ops, v: FORMAT_VERSION };
90
84
  if (_bases.hasOwnProperty(k)) {
91
85
  body.base = _bases[k];
92
86
  delete _bases[k];
@@ -161,10 +155,8 @@ var HistoryService = (function () {
161
155
  return {
162
156
  beginTracking: beginTracking,
163
157
  resumeTracking: resumeTracking,
164
- stopTracking: stopTracking,
165
158
  setReplayInProgress: setReplayInProgress,
166
159
  recordOps: recordOps,
167
- flush: flush,
168
160
  flushAll: flushAll,
169
161
  flushForPath: flushForPath,
170
162
  fetchHistory: fetchHistory
@@ -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; }));
@@ -127,6 +145,7 @@ var SearchService = (function () {
127
145
  var controller = new AbortController();
128
146
  _searchController = controller;
129
147
 
148
+ var startedAt = Date.now();
130
149
  return axios.get(window.mbeditorBasePath() + '/search', {
131
150
  params: { q: query, offset: off, limit: lim, regex: useRegex ? 'true' : 'false', match_case: matchCase ? 'true' : 'false', whole_word: wholeWord ? 'true' : 'false' },
132
151
  signal: controller.signal
@@ -137,6 +156,12 @@ var SearchService = (function () {
137
156
  var hasMore = !Array.isArray(data) && !!(data && data.has_more);
138
157
  var totalCount = (data && data.total_count != null) ? data.total_count : null;
139
158
 
159
+ var audit = window.MbeditorAudit;
160
+ if (audit) {
161
+ audit.rec(audit.EV.SEARCH, audit.code('searchBackend', window.MBEDITOR_SEARCH_BACKEND),
162
+ Date.now() - startedAt, results.length);
163
+ }
164
+
140
165
  var payload = { results: results, hasMore: hasMore, totalCount: totalCount };
141
166
  _cacheSet(key, payload);
142
167
 
@@ -152,6 +177,8 @@ var SearchService = (function () {
152
177
  if (axios.isCancel(err) || (err && err.name === 'CanceledError')) {
153
178
  return { results: [], hasMore: false, totalCount: null };
154
179
  }
180
+ var audit = window.MbeditorAudit;
181
+ if (audit) audit.rec(audit.EV.ERR, audit.EV.SEARCH);
155
182
  EditorStore.setStatus("Search failed: " + err.message, "error");
156
183
  return { results: [], hasMore: false, totalCount: null };
157
184
  });