mbeditor 0.13.1 → 0.14.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 +91 -0
- data/app/assets/javascripts/mbeditor/application.js +3 -0
- data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
- data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
- data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
- data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
- data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +429 -247
- data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
- data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
- data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
- data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1168 -1243
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
- data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
- data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
- data/app/assets/javascripts/mbeditor/editor_plugins.js +687 -305
- data/app/assets/javascripts/mbeditor/file_import.js +13 -15
- data/app/assets/javascripts/mbeditor/file_service.js +38 -39
- data/app/assets/javascripts/mbeditor/git_service.js +15 -1
- data/app/assets/javascripts/mbeditor/history_service.js +5 -13
- data/app/assets/javascripts/mbeditor/search_service.js +9 -0
- data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
- data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
- data/app/assets/stylesheets/mbeditor/application.css +6 -1
- data/app/assets/stylesheets/mbeditor/editor.css +642 -248
- data/app/assets/stylesheets/mbeditor/glass.css +163 -0
- data/app/assets/stylesheets/mbeditor/themes.css +90 -30
- data/app/channels/mbeditor/collaboration_channel.rb +17 -4
- data/app/controllers/mbeditor/application_controller.rb +26 -3
- data/app/controllers/mbeditor/editors_controller.rb +117 -439
- data/app/services/mbeditor/archive_service.rb +137 -0
- data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
- data/app/services/mbeditor/editor_state_service.rb +14 -51
- data/app/services/mbeditor/file_history_service.rb +222 -0
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
- data/app/services/mbeditor/lint_service.rb +137 -0
- data/app/services/mbeditor/locked_json_file.rb +67 -0
- data/app/services/mbeditor/process_runner.rb +32 -0
- data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
- data/app/services/mbeditor/search_replace_service.rb +8 -0
- data/app/views/layouts/mbeditor/application.html.erb +1 -1
- data/lib/mbeditor/audit_log.rb +203 -0
- data/lib/mbeditor/configuration.rb +6 -1
- data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
- data/lib/mbeditor/route_map.rb +4 -0
- data/lib/mbeditor/ruby_lsp_client.rb +82 -14
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- metadata +12 -2
|
@@ -13,6 +13,16 @@ var FileImport = (function () {
|
|
|
13
13
|
// otherwise reject the request as a 500 before the server guard can run.
|
|
14
14
|
var MAX_ENTRIES = 100;
|
|
15
15
|
|
|
16
|
+
// Every collector returns this same shape; `truncated` is read off the full
|
|
17
|
+
// list, so it must be computed before the slice.
|
|
18
|
+
function capResult(list, foldersSkipped) {
|
|
19
|
+
return {
|
|
20
|
+
entries: list.slice(0, MAX_ENTRIES),
|
|
21
|
+
truncated: list.length > MAX_ENTRIES,
|
|
22
|
+
foldersSkipped: !!foldersSkipped
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
function hasExternalFiles(dataTransfer) {
|
|
17
27
|
if (!dataTransfer || !dataTransfer.types) return false;
|
|
18
28
|
return Array.prototype.indexOf.call(dataTransfer.types, 'Files') !== -1;
|
|
@@ -41,20 +51,12 @@ var FileImport = (function () {
|
|
|
41
51
|
for (var j = 0; j < files.length; j++) {
|
|
42
52
|
flat.push({ file: files[j], relativePath: files[j].name });
|
|
43
53
|
}
|
|
44
|
-
return Promise.resolve(
|
|
45
|
-
entries: flat.slice(0, MAX_ENTRIES),
|
|
46
|
-
truncated: flat.length > MAX_ENTRIES,
|
|
47
|
-
foldersSkipped: !!(items && items.length > flat.length)
|
|
48
|
-
});
|
|
54
|
+
return Promise.resolve(capResult(flat, items && items.length > flat.length));
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
var collected = [];
|
|
52
58
|
return walkAll(roots, collected).then(function () {
|
|
53
|
-
return
|
|
54
|
-
entries: collected.slice(0, MAX_ENTRIES),
|
|
55
|
-
truncated: collected.length > MAX_ENTRIES,
|
|
56
|
-
foldersSkipped: false
|
|
57
|
-
};
|
|
59
|
+
return capResult(collected, false);
|
|
58
60
|
});
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -99,11 +101,7 @@ var FileImport = (function () {
|
|
|
99
101
|
var f = fileList[i];
|
|
100
102
|
out.push({ file: f, relativePath: stripLeadingSlash(f.webkitRelativePath || f.name) });
|
|
101
103
|
}
|
|
102
|
-
return
|
|
103
|
-
entries: out.slice(0, MAX_ENTRIES),
|
|
104
|
-
truncated: out.length > MAX_ENTRIES,
|
|
105
|
-
foldersSkipped: false
|
|
106
|
-
};
|
|
104
|
+
return capResult(out, false);
|
|
107
105
|
}
|
|
108
106
|
|
|
109
107
|
// Where in the workspace does this set of files already live?
|
|
@@ -140,56 +140,47 @@ axios.interceptors.response.use(function (response) {
|
|
|
140
140
|
//
|
|
141
141
|
// The editor keeps working while migrations are pending — the server-side
|
|
142
142
|
// bypass (Mbeditor::Rack::PendingMigrationBypass) is what makes that true — so
|
|
143
|
-
// this is purely informational
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
143
|
+
// this is purely informational. It is driven by a response *header* rather than
|
|
144
|
+
// by a failed request, for two reasons: after the bypass there is no failed
|
|
145
|
+
// request left to hang it on, and a header rides every response, so a migration
|
|
146
|
+
// created mid-session raises the warning too, and running the migration clears
|
|
147
|
+
// it without a reload.
|
|
148
148
|
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
'<strong>Pending migrations detected.</strong>' +
|
|
165
|
-
' Run <code style="background:rgba(0,0,0,.15);padding:1px 5px;border-radius:3px">rails db:migrate</code>' +
|
|
166
|
-
' then reload — editing still works in the meantime.' +
|
|
167
|
-
'<button onclick="this.parentNode.remove()" style="margin-left:auto;background:none;border:none;' +
|
|
168
|
-
'cursor:pointer;font-size:16px;line-height:1;padding:0 4px" aria-label="Dismiss">\u00d7</button>';
|
|
169
|
-
document.body.prepend(banner);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function _hideMigrationBanner() {
|
|
173
|
-
var existing = document.getElementById(MIGRATION_BANNER_ID);
|
|
174
|
-
if (existing) existing.remove();
|
|
149
|
+
// Only an explicit "1"/"0" counts. A response with no header at all is no
|
|
150
|
+
// evidence either way: an error page rendered by ActionDispatch::DebugExceptions
|
|
151
|
+
// never carries one (that middleware sits above the bypass), and treating its
|
|
152
|
+
// absence as "cleared" made the warning flicker off and back on.
|
|
153
|
+
//
|
|
154
|
+
// The state lives as a class on <body> so the CSS can recolour the status bar
|
|
155
|
+
// without React; the event is what the status bar's warning listens to.
|
|
156
|
+
var MIGRATION_PENDING_CLASS = 'mbeditor-migration-pending';
|
|
157
|
+
var MIGRATION_PENDING_EVENT = 'mbeditor:pending-migration';
|
|
158
|
+
|
|
159
|
+
function _setPendingMigration(pending) {
|
|
160
|
+
if (!document.body) return;
|
|
161
|
+
if (document.body.classList.contains(MIGRATION_PENDING_CLASS) === pending) return;
|
|
162
|
+
document.body.classList.toggle(MIGRATION_PENDING_CLASS, pending);
|
|
163
|
+
window.dispatchEvent(new CustomEvent(MIGRATION_PENDING_EVENT, { detail: pending }));
|
|
175
164
|
}
|
|
176
165
|
|
|
177
|
-
// Mirrors Mbeditor::Rack::PendingMigrationBypass::PENDING_HEADER.
|
|
178
|
-
// the check passed, so the banner is cleared as soon as the migration is run —
|
|
179
|
-
// no reload needed.
|
|
166
|
+
// Mirrors Mbeditor::Rack::PendingMigrationBypass::PENDING_HEADER.
|
|
180
167
|
function _notePendingMigrationHeader(response) {
|
|
181
168
|
if (!response || !response.headers) return;
|
|
182
|
-
|
|
183
|
-
|
|
169
|
+
var value = response.headers['x-mbeditor-pending-migration'];
|
|
170
|
+
if (value === undefined || value === null || value === '') return;
|
|
171
|
+
_setPendingMigration(value !== '0');
|
|
184
172
|
}
|
|
185
173
|
|
|
186
174
|
axios.interceptors.response.use(function (response) {
|
|
187
175
|
_notePendingMigrationHeader(response);
|
|
188
176
|
return response;
|
|
189
177
|
}, function (error) {
|
|
178
|
+
// A PendingMigrationError raised somewhere the bypass does not cover.
|
|
190
179
|
if (error && error.response && error.response.data &&
|
|
191
180
|
error.response.data.pending_migration_error) {
|
|
192
|
-
|
|
181
|
+
_setPendingMigration(true);
|
|
182
|
+
} else if (error && error.response) {
|
|
183
|
+
_notePendingMigrationHeader(error.response);
|
|
193
184
|
}
|
|
194
185
|
return Promise.reject(error);
|
|
195
186
|
});
|
|
@@ -242,9 +233,17 @@ var FileService = (function () {
|
|
|
242
233
|
// Multipart import of files dragged in from outside the browser. The
|
|
243
234
|
// default 30 s axios timeout is too tight for a large drop on a slow disk,
|
|
244
235
|
// so this one call gets a longer leash.
|
|
245
|
-
|
|
236
|
+
// onProgress(percentOrNull) fires as the body uploads, then once more with
|
|
237
|
+
// null when the bytes are away and the server is still writing. A big drop
|
|
238
|
+
// on a slow link otherwise sits on one static message long enough to look
|
|
239
|
+
// like it has hung.
|
|
240
|
+
function importFiles(formData, onProgress) {
|
|
246
241
|
return axios.post(window.mbeditorBasePath() + '/import', formData, {
|
|
247
|
-
timeout: 120000
|
|
242
|
+
timeout: 120000,
|
|
243
|
+
onUploadProgress: onProgress ? function (e) {
|
|
244
|
+
var done = e.loaded === e.total;
|
|
245
|
+
onProgress(!done && e.total ? Math.round((e.loaded / e.total) * 100) : null);
|
|
246
|
+
} : undefined
|
|
248
247
|
}).then(function (res) { return res.data; });
|
|
249
248
|
}
|
|
250
249
|
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
// Git's seven unmerged porcelain codes. Defined here because git_service.js
|
|
2
|
+
// loads before both consumers (GitPanel's status badges and the status bar's
|
|
3
|
+
// conflict chip) and one list is the point: testing the first character
|
|
4
|
+
// instead reads AA as "added" and DD as "deleted", so four of the seven
|
|
5
|
+
// conflicts get mislabelled.
|
|
6
|
+
window.MBEDITOR_UNMERGED_STATUSES = { DD: 1, AU: 1, UD: 1, UA: 1, DU: 1, AA: 1, UU: 1 };
|
|
7
|
+
|
|
1
8
|
var GitService = (function () {
|
|
2
9
|
function applyGitInfo(data) {
|
|
3
10
|
var files = data.workingTree || data.files || [];
|
|
@@ -71,6 +78,7 @@ var GitService = (function () {
|
|
|
71
78
|
// unreachable rather than failing over and over in the console.
|
|
72
79
|
function fetchStatusLite(opts) {
|
|
73
80
|
var cfg = (opts && opts.background) ? { mbeditorBackground: true } : {};
|
|
81
|
+
var startedAt = Date.now();
|
|
74
82
|
return axios.get(window.mbeditorBasePath() + '/git_status', cfg)
|
|
75
83
|
.then(function(res) {
|
|
76
84
|
var data = res.data;
|
|
@@ -79,6 +87,8 @@ var GitService = (function () {
|
|
|
79
87
|
var st = EditorStore.getState();
|
|
80
88
|
var prevInfo = st.gitInfo;
|
|
81
89
|
var files = data.files || [];
|
|
90
|
+
var audit = window.MbeditorAudit;
|
|
91
|
+
if (audit) audit.rec(audit.EV.GIT_POLL, Date.now() - startedAt, files.length);
|
|
82
92
|
var gitSig = function(arr) { return arr.map(function(f) { return f.path + '\x00' + f.status; }).join('\x01'); };
|
|
83
93
|
var branchChanged = (data.branch || "") !== (st.gitBranch || "");
|
|
84
94
|
var treeChanged = gitSig(files) !== gitSig(st.gitFiles || []);
|
|
@@ -123,7 +133,11 @@ var GitService = (function () {
|
|
|
123
133
|
|
|
124
134
|
return data;
|
|
125
135
|
})
|
|
126
|
-
.catch(function () {
|
|
136
|
+
.catch(function () {
|
|
137
|
+
// transient poll errors retry on the next tick
|
|
138
|
+
var audit = window.MbeditorAudit;
|
|
139
|
+
if (audit) audit.rec(audit.EV.ERR, audit.EV.GIT_POLL);
|
|
140
|
+
});
|
|
127
141
|
}
|
|
128
142
|
|
|
129
143
|
function fetchStatus() {
|
|
@@ -13,6 +13,10 @@ var HistoryService = (function () {
|
|
|
13
13
|
var _idleTimers = {};
|
|
14
14
|
|
|
15
15
|
var IDLE_MS = 30000;
|
|
16
|
+
// Server-side history format. 2 means tracking starts after the file load, so
|
|
17
|
+
// the load is the base rather than an insert-at-origin op (see #92/#93). The
|
|
18
|
+
// server uses this to tell a legacy payload from a current one.
|
|
19
|
+
var FORMAT_VERSION = 2;
|
|
16
20
|
|
|
17
21
|
function _key(branch, filePath) {
|
|
18
22
|
return branch + ':' + filePath;
|
|
@@ -40,16 +44,6 @@ var HistoryService = (function () {
|
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
function stopTracking(filePath) {
|
|
44
|
-
var rec = _tracking[filePath];
|
|
45
|
-
if (!rec) return;
|
|
46
|
-
var k = _key(rec.branch, filePath);
|
|
47
|
-
clearTimeout(_idleTimers[k]);
|
|
48
|
-
delete _idleTimers[k];
|
|
49
|
-
flush(rec.branch, filePath);
|
|
50
|
-
delete _tracking[filePath];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
47
|
function setReplayInProgress(filePath, inProgress) {
|
|
54
48
|
if (inProgress) {
|
|
55
49
|
_replayingPaths[filePath] = true;
|
|
@@ -86,7 +80,7 @@ var HistoryService = (function () {
|
|
|
86
80
|
clearTimeout(_idleTimers[k]);
|
|
87
81
|
delete _idleTimers[k];
|
|
88
82
|
|
|
89
|
-
var body = { branch: branch, path: filePath, ops: ops };
|
|
83
|
+
var body = { branch: branch, path: filePath, ops: ops, v: FORMAT_VERSION };
|
|
90
84
|
if (_bases.hasOwnProperty(k)) {
|
|
91
85
|
body.base = _bases[k];
|
|
92
86
|
delete _bases[k];
|
|
@@ -161,10 +155,8 @@ var HistoryService = (function () {
|
|
|
161
155
|
return {
|
|
162
156
|
beginTracking: beginTracking,
|
|
163
157
|
resumeTracking: resumeTracking,
|
|
164
|
-
stopTracking: stopTracking,
|
|
165
158
|
setReplayInProgress: setReplayInProgress,
|
|
166
159
|
recordOps: recordOps,
|
|
167
|
-
flush: flush,
|
|
168
160
|
flushAll: flushAll,
|
|
169
161
|
flushForPath: flushForPath,
|
|
170
162
|
fetchHistory: fetchHistory
|
|
@@ -145,6 +145,7 @@ var SearchService = (function () {
|
|
|
145
145
|
var controller = new AbortController();
|
|
146
146
|
_searchController = controller;
|
|
147
147
|
|
|
148
|
+
var startedAt = Date.now();
|
|
148
149
|
return axios.get(window.mbeditorBasePath() + '/search', {
|
|
149
150
|
params: { q: query, offset: off, limit: lim, regex: useRegex ? 'true' : 'false', match_case: matchCase ? 'true' : 'false', whole_word: wholeWord ? 'true' : 'false' },
|
|
150
151
|
signal: controller.signal
|
|
@@ -155,6 +156,12 @@ var SearchService = (function () {
|
|
|
155
156
|
var hasMore = !Array.isArray(data) && !!(data && data.has_more);
|
|
156
157
|
var totalCount = (data && data.total_count != null) ? data.total_count : null;
|
|
157
158
|
|
|
159
|
+
var audit = window.MbeditorAudit;
|
|
160
|
+
if (audit) {
|
|
161
|
+
audit.rec(audit.EV.SEARCH, audit.code('searchBackend', window.MBEDITOR_SEARCH_BACKEND),
|
|
162
|
+
Date.now() - startedAt, results.length);
|
|
163
|
+
}
|
|
164
|
+
|
|
158
165
|
var payload = { results: results, hasMore: hasMore, totalCount: totalCount };
|
|
159
166
|
_cacheSet(key, payload);
|
|
160
167
|
|
|
@@ -170,6 +177,8 @@ var SearchService = (function () {
|
|
|
170
177
|
if (axios.isCancel(err) || (err && err.name === 'CanceledError')) {
|
|
171
178
|
return { results: [], hasMore: false, totalCount: null };
|
|
172
179
|
}
|
|
180
|
+
var audit = window.MbeditorAudit;
|
|
181
|
+
if (audit) audit.rec(audit.EV.ERR, audit.EV.SEARCH);
|
|
173
182
|
EditorStore.setStatus("Search failed: " + err.message, "error");
|
|
174
183
|
return { results: [], hasMore: false, totalCount: null };
|
|
175
184
|
});
|
|
@@ -145,10 +145,12 @@ var TabManager = (function () {
|
|
|
145
145
|
});
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
//
|
|
149
|
-
// PageUp/PageDown
|
|
150
|
-
|
|
151
|
-
var
|
|
148
|
+
// Back/forward navigation history: every real jump or file change pushes
|
|
149
|
+
// an entry; PageUp/PageDown walk it like browser back/forward.
|
|
150
|
+
var _navStack = [];
|
|
151
|
+
var _navIndex = -1;
|
|
152
|
+
var _navigating = false;
|
|
153
|
+
var NAV_STACK_MAX = 50;
|
|
152
154
|
|
|
153
155
|
function _snapshotPosition() {
|
|
154
156
|
var editor = window.__mbeditorActiveEditor;
|
|
@@ -161,19 +163,40 @@ var TabManager = (function () {
|
|
|
161
163
|
return { path: tab.path, name: tab.name, line: pos.lineNumber, col: pos.column };
|
|
162
164
|
}
|
|
163
165
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
166
|
+
// Truncates any forward entries, then pushes. Skips a push that would
|
|
167
|
+
// duplicate the current top entry's path+line, and is a no-op entirely
|
|
168
|
+
// while a navigateBack/navigateForward-driven openTab is replaying.
|
|
169
|
+
function _pushNavEntry(entry) {
|
|
170
|
+
if (_navigating || !entry || !entry.path) return;
|
|
171
|
+
if (_navIndex > -1) _navStack = _navStack.slice(0, _navIndex + 1);
|
|
172
|
+
var top = _navStack[_navStack.length - 1];
|
|
173
|
+
if (top && top.path === entry.path && top.line === entry.line) return;
|
|
174
|
+
_navStack.push(entry);
|
|
175
|
+
if (_navStack.length > NAV_STACK_MAX) _navStack.shift();
|
|
176
|
+
_navIndex = _navStack.length - 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Refreshing the entry we are leaving from the live cursor is what makes
|
|
180
|
+
// going back land where you actually were, not where the entry was created.
|
|
181
|
+
function _navigate(step) {
|
|
182
|
+
var next = _navIndex + step;
|
|
183
|
+
if (_navIndex < 0 || next < 0 || next >= _navStack.length) return;
|
|
184
|
+
var live = _snapshotPosition();
|
|
185
|
+
if (live) _navStack[_navIndex] = live;
|
|
186
|
+
_navIndex = next;
|
|
187
|
+
var target = _navStack[next];
|
|
188
|
+
_navigating = true;
|
|
189
|
+
try { openTab(target.path, target.name, target.line, null, false, target.col); }
|
|
190
|
+
finally { _navigating = false; }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function navigateBack() { _navigate(-1); }
|
|
194
|
+
function navigateForward() { _navigate(1); }
|
|
171
195
|
|
|
172
196
|
// col/endCol are the 1-based bounds of the thing being jumped to. Pass both
|
|
173
197
|
// and the editor selects it, leaving the cursor at its end; pass col alone
|
|
174
198
|
// and it just parks the cursor there.
|
|
175
199
|
function openTab(path, name, line, forcePaneId, isSoftOpen, col, endCol) {
|
|
176
|
-
if (line) _jumpOrigin = _snapshotPosition() || _jumpOrigin;
|
|
177
200
|
var state = EditorStore.getState();
|
|
178
201
|
var paneId = forcePaneId || state.focusedPaneId;
|
|
179
202
|
var pane = state.panes.find(function(p) { return p.id === paneId; });
|
|
@@ -189,13 +212,14 @@ var TabManager = (function () {
|
|
|
189
212
|
|
|
190
213
|
if (!pane) return;
|
|
191
214
|
|
|
215
|
+
_pushNavEntry({ path: path, name: name, line: line || null, col: col || null });
|
|
192
216
|
_recordRecentFile(path, name);
|
|
193
217
|
|
|
194
218
|
var existing = pane.tabs.find(function(t) { return t.path === path; });
|
|
195
219
|
|
|
196
220
|
if (existing) {
|
|
197
221
|
if (line) _updateTab(paneId, path, { gotoLine: line, gotoCol: col || null, gotoEndCol: endCol || null });
|
|
198
|
-
|
|
222
|
+
_switchTab(paneId, path);
|
|
199
223
|
if (_isMarkdownPath(path)) {
|
|
200
224
|
_ensureMarkdownPreview(paneId, path, existing.name || name, existing.content || "");
|
|
201
225
|
}
|
|
@@ -230,6 +254,7 @@ var TabManager = (function () {
|
|
|
230
254
|
});
|
|
231
255
|
|
|
232
256
|
EditorStore.setState({ panes: newPanes, focusedPaneId: paneId, activeTabId: path });
|
|
257
|
+
_promoteMru(paneId, path);
|
|
233
258
|
|
|
234
259
|
// Virtual paths (diff://, combined-diff://) should never hit the file endpoint
|
|
235
260
|
if (path.indexOf('diff://') === 0 || path.indexOf('combined-diff://') === 0) {
|
|
@@ -237,6 +262,7 @@ var TabManager = (function () {
|
|
|
237
262
|
}
|
|
238
263
|
|
|
239
264
|
// Use a prefetched result if available (hover-prefetch hit), otherwise fetch normally.
|
|
265
|
+
var startedAt = Date.now();
|
|
240
266
|
var prefetchPromise = FileService.getPrefetched(path);
|
|
241
267
|
var filePromise = prefetchPromise || FileService.getFile(path, { allowMissing: true });
|
|
242
268
|
|
|
@@ -249,6 +275,11 @@ var TabManager = (function () {
|
|
|
249
275
|
if (!data) { closeTab(paneId, path); return; }
|
|
250
276
|
var loadedContent = typeof data.content === 'string' ? data.content : "";
|
|
251
277
|
var fileNotFound = data && data.missing === true;
|
|
278
|
+
var audit = window.MbeditorAudit;
|
|
279
|
+
if (audit) {
|
|
280
|
+
audit.rec(audit.EV.OPEN, audit.code('ext', String(path).split('.').pop().toLowerCase()),
|
|
281
|
+
loadedContent.length, Date.now() - startedAt);
|
|
282
|
+
}
|
|
252
283
|
_updateTab(paneId, path, {
|
|
253
284
|
content: loadedContent,
|
|
254
285
|
cleanContent: loadedContent,
|
|
@@ -263,6 +294,8 @@ var TabManager = (function () {
|
|
|
263
294
|
_syncMarkdownPreviewContent(path, typeof data.content === 'string' ? data.content : "");
|
|
264
295
|
}
|
|
265
296
|
}).catch(function(err) {
|
|
297
|
+
var audit = window.MbeditorAudit;
|
|
298
|
+
if (audit) audit.rec(audit.EV.ERR, audit.EV.OPEN);
|
|
266
299
|
if (path.startsWith('diff://')) return; // diff tabs handle their own loading
|
|
267
300
|
if (err.response && err.response.status === 413) {
|
|
268
301
|
FileService.getFileChunk(path, 0, 500).then(function(data) {
|
|
@@ -426,18 +459,34 @@ var TabManager = (function () {
|
|
|
426
459
|
if (typeof HistoryService !== 'undefined') {
|
|
427
460
|
HistoryService.flushForPath(path);
|
|
428
461
|
}
|
|
462
|
+
_removeMru(paneId, path);
|
|
429
463
|
var state = EditorStore.getState();
|
|
464
|
+
// Closing a markdown source also closes its preview tab, wherever it lives.
|
|
465
|
+
// Guard against recursion: a preview tab has no preview of its own.
|
|
466
|
+
var closingPane = state.panes.find(function(p) { return p.id === paneId; });
|
|
467
|
+
var closingTab = closingPane && closingPane.tabs.find(function(t) { return t.id === path; });
|
|
468
|
+
var closePreviewsFor = (closingTab && !closingTab.isPreview) ? path : null;
|
|
469
|
+
|
|
430
470
|
var newPanes = state.panes.map(function(pane) {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
471
|
+
// Match by t.id so diff tabs (id = 'diff://...') are closed correctly
|
|
472
|
+
var newTabs = pane.tabs.filter(function(t) {
|
|
473
|
+
if (pane.id === paneId && t.id === path) return false;
|
|
474
|
+
if (closePreviewsFor && t.isPreview && t.previewFor === closePreviewsFor) return false;
|
|
475
|
+
return true;
|
|
476
|
+
});
|
|
477
|
+
if (newTabs.length === pane.tabs.length) return pane;
|
|
478
|
+
|
|
479
|
+
var newActive = pane.activeTabId;
|
|
480
|
+
if (!newTabs.some(function(t) { return t.id === newActive; })) {
|
|
481
|
+
// Reactivate the tab the user was actually last in, not just the rightmost one.
|
|
482
|
+
var mruList = _mru[pane.id] || [];
|
|
483
|
+
var mruPick = null;
|
|
484
|
+
for (var i = 0; i < mruList.length; i++) {
|
|
485
|
+
if (newTabs.some(function(t) { return t.id === mruList[i]; })) { mruPick = mruList[i]; break; }
|
|
437
486
|
}
|
|
438
|
-
|
|
487
|
+
newActive = mruPick || (newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null);
|
|
439
488
|
}
|
|
440
|
-
return pane;
|
|
489
|
+
return Object.assign({}, pane, { tabs: newTabs, activeTabId: newActive });
|
|
441
490
|
});
|
|
442
491
|
|
|
443
492
|
var nextFocusedPaneId = state.focusedPaneId;
|
|
@@ -497,7 +546,7 @@ var TabManager = (function () {
|
|
|
497
546
|
if (!pane || pane.tabs.length === 0) return;
|
|
498
547
|
|
|
499
548
|
pane.tabs.slice().forEach(function(tab) {
|
|
500
|
-
closeTab(paneId, tab.
|
|
549
|
+
closeTab(paneId, tab.id);
|
|
501
550
|
});
|
|
502
551
|
}
|
|
503
552
|
|
|
@@ -528,13 +577,43 @@ var TabManager = (function () {
|
|
|
528
577
|
});
|
|
529
578
|
}
|
|
530
579
|
|
|
531
|
-
|
|
580
|
+
// Most-recently-used tab ids per pane, most-recent first. Lets closeTab
|
|
581
|
+
// reactivate the tab the user was actually last in, not just the rightmost one.
|
|
582
|
+
var _mru = {};
|
|
583
|
+
var MRU_MAX = 20;
|
|
584
|
+
|
|
585
|
+
function _promoteMru(paneId, tabId) {
|
|
586
|
+
if (!tabId) return;
|
|
587
|
+
var list = _mru[paneId] || (_mru[paneId] = []);
|
|
588
|
+
var idx = list.indexOf(tabId);
|
|
589
|
+
if (idx !== -1) list.splice(idx, 1);
|
|
590
|
+
list.unshift(tabId);
|
|
591
|
+
if (list.length > MRU_MAX) list.length = MRU_MAX;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function _removeMru(paneId, tabId) {
|
|
595
|
+
var list = _mru[paneId];
|
|
596
|
+
if (!list) return;
|
|
597
|
+
var idx = list.indexOf(tabId);
|
|
598
|
+
if (idx !== -1) list.splice(idx, 1);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function _switchTab(paneId, path) {
|
|
532
602
|
var state = EditorStore.getState();
|
|
533
603
|
var newPanes = state.panes.map(function(p) {
|
|
534
604
|
if (p.id === paneId) return Object.assign({}, p, { activeTabId: path });
|
|
535
605
|
return p;
|
|
536
606
|
});
|
|
537
607
|
EditorStore.setState({ panes: newPanes, focusedPaneId: paneId, activeTabId: path });
|
|
608
|
+
_promoteMru(paneId, path);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function switchTab(paneId, path) {
|
|
612
|
+
_switchTab(paneId, path);
|
|
613
|
+
var state = EditorStore.getState();
|
|
614
|
+
var pane = state.panes.find(function(p) { return p.id === paneId; });
|
|
615
|
+
var tab = pane && pane.tabs.find(function(t) { return t.id === path; });
|
|
616
|
+
_pushNavEntry({ path: path, name: tab ? tab.name : null, line: null, col: null });
|
|
538
617
|
}
|
|
539
618
|
|
|
540
619
|
function focusPane(paneId) {
|
|
@@ -564,12 +643,12 @@ var TabManager = (function () {
|
|
|
564
643
|
function _queueContent(paneId, path, content, updates, markdown) {
|
|
565
644
|
var key = paneId + '' + path;
|
|
566
645
|
if (updates) {
|
|
567
|
-
// A
|
|
568
|
-
|
|
569
|
-
|
|
646
|
+
// A clean<->dirty transition must land now, but the full buffer is still
|
|
647
|
+
// materialized only on the trailing edge below. Callers pass a provider
|
|
648
|
+
// (a function) from the per-keystroke content listener so getValue() runs
|
|
649
|
+
// once per flush instead of once per keypress; everything else passes the
|
|
650
|
+
// string it already holds.
|
|
570
651
|
_updateTab(paneId, path, updates);
|
|
571
|
-
if (markdown) _syncMarkdownPreviewContent(path, content);
|
|
572
|
-
return;
|
|
573
652
|
}
|
|
574
653
|
_pendingContent[key] = { paneId: paneId, path: path, content: content, markdown: markdown };
|
|
575
654
|
if (_contentTimer === null) {
|
|
@@ -577,6 +656,13 @@ var TabManager = (function () {
|
|
|
577
656
|
}
|
|
578
657
|
}
|
|
579
658
|
|
|
659
|
+
// A pending content slot holds either a string or a provider invoked at flush
|
|
660
|
+
// time. A provider whose model has gone away returns nothing and is skipped.
|
|
661
|
+
function _resolveContent(content) {
|
|
662
|
+
if (typeof content !== 'function') return content;
|
|
663
|
+
try { return content(); } catch (e) { return null; }
|
|
664
|
+
}
|
|
665
|
+
|
|
580
666
|
function flushContent() {
|
|
581
667
|
if (_contentTimer !== null) {
|
|
582
668
|
clearTimeout(_contentTimer);
|
|
@@ -586,8 +672,10 @@ var TabManager = (function () {
|
|
|
586
672
|
_pendingContent = {};
|
|
587
673
|
Object.keys(pending).forEach(function (k) {
|
|
588
674
|
var p = pending[k];
|
|
589
|
-
|
|
590
|
-
if (
|
|
675
|
+
var content = _resolveContent(p.content);
|
|
676
|
+
if (typeof content !== 'string') return;
|
|
677
|
+
_updateTab(p.paneId, p.path, { content: content });
|
|
678
|
+
if (p.markdown) _syncMarkdownPreviewContent(p.path, content);
|
|
591
679
|
});
|
|
592
680
|
}
|
|
593
681
|
|
|
@@ -617,24 +705,13 @@ var TabManager = (function () {
|
|
|
617
705
|
_updateTab(paneId, path, { isSoftOpen: false });
|
|
618
706
|
}
|
|
619
707
|
|
|
620
|
-
function saveTabViewState(
|
|
621
|
-
var
|
|
622
|
-
var
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
var state = EditorStore.getState();
|
|
628
|
-
path = paneIdOrPath;
|
|
629
|
-
viewState = pathOrViewState;
|
|
630
|
-
var containingPane = state.panes.find(function(p) {
|
|
631
|
-
return p.tabs.some(function(t) { return t.path === path; });
|
|
632
|
-
});
|
|
633
|
-
if (!containingPane) return;
|
|
634
|
-
paneId = containingPane.id;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
_updateTab(paneId, path, { viewState: viewState });
|
|
708
|
+
function saveTabViewState(path, viewState) {
|
|
709
|
+
var state = EditorStore.getState();
|
|
710
|
+
var containingPane = state.panes.find(function(p) {
|
|
711
|
+
return p.tabs.some(function(t) { return t.path === path; });
|
|
712
|
+
});
|
|
713
|
+
if (!containingPane) return;
|
|
714
|
+
_updateTab(containingPane.id, path, { viewState: viewState });
|
|
638
715
|
}
|
|
639
716
|
|
|
640
717
|
function reorderTabInPane(paneId, tabId, insertBeforeTabId) {
|
|
@@ -755,7 +832,8 @@ var TabManager = (function () {
|
|
|
755
832
|
reorderTabInPane: reorderTabInPane,
|
|
756
833
|
moveTabToPane: moveTabToPane,
|
|
757
834
|
clearGotoLine: clearGotoLine,
|
|
758
|
-
|
|
835
|
+
navigateBack: navigateBack,
|
|
836
|
+
navigateForward: navigateForward,
|
|
759
837
|
closeAllTabsInPane: closeAllTabsInPane,
|
|
760
838
|
closeOtherTabsInPane: closeOtherTabsInPane,
|
|
761
839
|
closeSavedTabsInPane: closeSavedTabsInPane,
|
|
@@ -75,6 +75,17 @@ var WebSocketService = (function () {
|
|
|
75
75
|
return window.ActionCable.createConsumer(_cableUrl());
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Single consumer for both the EditorChannel and every CollaborationChannel.
|
|
79
|
+
// subscribeCollaboration used to mint its own consumer whenever _consumer was
|
|
80
|
+
// null (the state after a drop), and _attemptConnect then created a second one
|
|
81
|
+
// — the first socket was never disconnected, so every outage during which a
|
|
82
|
+
// file was opened leaked a WebSocket. Whoever asks first creates it; everyone
|
|
83
|
+
// else reuses it.
|
|
84
|
+
function _ensureConsumer() {
|
|
85
|
+
if (!_consumer) _consumer = _getConsumer();
|
|
86
|
+
return _consumer;
|
|
87
|
+
}
|
|
88
|
+
|
|
78
89
|
function _cleanupConsumer() {
|
|
79
90
|
if (_subscription) {
|
|
80
91
|
try { _subscription.unsubscribe(); } catch (e) { /* ignore */ }
|
|
@@ -146,7 +157,7 @@ var WebSocketService = (function () {
|
|
|
146
157
|
if (_status !== 'rejected') _status = 'connecting';
|
|
147
158
|
|
|
148
159
|
try {
|
|
149
|
-
_consumer =
|
|
160
|
+
_consumer = _ensureConsumer();
|
|
150
161
|
_subscription = _consumer.subscriptions.create(
|
|
151
162
|
{ channel: 'Mbeditor::EditorChannel' },
|
|
152
163
|
{
|
|
@@ -344,10 +355,7 @@ var WebSocketService = (function () {
|
|
|
344
355
|
if (!isCableAvailable()) return null;
|
|
345
356
|
handlers = handlers || {};
|
|
346
357
|
try {
|
|
347
|
-
|
|
348
|
-
_consumer = _getConsumer();
|
|
349
|
-
}
|
|
350
|
-
return _consumer.subscriptions.create(
|
|
358
|
+
return _ensureConsumer().subscriptions.create(
|
|
351
359
|
{ channel: 'Mbeditor::CollaborationChannel', path: path },
|
|
352
360
|
{
|
|
353
361
|
connected: function () { if (handlers.connected) handlers.connected(); },
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
*= require pico.classless
|
|
3
3
|
*= require mbeditor/themes
|
|
4
4
|
*= require mbeditor/editor
|
|
5
|
+
*= require mbeditor/glass
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
/* ── Git & Code Review Styles ───────────────────────────────────────── */
|
|
@@ -641,7 +642,7 @@
|
|
|
641
642
|
}
|
|
642
643
|
.redmine-badge {
|
|
643
644
|
background-color: var(--ide-accent);
|
|
644
|
-
color: var(--ide-accent
|
|
645
|
+
color: var(--ide-on-accent);
|
|
645
646
|
font-size: 10px;
|
|
646
647
|
padding: 2px 6px;
|
|
647
648
|
border-radius: 10px;
|
|
@@ -858,6 +859,10 @@
|
|
|
858
859
|
.search-loading-spinner {
|
|
859
860
|
animation-duration: 0.7s !important;
|
|
860
861
|
}
|
|
862
|
+
|
|
863
|
+
.statusbar-offline {
|
|
864
|
+
animation: none;
|
|
865
|
+
}
|
|
861
866
|
}
|
|
862
867
|
|
|
863
868
|
.cdiff-ctx { color: var(--ide-text-muted); }
|