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
@@ -90,6 +90,82 @@ var FileImport = (function () {
90
90
  return readBatch();
91
91
  }
92
92
 
93
+ // <input type="file"> equivalent of collectEntries. A directory pick fills
94
+ // webkitRelativePath with the path *below* the chosen folder, including that
95
+ // folder's own name; a plain multi-file pick leaves it empty.
96
+ function entriesFromFileList(fileList) {
97
+ var out = [];
98
+ for (var i = 0; i < (fileList || []).length; i++) {
99
+ var f = fileList[i];
100
+ out.push({ file: f, relativePath: stripLeadingSlash(f.webkitRelativePath || f.name) });
101
+ }
102
+ return {
103
+ entries: out.slice(0, MAX_ENTRIES),
104
+ truncated: out.length > MAX_ENTRIES,
105
+ foldersSkipped: false
106
+ };
107
+ }
108
+
109
+ // Where in the workspace does this set of files already live?
110
+ //
111
+ // A folder picked as `ux/component/` is almost never meant for the workspace
112
+ // root — its real home is whatever prefix makes the path resolve, e.g.
113
+ // `app/assets/javascripts`. So every entry's relative path is matched as a
114
+ // *suffix* of the existing tree: a file suffix hit means "this exact file is
115
+ // already there" (the replace case), a directory hit on the entry's parent
116
+ // means "the structure is there, this file is new". Files score higher so
117
+ // an exact replace target sorts above a merely plausible folder.
118
+ //
119
+ // docs: [{ path, type: 'file' | 'dir' }] — SearchService's index, which
120
+ // already has excluded paths removed.
121
+ // => [{ prefix, files, dirs, score }] best first, root ('') never included.
122
+ //
123
+ // ponytail: O(entries x docs) scan — 100 x ~2000 measures well under a
124
+ // frame. Index docs by basename if a tree ever makes it drag.
125
+ function suggestDestinations(entries, docs, limit) {
126
+ var scores = {};
127
+
128
+ var note = function (prefix, key) {
129
+ if (!prefix) return; // the root is always offered separately
130
+ var s = scores[prefix] || (scores[prefix] = { prefix: prefix, files: 0, dirs: 0 });
131
+ s[key] += 1;
132
+ };
133
+
134
+ // The prefix that makes `suffix` land on `path`, or null if it doesn't.
135
+ var prefixFor = function (path, suffix) {
136
+ if (!suffix || path.length <= suffix.length) return null;
137
+ var cut = path.length - suffix.length;
138
+ if (path.charAt(cut - 1) !== '/') return null;
139
+ return path.slice(cut) === suffix ? path.slice(0, cut - 1) : null;
140
+ };
141
+
142
+ (entries || []).forEach(function (e) {
143
+ var rel = stripLeadingSlash(e.relativePath);
144
+ var slash = rel.lastIndexOf('/');
145
+ var dir = slash === -1 ? '' : rel.slice(0, slash);
146
+
147
+ (docs || []).forEach(function (doc) {
148
+ if (doc.type === 'file') {
149
+ note(prefixFor(doc.path, rel), 'files');
150
+ } else if (dir) {
151
+ note(prefixFor(doc.path, dir), 'dirs');
152
+ }
153
+ });
154
+ });
155
+
156
+ return Object.keys(scores)
157
+ .map(function (k) {
158
+ var s = scores[k];
159
+ s.score = s.files * 2 + s.dirs;
160
+ return s;
161
+ })
162
+ .sort(function (a, b) {
163
+ if (b.score !== a.score) return b.score - a.score;
164
+ return a.prefix.length - b.prefix.length;
165
+ })
166
+ .slice(0, limit || 5);
167
+ }
168
+
93
169
  function stripLeadingSlash(p) {
94
170
  return String(p || '').replace(/^\/+/, '');
95
171
  }
@@ -136,6 +212,8 @@ var FileImport = (function () {
136
212
  MAX_ENTRIES: MAX_ENTRIES,
137
213
  hasExternalFiles: hasExternalFiles,
138
214
  collectEntries: collectEntries,
215
+ entriesFromFileList: entriesFromFileList,
216
+ suggestDestinations: suggestDestinations,
139
217
  joinPath: joinPath,
140
218
  buildFormData: buildFormData,
141
219
  conflictedEntries: conflictedEntries
@@ -106,39 +106,90 @@ function _isCanceled(error) {
106
106
  return error.code === 'ERR_CANCELED' || error.name === 'CanceledError' || error.name === 'AbortError';
107
107
  }
108
108
 
109
+ // A timeout is not an unreachable server, even though axios reports both the
110
+ // same way — with no `response`. ECONNABORTED means the request went out and
111
+ // the server had not answered *yet*, which is exactly what a busy dev server
112
+ // does when a burst of saves queues behind git and tree work. Counting it as a
113
+ // network failure declared the editor offline at the precise moment the server
114
+ // was alive but loaded, and going offline then suppressed the very background
115
+ // polls that would have proved it up — so it stayed "disconnected" until the
116
+ // next /ping probe. A genuinely dead server still registers: a refused
117
+ // connection or an unresolvable host fails immediately rather than timing out.
118
+ function _isTimeout(error) {
119
+ if (!error) return false;
120
+ return error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT' ||
121
+ /timeout/i.test(error.message || '');
122
+ }
123
+
109
124
  axios.interceptors.response.use(function (response) {
110
125
  ServerReachability.noteSuccess();
111
126
  return response;
112
127
  }, function (error) {
113
128
  if (error && error.mbeditorSkipped) return Promise.reject(error);
114
129
  if (_isCanceled(error)) return Promise.reject(error);
130
+ // Neither success nor failure: a timeout is no evidence either way, so it
131
+ // must not clear the failure counter any more than it may advance it.
132
+ if (_isTimeout(error)) return Promise.reject(error);
115
133
  // No response object at all means the request never reached the server.
116
134
  if (error && !error.response) ServerReachability.noteNetworkFailure();
117
135
  else ServerReachability.noteSuccess();
118
136
  return Promise.reject(error);
119
137
  });
120
138
 
121
- // Surface pending-migration errors as a dismissible banner instead of silently failing.
122
- axios.interceptors.response.use(null, function(error) {
123
- if (error.response && error.response.data && error.response.data.pending_migration_error) {
124
- var bannerId = 'mbeditor-migration-banner';
125
- if (!document.getElementById(bannerId)) {
126
- var banner = document.createElement('div');
127
- banner.id = bannerId;
128
- banner.style.cssText = [
129
- 'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99999',
130
- 'background:#f1c40f', 'color:#1e1e1e', 'font-family:system-ui,sans-serif',
131
- 'font-size:13px', 'padding:8px 16px', 'display:flex',
132
- 'align-items:center', 'gap:12px'
133
- ].join(';');
134
- banner.innerHTML =
139
+ // Pending-migration warning.
140
+ //
141
+ // The editor keeps working while migrations are pending — the server-side
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.
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 =
135
164
  '<strong>Pending migrations detected.</strong>' +
136
165
  ' Run <code style="background:rgba(0,0,0,.15);padding:1px 5px;border-radius:3px">rails db:migrate</code>' +
137
- ' then reload — editor is still available.' +
166
+ ' then reload — editing still works in the meantime.' +
138
167
  '<button onclick="this.parentNode.remove()" style="margin-left:auto;background:none;border:none;' +
139
168
  'cursor:pointer;font-size:16px;line-height:1;padding:0 4px" aria-label="Dismiss">\u00d7</button>';
140
- document.body.prepend(banner);
141
- }
169
+ document.body.prepend(banner);
170
+ }
171
+
172
+ function _hideMigrationBanner() {
173
+ var existing = document.getElementById(MIGRATION_BANNER_ID);
174
+ if (existing) existing.remove();
175
+ }
176
+
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.
180
+ function _notePendingMigrationHeader(response) {
181
+ if (!response || !response.headers) return;
182
+ if (response.headers['x-mbeditor-pending-migration']) _showMigrationBanner();
183
+ else _hideMigrationBanner();
184
+ }
185
+
186
+ axios.interceptors.response.use(function (response) {
187
+ _notePendingMigrationHeader(response);
188
+ return response;
189
+ }, function (error) {
190
+ if (error && error.response && error.response.data &&
191
+ error.response.data.pending_migration_error) {
192
+ _showMigrationBanner();
142
193
  }
143
194
  return Promise.reject(error);
144
195
  });
@@ -158,6 +209,9 @@ var FileService = (function () {
158
209
  // asked for, so it can be skipped while the server is unreachable.
159
210
  function getTree(opts) {
160
211
  var cfg = (opts && opts.background) ? { mbeditorBackground: true } : {};
212
+ // opts.refresh bypasses the server's tree cache. The 10s poll must not set
213
+ // it — the cache is what keeps that poll cheap.
214
+ if (opts && opts.refresh) cfg.params = { refresh: 1 };
161
215
  return axios.get(window.mbeditorBasePath() + '/files', cfg).then(function(res) { return res.data; });
162
216
  }
163
217
 
@@ -222,10 +276,30 @@ var FileService = (function () {
222
276
 
223
277
  // line (1-based, optional) narrows the run to the single test at that line;
224
278
  // 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
281
+ // numbers meant that raising config.test_timeout past the client's own cap
282
+ // made the browser abort a run the server was still executing, and the user
283
+ // 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; }
292
+
225
293
  function runTests(path, line) {
226
294
  var payload = { path: path };
227
295
  if (line) payload.line = line;
228
- return axios.post(window.mbeditorBasePath() + '/test', payload, { timeout: 120000 }).then(function(res) { return res.data; });
296
+ 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; });
229
303
  }
230
304
 
231
305
  function ping() {
@@ -305,6 +379,23 @@ var FileService = (function () {
305
379
  prefetchCache.set(path, entry);
306
380
  }
307
381
 
382
+ // Put content we already hold into the same cache a hover-prefetch fills, so
383
+ // the open that follows serves from memory instead of the network.
384
+ //
385
+ // Used after creating a file: we just wrote it and know exactly what is in
386
+ // it, yet opening it went straight back to the server to be told the same
387
+ // thing. This reuses the prefetch path rather than adding a second content
388
+ // cache, so the consume-once and TTL rules stay in one place.
389
+ function seedPrefetch(path, content) {
390
+ prefetchCache.set(path, {
391
+ // Never fetched, so there is nothing to abort — but cancelPrefetch calls
392
+ // abort() unconditionally, so the shape has to match.
393
+ controller: { abort: function () {} },
394
+ promise: Promise.resolve({ content: content, missing: false }),
395
+ resolvedAt: Date.now()
396
+ });
397
+ }
398
+
308
399
  // Returns a Promise for the cached result and removes the entry (consume-once),
309
400
  // or returns null if no prefetch is in-flight / completed for this path.
310
401
  // Settled entries older than PREFETCH_TTL_MS are treated as expired.
@@ -380,6 +471,15 @@ var FileService = (function () {
380
471
  }).then(function (res) { return res.data; });
381
472
  }
382
473
 
474
+ // Whole-workspace rubocop. mode 'autocorrect' runs `-a` and writes to disk.
475
+ // Generous timeout: a cold run over a large app is slow, and there is no
476
+ // progress stream to fall back on.
477
+ function runRubocop(mode) {
478
+ return axios.post(window.mbeditorBasePath() + '/rubocop',
479
+ { mode: mode || 'check' }, { timeout: 300000 }
480
+ ).then(function (res) { return res.data; });
481
+ }
482
+
383
483
  // Exceptions raised by the host app, newest first. The cable push is the
384
484
  // live path; this seeds the panel and covers hosts without ActionCable.
385
485
  function getExceptions() {
@@ -471,6 +571,7 @@ var FileService = (function () {
471
571
  getJsDefinition: getJsDefinition,
472
572
  getJsMembers: getJsMembers,
473
573
  prefetch: prefetch,
574
+ seedPrefetch: seedPrefetch,
474
575
  getPrefetched: getPrefetched,
475
576
  cancelPrefetch: cancelPrefetch,
476
577
  getModuleMembers: getModuleMembers,
@@ -483,6 +584,9 @@ var FileService = (function () {
483
584
  lspDiagnostics: lspDiagnostics,
484
585
  rubyRename: rubyRename,
485
586
  getModelGraph: getModelGraph,
587
+ runRubocop: runRubocop,
588
+ runAllTests: runAllTests,
589
+ setTestTimeouts: setTestTimeouts,
486
590
  getExceptions: getExceptions,
487
591
  clearExceptions: clearExceptions,
488
592
  getRelatedFiles: getRelatedFiles,
@@ -1,7 +1,21 @@
1
1
  var GitService = (function () {
2
2
  function applyGitInfo(data) {
3
3
  var files = data.workingTree || data.files || [];
4
- var current = EditorStore.getState().gitFiles;
4
+ var st = EditorStore.getState();
5
+ var current = st.gitFiles;
6
+
7
+ // /git_info runs on every window focus, and the payload is a fresh object
8
+ // every time — writing it unconditionally re-rendered the whole app for a
9
+ // repository that had not moved. Same trap, same fix as the file-tree poll
10
+ // (see _treeUpdater in MbeditorApp.js). The comparison has to be the whole
11
+ // payload, not the scalars: unpushedCommits and branchCommits move on their
12
+ // own (an amend leaves branch/ahead/behind untouched), and the panel reads
13
+ // all of them.
14
+ if (st.gitInfoError == null && JSON.stringify(data) === JSON.stringify(st.gitInfo)) {
15
+ clearHistoryCache();
16
+ return;
17
+ }
18
+
5
19
  var stateUpdate = {
6
20
  gitBranch: data.branch || "",
7
21
  gitInfo: data,
@@ -11,6 +25,10 @@ var GitService = (function () {
11
25
  if (gitSig(files) !== gitSig(current)) {
12
26
  stateUpdate.gitFiles = files;
13
27
  }
28
+ // A full git_info means the repository moved — commit, branch switch, or an
29
+ // external change. That is exactly when a file's last-commit answer can
30
+ // differ, so this is where the per-file history cache is dropped.
31
+ clearHistoryCache();
14
32
  EditorStore.setState(stateUpdate);
15
33
  }
16
34
 
@@ -30,6 +48,20 @@ var GitService = (function () {
30
48
  });
31
49
  }
32
50
 
51
+ // Collapses a run of working-tree changes into one /git_info fan-out once
52
+ // the run stops. A branch change is NOT routed through here — that is rare,
53
+ // user-visible, and escalates immediately.
54
+ var GIT_INFO_REFRESH_DEBOUNCE_MS = 1500;
55
+ var _infoRefreshTimer = null;
56
+
57
+ function _scheduleInfoRefresh() {
58
+ if (_infoRefreshTimer) clearTimeout(_infoRefreshTimer);
59
+ _infoRefreshTimer = setTimeout(function () {
60
+ _infoRefreshTimer = null;
61
+ fetchInfo();
62
+ }, GIT_INFO_REFRESH_DEBOUNCE_MS);
63
+ }
64
+
33
65
  // Cheap steady-state poll: hits /git_status (2 git subprocesses) and only
34
66
  // escalates to the expensive /git_info fan-out when something actually
35
67
  // changed — an external branch switch or a working-tree change. Rich fields
@@ -51,6 +83,18 @@ var GitService = (function () {
51
83
  var branchChanged = (data.branch || "") !== (st.gitBranch || "");
52
84
  var treeChanged = gitSig(files) !== gitSig(st.gitFiles || []);
53
85
 
86
+ // A checkout rewrites the working tree wholesale, and nothing else
87
+ // tells the editor: the files_changed push only ever announces
88
+ // mbeditor's own writes, and the 10s poll refreshes the tree's shape,
89
+ // not the contents behind open tabs. So every buffer stayed on the old
90
+ // branch's text until something happened to touch it. Announce it and
91
+ // let the app re-read what it is showing.
92
+ if (branchChanged && st.gitBranch) {
93
+ window.dispatchEvent(new CustomEvent('mbeditor:branch-changed', {
94
+ detail: { from: st.gitBranch, to: data.branch || "" }
95
+ }));
96
+ }
97
+
54
98
  // No full snapshot yet, or the branch changed under us (external
55
99
  // `git checkout`) — run the full fan-out.
56
100
  if (!prevInfo || !prevInfo.ok || branchChanged) return fetchInfo();
@@ -64,7 +108,17 @@ var GitService = (function () {
64
108
  gitInfo: Object.assign({}, prevInfo, { branch: data.branch || "", workingTree: files }),
65
109
  gitInfoError: null
66
110
  });
67
- return fetchInfo();
111
+ // Trailing debounce, because this is the escalation that hurts.
112
+ // Saving a file changes the porcelain signature by definition, so
113
+ // saving a run of files fired the full fan-out — four-plus git
114
+ // subprocesses on the server, the most expensive request the editor
115
+ // makes — once per save, each one arriving while the previous was
116
+ // still running. The cheap fields above are what the badges and the
117
+ // file list actually read; the rich ones (ahead/behind, base branch,
118
+ // unpushed commits) are not worth a fan-out per keystroke-save and
119
+ // are perfectly happy to land once the burst is over.
120
+ _scheduleInfoRefresh();
121
+ return data;
68
122
  }
69
123
 
70
124
  return data;
@@ -118,16 +172,83 @@ var GitService = (function () {
118
172
  }
119
173
 
120
174
  // Per-line add/modify/delete ranges for one file, used to tint line numbers.
175
+ //
176
+ // Two different duplications get collapsed here, both measured:
177
+ //
178
+ // - Concurrent calls share one flight. The tint effect re-runs as a freshly
179
+ // opened tab's content version settles, so every file open fired two
180
+ // identical `git diff` subprocesses about 8ms apart.
181
+ // - A just-resolved result is reused for a moment afterwards. Creating a
182
+ // file fetches once when the new tab mounts and again when the server's
183
+ // broadcast for that same write arrives ~150ms later; both describe the
184
+ // identical on-disk state.
185
+ //
186
+ // The reuse window is only safe because any local write drops the entry
187
+ // (invalidateLineDiff, called from noteLocalSave). That is what makes this
188
+ // exact rather than a guess: the window can only ever merge two fetches with
189
+ // no write between them, so a save always gets a fresh diff — which matters,
190
+ // because a save's broadcast is the *only* thing that refreshes the tint.
191
+ //
192
+ // ponytail: an external write landing within the window right after an
193
+ // unrelated fetch of the same file can still serve one stale tint; the 10s
194
+ // poll heals it. Thread the write's timestamp through the broadcast if that
195
+ // ever shows up in practice.
196
+ var LINE_DIFF_REUSE_MS = 400;
197
+ var _lineDiffFlights = {};
198
+
121
199
  function fetchLineDiff(path) {
122
- return axios.get(window.mbeditorBasePath() + '/git/line_diff?file=' + encodeURIComponent(path)).then(function(res) {
123
- return res.data;
124
- });
200
+ var entry = _lineDiffFlights[path];
201
+ if (entry && (entry.resolvedAt === null || Date.now() - entry.resolvedAt < LINE_DIFF_REUSE_MS)) {
202
+ return entry.promise;
203
+ }
204
+
205
+ var self = { resolvedAt: null, promise: null };
206
+ var drop = function () { if (_lineDiffFlights[path] === self) delete _lineDiffFlights[path]; };
207
+ self.promise = axios.get(window.mbeditorBasePath() + '/git/line_diff?file=' + encodeURIComponent(path))
208
+ .then(function (res) { self.resolvedAt = Date.now(); return res.data; },
209
+ // A failure is never reused — drop it so the next call retries.
210
+ function (err) { drop(); throw err; });
211
+
212
+ _lineDiffFlights[path] = self;
213
+ return self.promise;
214
+ }
215
+
216
+ // Called whenever this client writes the file, so the next tint request goes
217
+ // back to git instead of reusing a pre-write answer.
218
+ function invalidateLineDiff(path) {
219
+ delete _lineDiffFlights[path];
125
220
  }
126
221
 
222
+ // Which commit last touched this file, for the status bar.
223
+ //
224
+ // Cached per path because the caller keys on the active tab id, so every tab
225
+ // activation re-ran `git log` for that file — including switching straight
226
+ // back to a tab you just left. The answer only moves when the repository
227
+ // does, and tab switching never refreshes git_info, so flicking between
228
+ // files is now free. The cached value is the promise itself, which makes
229
+ // concurrent callers share one flight as well.
230
+ //
231
+ // Dropped wholesale by clearHistoryCache() below, from applyGitInfo — a
232
+ // commit, a branch switch and an external change all produce a full git_info,
233
+ // so anything that can change the answer already clears this.
234
+ var _fileHistoryCache = {};
235
+
127
236
  function fetchFileHistory(path) {
128
- return axios.get(window.mbeditorBasePath() + '/git/file_history?file=' + encodeURIComponent(path)).then(function(res) {
129
- return res.data;
130
- });
237
+ var hit = _fileHistoryCache[path];
238
+ if (hit) return hit;
239
+
240
+ var flight = axios.get(window.mbeditorBasePath() + '/git/file_history?file=' + encodeURIComponent(path))
241
+ .then(function (res) { return res.data; },
242
+ // A failure must not be cached, or one blip pins the error until
243
+ // the next git event.
244
+ function (err) { delete _fileHistoryCache[path]; throw err; });
245
+
246
+ _fileHistoryCache[path] = flight;
247
+ return flight;
248
+ }
249
+
250
+ function clearHistoryCache() {
251
+ _fileHistoryCache = {};
131
252
  }
132
253
 
133
254
  function fetchCommitGraph() {
@@ -149,6 +270,7 @@ var GitService = (function () {
149
270
  fetchDiff: fetchDiff,
150
271
  fetchBlame: fetchBlame,
151
272
  fetchLineDiff: fetchLineDiff,
273
+ invalidateLineDiff: invalidateLineDiff,
152
274
  fetchFileHistory: fetchFileHistory,
153
275
  fetchCommitGraph: fetchCommitGraph,
154
276
  fetchCommitDetail: fetchCommitDetail
@@ -25,10 +25,19 @@ var HistoryService = (function () {
25
25
  _bases[k] = baseContent;
26
26
  }
27
27
 
28
- function resumeTracking(branch, filePath) {
28
+ // Reusing a cached model: the ops already recorded against this key still
29
+ // apply, so an existing base must not be replaced. But the key is per
30
+ // BRANCH, so switching branch (or clearing history) lands here with nothing
31
+ // recorded on either side, and a base-less first flush is a guaranteed
32
+ // "base required for initial history" 400. With no ops pending, the current
33
+ // content IS the base. The server ignores it once history exists.
34
+ function resumeTracking(branch, filePath, baseContent) {
29
35
  _tracking[filePath] = { branch: branch };
30
36
  var k = _key(branch, filePath);
31
37
  _pending[k] = _pending[k] || [];
38
+ if (!_bases.hasOwnProperty(k) && _pending[k].length === 0 && baseContent !== undefined) {
39
+ _bases[k] = baseContent;
40
+ }
32
41
  }
33
42
 
34
43
  function stopTracking(filePath) {
@@ -69,7 +78,7 @@ var HistoryService = (function () {
69
78
  _idleTimers[k] = setTimeout(function () { flush(rec.branch, filePath); }, IDLE_MS);
70
79
  }
71
80
 
72
- function flush(branch, filePath) {
81
+ function flush(branch, filePath, keepalive) {
73
82
  var k = _key(branch, filePath);
74
83
  var ops = _pending[k];
75
84
  if (!ops || ops.length === 0) return;
@@ -83,6 +92,19 @@ var HistoryService = (function () {
83
92
  delete _bases[k];
84
93
  }
85
94
 
95
+ // Put the ops — and the base, which this attempt consumed — back so the
96
+ // next flush retries. fetch only rejects on a transport failure, so a
97
+ // rejected POST has to be requeued from the response as well: a 400 used
98
+ // to discard the base along with the ops, and every later flush for that
99
+ // file was then a fresh "base required for initial history" 400. One
100
+ // dropped request permanently disabled persistent undo for the file.
101
+ function requeue() {
102
+ _pending[k] = ops.concat(_pending[k] || []);
103
+ if (body.base !== undefined && !_bases.hasOwnProperty(k)) {
104
+ _bases[k] = body.base;
105
+ }
106
+ }
107
+
86
108
  try {
87
109
  fetch(window.mbeditorBasePath() + '/file_history', {
88
110
  method: 'POST',
@@ -90,47 +112,20 @@ var HistoryService = (function () {
90
112
  'Content-Type': 'application/json',
91
113
  'X-Mbeditor-Client': '1'
92
114
  },
115
+ keepalive: keepalive === true,
93
116
  body: JSON.stringify(body)
94
- }).catch(function () {
95
- // On failure, put ops back so next flush retries
96
- var existing = _pending[k] || [];
97
- _pending[k] = ops.concat(existing);
98
- if (body.base !== undefined && !_bases.hasOwnProperty(k)) {
99
- _bases[k] = body.base;
100
- }
101
- });
102
- } catch (e) {}
117
+ }).then(function (res) {
118
+ if (!res.ok) requeue();
119
+ })["catch"](requeue);
120
+ } catch (e) {
121
+ requeue();
122
+ }
103
123
  }
104
124
 
105
125
  function flushAll(options) {
106
- var useKeepalive = options && options.keepalive;
126
+ var keepalive = !!(options && options.keepalive);
107
127
  Object.keys(_tracking).forEach(function (filePath) {
108
- var rec = _tracking[filePath];
109
- var k = _key(rec.branch, filePath);
110
- var ops = _pending[k];
111
- if (!ops || ops.length === 0) return;
112
- var remaining = ops.slice();
113
- _pending[k] = [];
114
- clearTimeout(_idleTimers[k]);
115
- delete _idleTimers[k];
116
-
117
- var body = { branch: rec.branch, path: filePath, ops: remaining };
118
- if (_bases.hasOwnProperty(k)) {
119
- body.base = _bases[k];
120
- delete _bases[k];
121
- }
122
-
123
- try {
124
- fetch(window.mbeditorBasePath() + '/file_history', {
125
- method: 'POST',
126
- headers: {
127
- 'Content-Type': 'application/json',
128
- 'X-Mbeditor-Client': '1'
129
- },
130
- keepalive: useKeepalive === true,
131
- body: JSON.stringify(body)
132
- });
133
- } catch (e) {}
128
+ flush(_tracking[filePath].branch, filePath, keepalive);
134
129
  });
135
130
  }
136
131
 
@@ -59,6 +59,10 @@ var LogService = (function () {
59
59
  _offset = null;
60
60
  _lines = [];
61
61
  _fetchOnce().then(function () {
62
+ // A stop() while that first fetch was in flight already ran the teardown;
63
+ // starting the transport now would leave the poll interval running (and
64
+ // the server tailing) with nothing to stop it.
65
+ if (!_active) return;
62
66
  if (window.WebSocketService && WebSocketService.isConnected()) {
63
67
  _usingWs = true;
64
68
  WebSocketService.onLogLines(_onWsLog);
@@ -159,20 +159,40 @@ var SearchService = (function () {
159
159
 
160
160
  // Fetch a specific page by index without touching EditorStore.
161
161
  // Used by the random-access virtual scroll loader.
162
+ //
163
+ // Shares projectSearch's cache (same query, same page, same default options →
164
+ // same key) and de-dupes in flight: the loader asks for whatever page the
165
+ // scroll position lands on, and a scroll that crosses one page boundary
166
+ // several times used to stack several identical requests.
167
+ var _pageRequests = {};
168
+
162
169
  function fetchPage(query, pageIndex) {
163
170
  if (!query) return Promise.resolve({ results: [], hasMore: false });
164
171
  var offset = pageIndex * SEARCH_PAGE_SIZE;
165
- return axios.get(window.mbeditorBasePath() + '/search', {
172
+ var key = _cacheKey(query, {}, offset);
173
+
174
+ var cached = _cacheGet(key);
175
+ if (cached) return Promise.resolve(cached);
176
+ if (_pageRequests[key]) return _pageRequests[key];
177
+
178
+ var request = axios.get(window.mbeditorBasePath() + '/search', {
166
179
  params: { q: query, offset: offset, limit: SEARCH_PAGE_SIZE }
167
180
  }).then(function(res) {
181
+ delete _pageRequests[key];
168
182
  var data = res.data;
169
183
  var results = Array.isArray(data) ? data : (data && data.results || []);
170
184
  var hasMore = !Array.isArray(data) && !!(data && data.has_more);
171
- return { results: results, hasMore: hasMore };
185
+ var payload = { results: results, hasMore: hasMore, totalCount: null };
186
+ _cacheSet(key, payload);
187
+ return payload;
172
188
  }).catch(function(err) {
189
+ delete _pageRequests[key];
173
190
  EditorStore.setStatus("Search failed: " + err.message, "error");
174
191
  return { results: [], hasMore: false };
175
192
  });
193
+
194
+ _pageRequests[key] = request;
195
+ return request;
176
196
  }
177
197
 
178
198
  // Re-scan a single changed file for the active query and splice its rows
@@ -236,8 +256,16 @@ var SearchService = (function () {
236
256
  });
237
257
  }
238
258
 
259
+ // The flattened, exclusion-filtered tree behind the index. Read-only for
260
+ // callers that need every known path (the import dialog's destination
261
+ // suggestions) rather than a query.
262
+ function allDocs() {
263
+ return _allDocs;
264
+ }
265
+
239
266
  return {
240
267
  buildIndex: buildIndex,
268
+ allDocs: allDocs,
241
269
  searchFiles: searchFiles,
242
270
  projectSearch: projectSearch,
243
271
  fetchPage: fetchPage,