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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -0
  3. data/app/assets/javascripts/mbeditor/application.js +3 -0
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
  6. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
  7. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
  8. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
  9. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
  10. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  11. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +429 -247
  12. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  13. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  14. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  15. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  16. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  17. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1168 -1243
  18. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  19. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
  20. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
  21. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  22. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  23. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  24. data/app/assets/javascripts/mbeditor/editor_plugins.js +687 -305
  25. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  26. data/app/assets/javascripts/mbeditor/file_service.js +38 -39
  27. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  28. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  29. data/app/assets/javascripts/mbeditor/search_service.js +9 -0
  30. data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
  31. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  32. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  33. data/app/assets/stylesheets/mbeditor/editor.css +642 -248
  34. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  35. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  36. data/app/channels/mbeditor/collaboration_channel.rb +17 -4
  37. data/app/controllers/mbeditor/application_controller.rb +26 -3
  38. data/app/controllers/mbeditor/editors_controller.rb +117 -439
  39. data/app/services/mbeditor/archive_service.rb +137 -0
  40. data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
  41. data/app/services/mbeditor/editor_state_service.rb +14 -51
  42. data/app/services/mbeditor/file_history_service.rb +222 -0
  43. data/app/services/mbeditor/git_info_service.rb +6 -0
  44. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  45. data/app/services/mbeditor/lint_service.rb +137 -0
  46. data/app/services/mbeditor/locked_json_file.rb +67 -0
  47. data/app/services/mbeditor/process_runner.rb +32 -0
  48. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  49. data/app/services/mbeditor/search_replace_service.rb +8 -0
  50. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  51. data/lib/mbeditor/audit_log.rb +203 -0
  52. data/lib/mbeditor/configuration.rb +6 -1
  53. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  54. data/lib/mbeditor/route_map.rb +4 -0
  55. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  56. data/lib/mbeditor/version.rb +1 -1
  57. data/lib/mbeditor.rb +1 -0
  58. 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 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.
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
- // 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 =
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. Absent means
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
- if (response.headers['x-mbeditor-pending-migration']) _showMigrationBanner();
183
- else _hideMigrationBanner();
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
- _showMigrationBanner();
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
- function importFiles(formData) {
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 () {}); // transient poll errors retry on the next tick
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
- // Single slot holding "where the cursor was before the last jump".
149
- // PageUp/PageDown swap the cursor between this slot and its current spot.
150
- // ponytail: two-position cycle, upgrade to a real back/forward stack if asked
151
- var _jumpOrigin = null;
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
- function toggleJumpOrigin() {
165
- var target = _jumpOrigin;
166
- if (!target) return;
167
- // openTab re-snapshots the current position into _jumpOrigin below,
168
- // which is exactly the swap we want.
169
- openTab(target.path, target.name, target.line, null, false, target.col);
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
- switchTab(paneId, path);
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
- if (pane.id === paneId) {
432
- // Match by t.id so diff tabs (id = 'diff://...') are closed correctly
433
- var newTabs = pane.tabs.filter(function(t) { return t.id !== path; });
434
- var newActive = pane.activeTabId;
435
- if (pane.activeTabId === path) {
436
- newActive = newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null;
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
- return Object.assign({}, pane, { tabs: newTabs, activeTabId: newActive });
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.path);
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
- function switchTab(paneId, path) {
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 state transition has to land now, and it takes the content with it.
568
- delete _pendingContent[key];
569
- updates.content = content;
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
- _updateTab(p.paneId, p.path, { content: p.content });
590
- if (p.markdown) _syncMarkdownPreviewContent(p.path, p.content);
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(paneIdOrPath, pathOrViewState, maybeViewState) {
621
- var paneId = paneIdOrPath;
622
- var path = pathOrViewState;
623
- var viewState = maybeViewState;
624
-
625
- // Backward-compatible signature: saveTabViewState(path, viewState)
626
- if (typeof maybeViewState === 'undefined') {
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
- toggleJumpOrigin: toggleJumpOrigin,
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 = _getConsumer();
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
- if (!_consumer) {
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-text);
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); }