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
@@ -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,21 @@ 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 ceiling (seconds), seeded from /workspace. The request
280
+ // timeout is derived from it 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 testTimeout = 180;
285
+ function setTestTimeout(seconds) { if (seconds > 0) testTimeout = seconds; }
286
+
225
287
  function runTests(path, line) {
226
288
  var payload = { path: path };
227
289
  if (line) payload.line = line;
228
- return axios.post(window.mbeditorBasePath() + '/test', payload, { timeout: 120000 }).then(function(res) { return res.data; });
290
+ // Margin for boot, JSON encoding and the trip back, so the server's own
291
+ // timeout is always the one that fires first and reports why.
292
+ return axios.post(window.mbeditorBasePath() + '/test', payload,
293
+ { timeout: (testTimeout + 30) * 1000 }).then(function(res) { return res.data; });
229
294
  }
230
295
 
231
296
  function ping() {
@@ -305,6 +370,23 @@ var FileService = (function () {
305
370
  prefetchCache.set(path, entry);
306
371
  }
307
372
 
373
+ // Put content we already hold into the same cache a hover-prefetch fills, so
374
+ // the open that follows serves from memory instead of the network.
375
+ //
376
+ // Used after creating a file: we just wrote it and know exactly what is in
377
+ // it, yet opening it went straight back to the server to be told the same
378
+ // thing. This reuses the prefetch path rather than adding a second content
379
+ // cache, so the consume-once and TTL rules stay in one place.
380
+ function seedPrefetch(path, content) {
381
+ prefetchCache.set(path, {
382
+ // Never fetched, so there is nothing to abort — but cancelPrefetch calls
383
+ // abort() unconditionally, so the shape has to match.
384
+ controller: { abort: function () {} },
385
+ promise: Promise.resolve({ content: content, missing: false }),
386
+ resolvedAt: Date.now()
387
+ });
388
+ }
389
+
308
390
  // Returns a Promise for the cached result and removes the entry (consume-once),
309
391
  // or returns null if no prefetch is in-flight / completed for this path.
310
392
  // Settled entries older than PREFETCH_TTL_MS are treated as expired.
@@ -380,6 +462,15 @@ var FileService = (function () {
380
462
  }).then(function (res) { return res.data; });
381
463
  }
382
464
 
465
+ // Whole-workspace rubocop. mode 'autocorrect' runs `-a` and writes to disk.
466
+ // Generous timeout: a cold run over a large app is slow, and there is no
467
+ // progress stream to fall back on.
468
+ function runRubocop(mode) {
469
+ return axios.post(window.mbeditorBasePath() + '/rubocop',
470
+ { mode: mode || 'check' }, { timeout: 300000 }
471
+ ).then(function (res) { return res.data; });
472
+ }
473
+
383
474
  // Exceptions raised by the host app, newest first. The cable push is the
384
475
  // live path; this seeds the panel and covers hosts without ActionCable.
385
476
  function getExceptions() {
@@ -471,6 +562,7 @@ var FileService = (function () {
471
562
  getJsDefinition: getJsDefinition,
472
563
  getJsMembers: getJsMembers,
473
564
  prefetch: prefetch,
565
+ seedPrefetch: seedPrefetch,
474
566
  getPrefetched: getPrefetched,
475
567
  cancelPrefetch: cancelPrefetch,
476
568
  getModuleMembers: getModuleMembers,
@@ -483,6 +575,8 @@ var FileService = (function () {
483
575
  lspDiagnostics: lspDiagnostics,
484
576
  rubyRename: rubyRename,
485
577
  getModelGraph: getModelGraph,
578
+ runRubocop: runRubocop,
579
+ setTestTimeout: setTestTimeout,
486
580
  getExceptions: getExceptions,
487
581
  clearExceptions: clearExceptions,
488
582
  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);