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
|
@@ -79,13 +79,31 @@ var SearchService = (function () {
|
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// Fuzziness per term: edit distance is `fuzzy * term.length` inside
|
|
83
|
+
// MiniSearch, so short terms get none (at 3 chars every 3-letter token in the
|
|
84
|
+
// tree is within one edit and the list becomes noise) and longer ones get
|
|
85
|
+
// roughly one typo per 4 characters.
|
|
86
|
+
function _fuzzyFor(term) {
|
|
87
|
+
return term.length > 3 ? 0.25 : false;
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
// Search files (and optionally folders) in the local MiniSearch index.
|
|
91
|
+
// Fuzzy + prefix, so a misspelled or half-typed query still finds the file;
|
|
92
|
+
// ranking back in QuickOpenDialog puts exact matches first, so loosening the
|
|
93
|
+
// match here cannot demote one.
|
|
83
94
|
// Also performs a case-insensitive substring scan so that partial-word
|
|
84
|
-
// queries like "project" reliably find "projects_controller.rb"
|
|
95
|
+
// queries like "project" reliably find "projects_controller.rb" — the
|
|
96
|
+
// tokenizer splits on punctuation, so a query spanning a separator
|
|
97
|
+
// ("models/user") tokenizes into terms no single token can satisfy.
|
|
85
98
|
// Returns merged results; MiniSearch scored entries come first.
|
|
86
99
|
function searchFiles(query) {
|
|
87
100
|
if (!query) return [];
|
|
88
|
-
var msResults = _miniSearch.search(query, {
|
|
101
|
+
var msResults = _miniSearch.search(query, {
|
|
102
|
+
prefix: true,
|
|
103
|
+
fuzzy: _fuzzyFor,
|
|
104
|
+
combineWith: 'AND',
|
|
105
|
+
boost: { name: 2 }
|
|
106
|
+
});
|
|
89
107
|
// Substring fallback — catch anything MiniSearch missed
|
|
90
108
|
var q = query.toLowerCase();
|
|
91
109
|
var msIds = new Set(msResults.map(function(r) { return r.id; }));
|
|
@@ -159,20 +177,40 @@ var SearchService = (function () {
|
|
|
159
177
|
|
|
160
178
|
// Fetch a specific page by index without touching EditorStore.
|
|
161
179
|
// Used by the random-access virtual scroll loader.
|
|
180
|
+
//
|
|
181
|
+
// Shares projectSearch's cache (same query, same page, same default options →
|
|
182
|
+
// same key) and de-dupes in flight: the loader asks for whatever page the
|
|
183
|
+
// scroll position lands on, and a scroll that crosses one page boundary
|
|
184
|
+
// several times used to stack several identical requests.
|
|
185
|
+
var _pageRequests = {};
|
|
186
|
+
|
|
162
187
|
function fetchPage(query, pageIndex) {
|
|
163
188
|
if (!query) return Promise.resolve({ results: [], hasMore: false });
|
|
164
189
|
var offset = pageIndex * SEARCH_PAGE_SIZE;
|
|
165
|
-
|
|
190
|
+
var key = _cacheKey(query, {}, offset);
|
|
191
|
+
|
|
192
|
+
var cached = _cacheGet(key);
|
|
193
|
+
if (cached) return Promise.resolve(cached);
|
|
194
|
+
if (_pageRequests[key]) return _pageRequests[key];
|
|
195
|
+
|
|
196
|
+
var request = axios.get(window.mbeditorBasePath() + '/search', {
|
|
166
197
|
params: { q: query, offset: offset, limit: SEARCH_PAGE_SIZE }
|
|
167
198
|
}).then(function(res) {
|
|
199
|
+
delete _pageRequests[key];
|
|
168
200
|
var data = res.data;
|
|
169
201
|
var results = Array.isArray(data) ? data : (data && data.results || []);
|
|
170
202
|
var hasMore = !Array.isArray(data) && !!(data && data.has_more);
|
|
171
|
-
|
|
203
|
+
var payload = { results: results, hasMore: hasMore, totalCount: null };
|
|
204
|
+
_cacheSet(key, payload);
|
|
205
|
+
return payload;
|
|
172
206
|
}).catch(function(err) {
|
|
207
|
+
delete _pageRequests[key];
|
|
173
208
|
EditorStore.setStatus("Search failed: " + err.message, "error");
|
|
174
209
|
return { results: [], hasMore: false };
|
|
175
210
|
});
|
|
211
|
+
|
|
212
|
+
_pageRequests[key] = request;
|
|
213
|
+
return request;
|
|
176
214
|
}
|
|
177
215
|
|
|
178
216
|
// Re-scan a single changed file for the active query and splice its rows
|
|
@@ -236,8 +274,16 @@ var SearchService = (function () {
|
|
|
236
274
|
});
|
|
237
275
|
}
|
|
238
276
|
|
|
277
|
+
// The flattened, exclusion-filtered tree behind the index. Read-only for
|
|
278
|
+
// callers that need every known path (the import dialog's destination
|
|
279
|
+
// suggestions) rather than a query.
|
|
280
|
+
function allDocs() {
|
|
281
|
+
return _allDocs;
|
|
282
|
+
}
|
|
283
|
+
|
|
239
284
|
return {
|
|
240
285
|
buildIndex: buildIndex,
|
|
286
|
+
allDocs: allDocs,
|
|
241
287
|
searchFiles: searchFiles,
|
|
242
288
|
projectSearch: projectSearch,
|
|
243
289
|
fetchPage: fetchPage,
|
|
@@ -169,7 +169,10 @@ var TabManager = (function () {
|
|
|
169
169
|
openTab(target.path, target.name, target.line, null, false, target.col);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
// col/endCol are the 1-based bounds of the thing being jumped to. Pass both
|
|
173
|
+
// and the editor selects it, leaving the cursor at its end; pass col alone
|
|
174
|
+
// and it just parks the cursor there.
|
|
175
|
+
function openTab(path, name, line, forcePaneId, isSoftOpen, col, endCol) {
|
|
173
176
|
if (line) _jumpOrigin = _snapshotPosition() || _jumpOrigin;
|
|
174
177
|
var state = EditorStore.getState();
|
|
175
178
|
var paneId = forcePaneId || state.focusedPaneId;
|
|
@@ -191,7 +194,7 @@ var TabManager = (function () {
|
|
|
191
194
|
var existing = pane.tabs.find(function(t) { return t.path === path; });
|
|
192
195
|
|
|
193
196
|
if (existing) {
|
|
194
|
-
if (line) _updateTab(paneId, path, { gotoLine: line, gotoCol: col || null });
|
|
197
|
+
if (line) _updateTab(paneId, path, { gotoLine: line, gotoCol: col || null, gotoEndCol: endCol || null });
|
|
195
198
|
switchTab(paneId, path);
|
|
196
199
|
if (_isMarkdownPath(path)) {
|
|
197
200
|
_ensureMarkdownPreview(paneId, path, existing.name || name, existing.content || "");
|
|
@@ -217,7 +220,7 @@ var TabManager = (function () {
|
|
|
217
220
|
isSoftOpen: isSoftOpen ? true : false,
|
|
218
221
|
loading: true
|
|
219
222
|
};
|
|
220
|
-
if (line) { newTab.gotoLine = line; newTab.gotoCol = col || null; }
|
|
223
|
+
if (line) { newTab.gotoLine = line; newTab.gotoCol = col || null; newTab.gotoEndCol = endCol || null; }
|
|
221
224
|
|
|
222
225
|
var newPanes = state.panes.map(function(p) {
|
|
223
226
|
if (p.id === paneId) {
|
|
@@ -418,6 +421,8 @@ var TabManager = (function () {
|
|
|
418
421
|
}
|
|
419
422
|
|
|
420
423
|
function closeTab(paneId, path) {
|
|
424
|
+
// A throttled content write must not outlive the tab it belongs to.
|
|
425
|
+
flushContent();
|
|
421
426
|
if (typeof HistoryService !== 'undefined') {
|
|
422
427
|
HistoryService.flushForPath(path);
|
|
423
428
|
}
|
|
@@ -539,23 +544,73 @@ var TabManager = (function () {
|
|
|
539
544
|
EditorStore.setState({ focusedPaneId: paneId, activeTabId: newActive });
|
|
540
545
|
}
|
|
541
546
|
|
|
547
|
+
// Typing calls markDirty/markClean per keystroke, and every store write
|
|
548
|
+
// re-renders the whole app (MbeditorApp subscribes to the entire store). Two
|
|
549
|
+
// rules keep that off the keystroke path:
|
|
550
|
+
//
|
|
551
|
+
// * the dirty flag is only written on a clean<->dirty transition;
|
|
552
|
+
// * `content` is written on a 250 ms trailing edge, so the store sees at
|
|
553
|
+
// most four content updates a second.
|
|
554
|
+
//
|
|
555
|
+
// FLUSH CONTRACT: anything that reads `tab.content` immediately after an edit
|
|
556
|
+
// — save, save-all, format, format-all — must call TabManager.flushContent()
|
|
557
|
+
// first and then re-read the tab from the store, or it works on text up to
|
|
558
|
+
// 250 ms old. Everything else (lint, EOL indicator, markdown preview, the
|
|
559
|
+
// external-change poll) tolerates the lag by design.
|
|
560
|
+
var CONTENT_WRITE_MS = 250;
|
|
561
|
+
var _pendingContent = {}; // "<paneId> <tabId>" -> { paneId, path, content, markdown }
|
|
562
|
+
var _contentTimer = null;
|
|
563
|
+
|
|
564
|
+
function _queueContent(paneId, path, content, updates, markdown) {
|
|
565
|
+
var key = paneId + '' + path;
|
|
566
|
+
if (updates) {
|
|
567
|
+
// A state transition has to land now, and it takes the content with it.
|
|
568
|
+
delete _pendingContent[key];
|
|
569
|
+
updates.content = content;
|
|
570
|
+
_updateTab(paneId, path, updates);
|
|
571
|
+
if (markdown) _syncMarkdownPreviewContent(path, content);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
_pendingContent[key] = { paneId: paneId, path: path, content: content, markdown: markdown };
|
|
575
|
+
if (_contentTimer === null) {
|
|
576
|
+
_contentTimer = setTimeout(flushContent, CONTENT_WRITE_MS);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function flushContent() {
|
|
581
|
+
if (_contentTimer !== null) {
|
|
582
|
+
clearTimeout(_contentTimer);
|
|
583
|
+
_contentTimer = null;
|
|
584
|
+
}
|
|
585
|
+
var pending = _pendingContent;
|
|
586
|
+
_pendingContent = {};
|
|
587
|
+
Object.keys(pending).forEach(function (k) {
|
|
588
|
+
var p = pending[k];
|
|
589
|
+
_updateTab(p.paneId, p.path, { content: p.content });
|
|
590
|
+
if (p.markdown) _syncMarkdownPreviewContent(p.path, p.content);
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
542
594
|
function markDirty(paneId, path, content) {
|
|
543
595
|
// Auto-harden any soft-open tab on first edit.
|
|
544
596
|
var st = EditorStore.getState();
|
|
545
597
|
var paneForTab = st.panes.find(function(p) { return p.id === paneId; });
|
|
546
598
|
var existingTab = paneForTab && paneForTab.tabs.find(function(t) { return t.path === path; });
|
|
547
|
-
var updates =
|
|
599
|
+
var updates = null;
|
|
600
|
+
if (!existingTab || !existingTab.dirty) updates = { dirty: true };
|
|
548
601
|
if (existingTab && existingTab.isSoftOpen) {
|
|
602
|
+
updates = updates || {};
|
|
549
603
|
updates.isSoftOpen = false;
|
|
550
604
|
}
|
|
551
|
-
|
|
552
|
-
if (_isMarkdownPath(path)) {
|
|
553
|
-
_syncMarkdownPreviewContent(path, content);
|
|
554
|
-
}
|
|
605
|
+
_queueContent(paneId, path, content, updates, _isMarkdownPath(path));
|
|
555
606
|
}
|
|
556
607
|
|
|
557
608
|
function markClean(paneId, path, content) {
|
|
558
|
-
|
|
609
|
+
var st = EditorStore.getState();
|
|
610
|
+
var paneForTab = st.panes.find(function(p) { return p.id === paneId; });
|
|
611
|
+
var existingTab = paneForTab && paneForTab.tabs.find(function(t) { return t.path === path; });
|
|
612
|
+
var updates = (existingTab && !existingTab.dirty) ? null : { dirty: false };
|
|
613
|
+
_queueContent(paneId, path, content, updates, _isMarkdownPath(path));
|
|
559
614
|
}
|
|
560
615
|
|
|
561
616
|
function hardenTab(paneId, path) {
|
|
@@ -647,7 +702,7 @@ var TabManager = (function () {
|
|
|
647
702
|
}
|
|
648
703
|
|
|
649
704
|
function clearGotoLine(paneId, path) {
|
|
650
|
-
_updateTab(paneId, path, { gotoLine: null, gotoCol: null });
|
|
705
|
+
_updateTab(paneId, path, { gotoLine: null, gotoCol: null, gotoEndCol: null });
|
|
651
706
|
}
|
|
652
707
|
|
|
653
708
|
// VS Code-style scratch buffer: a tab with no file behind it. Nothing is
|
|
@@ -694,6 +749,7 @@ var TabManager = (function () {
|
|
|
694
749
|
focusPane: focusPane,
|
|
695
750
|
markDirty: markDirty,
|
|
696
751
|
markClean: markClean,
|
|
752
|
+
flushContent: flushContent,
|
|
697
753
|
hardenTab: hardenTab,
|
|
698
754
|
saveTabViewState: saveTabViewState,
|
|
699
755
|
reorderTabInPane: reorderTabInPane,
|
|
@@ -8,8 +8,14 @@
|
|
|
8
8
|
// otherwise prevent reconnection.
|
|
9
9
|
var WebSocketService = (function () {
|
|
10
10
|
var _consumer = null;
|
|
11
|
+
// True only when _consumer is one we created ourselves; a consumer borrowed
|
|
12
|
+
// from the host app (window.App.cable) is not ours to disconnect.
|
|
13
|
+
var _ownsConsumer = false;
|
|
11
14
|
var _subscription = null;
|
|
12
15
|
var _connected = false;
|
|
16
|
+
// Distinguishes the first successful handshake from a reconnect, which is the
|
|
17
|
+
// point at which collaboration rooms have to be re-established.
|
|
18
|
+
var _everConnected = false;
|
|
13
19
|
// Why collaboration is or is not working, so the editor can say so instead of
|
|
14
20
|
// failing silently. A rejected subscription used to just schedule a reconnect
|
|
15
21
|
// and tell nobody, which made a blocked cable indistinguishable from an empty
|
|
@@ -62,8 +68,10 @@ var WebSocketService = (function () {
|
|
|
62
68
|
// Reuse an existing consumer the host app may have already created (App.cable
|
|
63
69
|
// is the Rails default). Fall back to creating our own.
|
|
64
70
|
if (typeof window.App !== 'undefined' && window.App.cable) {
|
|
71
|
+
_ownsConsumer = false;
|
|
65
72
|
return window.App.cable;
|
|
66
73
|
}
|
|
74
|
+
_ownsConsumer = true;
|
|
67
75
|
return window.ActionCable.createConsumer(_cableUrl());
|
|
68
76
|
}
|
|
69
77
|
|
|
@@ -72,10 +80,14 @@ var WebSocketService = (function () {
|
|
|
72
80
|
try { _subscription.unsubscribe(); } catch (e) { /* ignore */ }
|
|
73
81
|
_subscription = null;
|
|
74
82
|
}
|
|
75
|
-
|
|
83
|
+
// Only ever disconnect a consumer we created. When the host app already had
|
|
84
|
+
// one (window.App.cable) it is shared: disconnecting it on any mbeditor
|
|
85
|
+
// cable blip took down the host's own channels with it.
|
|
86
|
+
if (_ownsConsumer && _consumer && typeof _consumer.disconnect === 'function') {
|
|
76
87
|
try { _consumer.disconnect(); } catch (e) { /* ignore */ }
|
|
77
88
|
}
|
|
78
89
|
_consumer = null;
|
|
90
|
+
_ownsConsumer = false;
|
|
79
91
|
_connected = false;
|
|
80
92
|
}
|
|
81
93
|
|
|
@@ -139,11 +151,21 @@ var WebSocketService = (function () {
|
|
|
139
151
|
{ channel: 'Mbeditor::EditorChannel' },
|
|
140
152
|
{
|
|
141
153
|
connected: function () {
|
|
154
|
+
var reconnected = _everConnected;
|
|
155
|
+
_everConnected = true;
|
|
142
156
|
_connected = true;
|
|
143
157
|
_status = 'connected';
|
|
144
158
|
// Back to fast retries for the next blip.
|
|
145
159
|
_reconnectAttempts = 0;
|
|
146
160
|
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
|
161
|
+
// The drop took the old consumer down and every CollaborationChannel
|
|
162
|
+
// subscription made on it with it. Nothing else notices — cable
|
|
163
|
+
// availability is about support, not liveness — so the rooms would
|
|
164
|
+
// stay silently dead until each file was closed and reopened.
|
|
165
|
+
if (reconnected && typeof CollaborationService !== 'undefined' &&
|
|
166
|
+
typeof CollaborationService.rejoinRooms === 'function') {
|
|
167
|
+
try { CollaborationService.rejoinRooms(); } catch (e) { /* ignore */ }
|
|
168
|
+
}
|
|
147
169
|
},
|
|
148
170
|
disconnected: function () {
|
|
149
171
|
_status = _status === 'rejected' ? 'rejected' : 'dropped';
|
|
@@ -178,12 +200,71 @@ var WebSocketService = (function () {
|
|
|
178
200
|
}
|
|
179
201
|
}
|
|
180
202
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
203
|
+
// ── files_changed coalescing ───────────────────────────────────────────────
|
|
204
|
+
//
|
|
205
|
+
// The server broadcasts once per written file, and each subscriber turns a
|
|
206
|
+
// broadcast into HTTP work: the app refreshes the tree and git status, the
|
|
207
|
+
// Monaco plugin refreshes the TypeScript program, every open editor pane
|
|
208
|
+
// re-runs git line-diff. Saving several files in a row — paste, save, next
|
|
209
|
+
// file — therefore multiplied one write into a fan-out per save, and past
|
|
210
|
+
// the point where the dev server could keep up the queue simply grew: saves
|
|
211
|
+
// got slower and slower until requests hit the axios timeout.
|
|
212
|
+
//
|
|
213
|
+
// MbeditorApp already debounced its own handler for exactly this reason, but
|
|
214
|
+
// a debounce there cannot help the other subscribers. Coalescing here fixes
|
|
215
|
+
// every subscriber at once, including any added later.
|
|
216
|
+
var FILES_CHANGED_COALESCE_MS = 150;
|
|
217
|
+
var _filesChangedTimer = null;
|
|
218
|
+
var _filesChangedAcc = null;
|
|
219
|
+
|
|
220
|
+
// Merge a broadcast into the accumulator. Pure so it can be tested directly;
|
|
221
|
+
// the two invariants are easy to get wrong and both have teeth:
|
|
222
|
+
//
|
|
223
|
+
// - A broadcast with no paths means "something changed, re-check
|
|
224
|
+
// everything". It must not be narrowed by paths that happen to land in
|
|
225
|
+
// the same window, or the re-check silently covers only those files.
|
|
226
|
+
// - `structural` is a union: one structural broadcast in the burst makes
|
|
227
|
+
// the whole flush structural, because a tree walk that gets skipped
|
|
228
|
+
// leaves a created or deleted file missing from the explorer.
|
|
229
|
+
function _mergeFilesChanged(acc, data) {
|
|
230
|
+
var next = acc || { paths: [], pathless: false, structural: false };
|
|
231
|
+
if (data && data.paths) next.paths = next.paths.concat(data.paths);
|
|
232
|
+
else next.pathless = true;
|
|
233
|
+
// Absent `structural` means an older server — the conservative read is true.
|
|
234
|
+
if (!data || data.structural !== false) next.structural = true;
|
|
235
|
+
return next;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _coalescedPayload(acc) {
|
|
239
|
+
var payload = { type: 'files_changed', structural: acc.structural };
|
|
240
|
+
if (!acc.pathless && acc.paths.length) {
|
|
241
|
+
var seen = {};
|
|
242
|
+
payload.paths = acc.paths.filter(function (p) {
|
|
243
|
+
if (seen['$' + p]) return false;
|
|
244
|
+
seen['$' + p] = true;
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return payload;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function _flushFilesChanged() {
|
|
252
|
+
_filesChangedTimer = null;
|
|
253
|
+
var acc = _filesChangedAcc;
|
|
254
|
+
_filesChangedAcc = null;
|
|
255
|
+
if (!acc) return;
|
|
256
|
+
var payload = _coalescedPayload(acc);
|
|
257
|
+
_filesChangedCallbacks.slice().forEach(function (fn) {
|
|
258
|
+
try { fn(payload); } catch (e) { /* ignore */ }
|
|
184
259
|
});
|
|
185
260
|
}
|
|
186
261
|
|
|
262
|
+
function _emitFilesChanged(data) {
|
|
263
|
+
_filesChangedAcc = _mergeFilesChanged(_filesChangedAcc, data);
|
|
264
|
+
if (_filesChangedTimer) return;
|
|
265
|
+
_filesChangedTimer = setTimeout(_flushFilesChanged, FILES_CHANGED_COALESCE_MS);
|
|
266
|
+
}
|
|
267
|
+
|
|
187
268
|
function _emitFileSaved(data) {
|
|
188
269
|
_fileSavedCallbacks.forEach(function (fn) {
|
|
189
270
|
try { fn(data); } catch (e) { /* ignore */ }
|
|
@@ -342,6 +423,9 @@ var WebSocketService = (function () {
|
|
|
342
423
|
subscribeCollaboration: subscribeCollaboration,
|
|
343
424
|
perform: perform,
|
|
344
425
|
onFilesChanged: onFilesChanged,
|
|
426
|
+
// Exposed for tests — the burst-merge invariants above are worth pinning.
|
|
427
|
+
_mergeFilesChanged: _mergeFilesChanged,
|
|
428
|
+
_coalescedPayload: _coalescedPayload,
|
|
345
429
|
offFilesChanged: offFilesChanged,
|
|
346
430
|
onFileSaved: onFileSaved,
|
|
347
431
|
offFileSaved: offFileSaved,
|