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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +216 -1
- data/app/assets/javascripts/mbeditor/application.js +6 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +52 -1
- data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +104 -103
- data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
- data/app/assets/javascripts/mbeditor/components/ImportDialog.js +224 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +704 -270
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +193 -9
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +105 -77
- data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +434 -112
- data/app/assets/javascripts/mbeditor/file_import.js +78 -0
- data/app/assets/javascripts/mbeditor/file_service.js +112 -18
- data/app/assets/javascripts/mbeditor/git_service.js +130 -8
- data/app/assets/javascripts/mbeditor/history_service.js +33 -38
- data/app/assets/javascripts/mbeditor/log_service.js +4 -0
- data/app/assets/javascripts/mbeditor/search_service.js +50 -4
- data/app/assets/javascripts/mbeditor/tab_manager.js +66 -10
- data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
- data/app/assets/stylesheets/mbeditor/editor.css +389 -103
- data/app/channels/mbeditor/channel_authentication.rb +7 -0
- data/app/channels/mbeditor/collaboration_channel.rb +8 -2
- data/app/channels/mbeditor/editor_channel.rb +6 -3
- data/app/controllers/mbeditor/application_controller.rb +5 -0
- data/app/controllers/mbeditor/editors_controller.rb +155 -29
- data/app/controllers/mbeditor/git_controller.rb +3 -3
- data/app/controllers/mbeditor/logs_controller.rb +3 -1
- data/app/services/mbeditor/collaboration_doc_store.rb +49 -3
- data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
- data/app/services/mbeditor/editor_state_service.rb +36 -26
- data/app/services/mbeditor/exclusion_matcher.rb +12 -10
- data/app/services/mbeditor/file_operation_service.rb +38 -3
- data/app/services/mbeditor/file_tree_service.rb +30 -2
- data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
- data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
- data/app/services/mbeditor/git_diff_service.rb +5 -1
- data/app/services/mbeditor/git_info_service.rb +20 -8
- data/app/services/mbeditor/git_line_diff_service.rb +6 -2
- data/app/services/mbeditor/git_service.rb +65 -15
- data/app/services/mbeditor/js_definition_service.rb +3 -1
- data/app/services/mbeditor/js_globals_service.rb +18 -4
- data/app/services/mbeditor/js_members_service.rb +3 -2
- data/app/services/mbeditor/js_program_service.rb +15 -5
- data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
- data/app/services/mbeditor/process_runner.rb +42 -12
- data/app/services/mbeditor/ri_definition_service.rb +8 -1
- data/app/services/mbeditor/route_service.rb +45 -8
- data/app/services/mbeditor/rubocop_run_service.rb +98 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -4
- data/app/services/mbeditor/schema_service.rb +73 -66
- data/app/services/mbeditor/search_replace_service.rb +42 -7
- data/app/services/mbeditor/test_runner_service.rb +45 -10
- data/lib/mbeditor/cable_log_filter.rb +8 -2
- data/lib/mbeditor/configuration.rb +4 -1
- data/lib/mbeditor/editor_bootstrap.rb +18 -12
- data/lib/mbeditor/engine.rb +22 -3
- data/lib/mbeditor/exception_log.rb +2 -2
- data/lib/mbeditor/pending_migrations.rb +33 -0
- data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
- data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
- data/lib/mbeditor/route_map.rb +1 -0
- data/lib/mbeditor/ruby_lsp_client.rb +71 -12
- data/lib/mbeditor/version.rb +1 -1
- data/lib/tasks/mbeditor.rake +23 -0
- 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
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
}).
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
126
|
+
var keepalive = !!(options && options.keepalive);
|
|
107
127
|
Object.keys(_tracking).forEach(function (filePath) {
|
|
108
|
-
|
|
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);
|