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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +207 -0
- data/app/assets/javascripts/mbeditor/application.js +7 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
- 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 +135 -40
- 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 +216 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +629 -137
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +218 -13
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
- data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
- data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
- data/app/assets/javascripts/mbeditor/editor_plugins.js +444 -116
- data/app/assets/javascripts/mbeditor/file_import.js +78 -0
- data/app/assets/javascripts/mbeditor/file_service.js +122 -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 +30 -2
- data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
- data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
- data/app/assets/stylesheets/mbeditor/editor.css +302 -55
- data/app/channels/mbeditor/channel_authentication.rb +7 -0
- 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 +173 -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 +7 -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 +7 -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 +86 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -4
- data/app/services/mbeditor/schema_service.rb +65 -64
- data/app/services/mbeditor/search_replace_service.rb +75 -10
- data/app/services/mbeditor/test_runner_service.rb +98 -10
- data/lib/mbeditor/cable_log_filter.rb +8 -2
- data/lib/mbeditor/configuration.rb +12 -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 +2 -0
- data/lib/mbeditor/ruby_lsp_client.rb +71 -12
- data/lib/mbeditor/version.rb +1 -1
- 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
|
-
//
|
|
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,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,
|
|
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
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|