mbeditor 0.10.1 → 0.12.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +191 -0
  3. data/README.md +226 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +948 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +661 -140
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +68 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +273 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +481 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/js_globals_service.rb +31 -2
  37. data/app/services/mbeditor/js_program_service.rb +173 -0
  38. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  39. data/app/services/mbeditor/model_graph_service.rb +232 -0
  40. data/app/services/mbeditor/presence_registry.rb +83 -0
  41. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  42. data/app/services/mbeditor/search_replace_service.rb +24 -4
  43. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  44. data/lib/mbeditor/configuration.rb +43 -3
  45. data/lib/mbeditor/engine.rb +34 -0
  46. data/lib/mbeditor/exception_log.rb +84 -0
  47. data/lib/mbeditor/route_map.rb +6 -0
  48. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  49. data/lib/mbeditor/version.rb +1 -1
  50. data/lib/mbeditor.rb +1 -0
  51. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  52. metadata +16 -2
@@ -11,6 +11,27 @@ var useState = _React.useState;
11
11
  var useEffect = _React.useEffect;
12
12
  var useRef = _React.useRef;
13
13
 
14
+ // Functional setTreeData updater shared by every path that re-fetches the tree
15
+ // (WebSocket push, the 10s poll, the manual refresh button).
16
+ //
17
+ // Returning prevData when nothing changed is the whole point: the fetched array
18
+ // is always a fresh object, so returning it unconditionally makes React commit
19
+ // on every tick and defeats FileTreeMemo's `prev.items === next.items` check —
20
+ // a full app re-render every 10 seconds, forever, with the tree untouched.
21
+ //
22
+ // The comparison is a deep one. An earlier version hashed only the top-level
23
+ // entry names, which missed every file added or removed inside a directory:
24
+ // the re-render happened anyway, and the quick-open index was never rebuilt.
25
+ // JSON.stringify over the whole tree measures 0.33 ms for ~1600 nodes, well
26
+ // under the render it saves.
27
+ function _treeUpdater(newData) {
28
+ return function (prevData) {
29
+ if (JSON.stringify(newData) === JSON.stringify(prevData)) return prevData;
30
+ SearchService.buildIndex(newData);
31
+ return newData;
32
+ };
33
+ }
34
+
14
35
  var SIDEBAR_MIN_WIDTH = 280;
15
36
  var SIDEBAR_MAX_WIDTH = 560;
16
37
  var EDITOR_MIN_WIDTH = 320;
@@ -356,6 +377,11 @@ var MbeditorApp = function MbeditorApp() {
356
377
  var schemaModal = _useStateSchemaModal2[0];
357
378
  var setSchemaModal = _useStateSchemaModal2[1];
358
379
 
380
+ var _useStateImportConflict = useState(null);
381
+ var _useStateImportConflict2 = _slicedToArray(_useStateImportConflict, 2);
382
+ var importConflict = _useStateImportConflict2[0];
383
+ var setImportConflict = _useStateImportConflict2[1];
384
+
359
385
  var _useStateSchemaLoading = useState(null);
360
386
  var _useStateSchemaLoading2 = _slicedToArray(_useStateSchemaLoading, 2);
361
387
  var schemaLoadingLabel = _useStateSchemaLoading2[0];
@@ -451,6 +477,54 @@ var MbeditorApp = function MbeditorApp() {
451
477
  var problemCounts = _useStateProblemCounts2[0];
452
478
  var setProblemCounts = _useStateProblemCounts2[1];
453
479
 
480
+ // Below this the toolbar's labelled buttons no longer fit beside the title
481
+ // and the file search, and start pushing each other out of the bar.
482
+ var TOOLBAR_LABEL_MIN_WIDTH = 1180;
483
+
484
+ // matchMedia rather than a resize listener: the browser only tells us when
485
+ // the answer actually changes, so there is nothing to throttle.
486
+ var _useStateNarrow = useState(function () {
487
+ return typeof window.matchMedia === 'function' &&
488
+ window.matchMedia('(max-width: ' + TOOLBAR_LABEL_MIN_WIDTH + 'px)').matches;
489
+ });
490
+ var _useStateNarrow2 = _slicedToArray(_useStateNarrow, 2);
491
+ var narrowToolbar = _useStateNarrow2[0];
492
+ var setNarrowToolbar = _useStateNarrow2[1];
493
+
494
+ useEffect(function () {
495
+ if (typeof window.matchMedia !== 'function') return;
496
+ var mq = window.matchMedia('(max-width: ' + TOOLBAR_LABEL_MIN_WIDTH + 'px)');
497
+ var onChange = function (e) { setNarrowToolbar(e.matches); };
498
+ setNarrowToolbar(mq.matches);
499
+ // addEventListener on MediaQueryList is the modern spelling; addListener
500
+ // is kept for older Safari, which mbeditor still runs in.
501
+ if (mq.addEventListener) mq.addEventListener('change', onChange);
502
+ else mq.addListener(onChange);
503
+ return function () {
504
+ if (mq.removeEventListener) mq.removeEventListener('change', onChange);
505
+ else mq.removeListener(onChange);
506
+ };
507
+ }, []);
508
+
509
+ // Model graph. Built lazily — generating it eager-loads the host app — and
510
+ // only when the Models tab is opened.
511
+ var _useStateModelGraph = useState(null);
512
+ var _useStateModelGraph2 = _slicedToArray(_useStateModelGraph, 2);
513
+ var modelGraph = _useStateModelGraph2[0];
514
+ var setModelGraph = _useStateModelGraph2[1];
515
+
516
+ var _useStateModelGraphLoading = useState(false);
517
+ var _useStateModelGraphLoading2 = _slicedToArray(_useStateModelGraphLoading, 2);
518
+ var modelGraphLoading = _useStateModelGraphLoading2[0];
519
+ var setModelGraphLoading = _useStateModelGraphLoading2[1];
520
+
521
+ // ruby-lsp status for the status-bar chip. 'off' means never available here,
522
+ // 'degraded' means we backed off after a failure, 'ok' means it's answering.
523
+ var _useStateLspHealth = useState({ status: 'off', reason: null });
524
+ var _useStateLspHealth2 = _slicedToArray(_useStateLspHealth, 2);
525
+ var lspHealth = _useStateLspHealth2[0];
526
+ var setLspHealth = _useStateLspHealth2[1];
527
+
454
528
  var _useState18g = useState(320);
455
529
  var _useState18g2 = _slicedToArray(_useState18g, 2);
456
530
  var gitPanelWidth = _useState18g2[0];
@@ -541,6 +615,10 @@ var MbeditorApp = function MbeditorApp() {
541
615
  var editorPrefs = _useState18p2[0];
542
616
  var setEditorPrefs = _useState18p2[1];
543
617
 
618
+ // Icon-only toolbar: on by preference, or automatically once the window is
619
+ // too narrow for the labels to fit beside the title and file search.
620
+ var toolbarIconOnly = editorPrefs.toolbarIconOnly || narrowToolbar;
621
+
544
622
  var _useState19 = useState({
545
623
  openEditors: false,
546
624
  projects: false
@@ -597,8 +675,48 @@ var MbeditorApp = function MbeditorApp() {
597
675
  var setCustomPaths = _useStateCP2[1];
598
676
  var customPathsRef = useRef([]);
599
677
  customPathsRef.current = customPaths;
678
+ // Whether to show the presence chips at all. Read at render rather than held in
679
+ // state: cable availability changes on handshake and on every reconnect, so a
680
+ // stored copy is stale the moment it is written. This only decides whether a
681
+ // chip paints — the protocol itself is gated on the roster.
682
+ var collabEnabled = typeof WebSocketService !== 'undefined' &&
683
+ typeof WebSocketService.isCableAvailable === 'function' &&
684
+ WebSocketService.isCableAvailable();
685
+ var _useStateIdent = useState(
686
+ typeof CollaborationIdentity !== 'undefined' ? CollaborationIdentity.get() : null
687
+ );
688
+ var _useStateIdent2 = _slicedToArray(_useStateIdent, 2);
689
+ var collabIdentity = _useStateIdent2[0];
690
+ var setCollabIdentity = _useStateIdent2[1];
691
+ // Presence roster: other connected participants, keyed by client_id →
692
+ // { name, colour, current_file }. Fed by the global presence stream; rendered
693
+ // as click-to-jump chips in the status bar.
694
+ var _useStateRoster = useState({});
695
+ var _useStateRoster2 = _slicedToArray(_useStateRoster, 2);
696
+ var collabRoster = _useStateRoster2[0];
697
+ var setCollabRoster = _useStateRoster2[1];
698
+
699
+ // Follow mode (slice 8): the presence client_id of the participant whose file +
700
+ // viewport we're tracking, or null when navigating independently. Toggled from a
701
+ // roster chip. The file-open is driven by the effect below; the scroll-tracking
702
+ // lives in CollaborationService.setFollow().
703
+ var _useStateFollow = useState(null);
704
+ var _useStateFollow2 = _slicedToArray(_useStateFollow, 2);
705
+ var followedClientId = _useStateFollow2[0];
706
+ var setFollowedClientId = _useStateFollow2[1];
600
707
  var recentSavesRef = useRef({});
601
708
  var isSavingRef = useRef(false);
709
+ // True once the saved session has finished loading into the panes. Anything
710
+ // that opens a tab on startup must wait for this, or the restore overwrites it.
711
+ var _useStateSR = useState(false);
712
+ var _useStateSR2 = _slicedToArray(_useStateSR, 2);
713
+ var sessionRestored = _useStateSR2[0];
714
+ var setSessionRestored = _useStateSR2[1];
715
+ var pendingChangelogRef = useRef(false);
716
+ // path -> the file's content as last seen ON DISK (newline-normalised).
717
+ // External-change detection compares disk-to-disk; comparing disk to the
718
+ // buffer flags every dirty tab, which is just the definition of "dirty".
719
+ var lastDiskContentRef = useRef({});
602
720
 
603
721
  // ── Draft backup helpers ─────────────────────────────────────────────────
604
722
  var draftWriteTimerRef = useRef({});
@@ -712,6 +830,112 @@ var MbeditorApp = function MbeditorApp() {
712
830
  return path && (path.endsWith('.rb') || path.endsWith('.gemspec') || path.endsWith('Rakefile') || path.endsWith('Gemfile'));
713
831
  };
714
832
 
833
+ // editor_plugins.js owns the ruby-lsp health flags; this app never writes
834
+ // them directly. Tolerates the plugins file not having loaded yet.
835
+ var noteLspFailure = function noteLspFailure(err) {
836
+ if (window.MbeditorEditorPlugins && MbeditorEditorPlugins.noteLspFailure) {
837
+ MbeditorEditorPlugins.noteLspFailure(err);
838
+ }
839
+ };
840
+
841
+ // Opens the schema modal for a model. Shared by the Rails panel's schema
842
+ // button and the model diagram, so both show the same thing.
843
+ var openSchemaModal = function openSchemaModal(label) {
844
+ if (schemaLoadingLabel === label) return;
845
+ setSchemaLoadingLabel(label);
846
+ FileService.getModelSchema(label.replace(/\s+/g, '')).then(function (data) {
847
+ setSchemaLoadingLabel(null);
848
+ setSchemaModal(data && data.columns
849
+ ? { label: label, data: data }
850
+ : { label: label, error: 'No schema found for ' + label });
851
+ })["catch"](function (err) {
852
+ setSchemaLoadingLabel(null);
853
+ var msg = (err && err.response && err.response.data && err.response.data.error) ||
854
+ 'No db/schema.rb found or table not defined';
855
+ setSchemaModal({ label: label, error: msg });
856
+ });
857
+ };
858
+
859
+ // The server caches on a fingerprint of app/models and db/migrate mtimes, so
860
+ // re-requesting on every tab visit is cheap and picks up a saved model or a
861
+ // new migration without any invalidation wiring here.
862
+ var loadModelGraph = function loadModelGraph(force) {
863
+ if (!FileService.getModelGraph) return;
864
+ setModelGraphLoading(true);
865
+ FileService.getModelGraph(force).then(function (data) {
866
+ setModelGraph(data);
867
+ })["catch"](function (err) {
868
+ setModelGraph({
869
+ ok: false,
870
+ error: (err && err.response && err.response.data && err.response.data.error) ||
871
+ 'Could not load the model graph.'
872
+ });
873
+ })["finally"](function () { setModelGraphLoading(false); });
874
+ };
875
+
876
+ useEffect(function () {
877
+ if (activeSidebarTab !== 'models' || sidebarCollapsed) return;
878
+ loadModelGraph(false);
879
+ }, [activeSidebarTab, sidebarCollapsed]);
880
+
881
+ var readLspHealth = function readLspHealth() {
882
+ if (!window.MBEDITOR_RUBY_LSP_AVAILABLE) {
883
+ return { status: 'off', reason: window.MBEDITOR_RUBY_LSP_REASON || null };
884
+ }
885
+ if (window.MbeditorEditorPlugins && MbeditorEditorPlugins.lspBackedOff()) {
886
+ return { status: 'degraded', reason: window.MBEDITOR_RUBY_LSP_REASON || null };
887
+ }
888
+ return { status: 'ok', reason: null };
889
+ };
890
+
891
+ // The backoff expires on a wall-clock deadline rather than a timer, so the
892
+ // chip also re-reads on a slow interval — otherwise it would sit on
893
+ // 'degraded' until the next failure or restart click.
894
+ //
895
+ // readLspHealth() builds a fresh object every call, so handing it straight to
896
+ // setLspHealth re-rendered the whole app every 10 seconds whether or not the
897
+ // health had changed — React bails on Object.is, and two object literals are
898
+ // never identical. Compare the fields and keep the previous object when they
899
+ // match. (Same shape of bug as the file-tree poll; see _treeUpdater.)
900
+ useEffect(function () {
901
+ var sync = function () {
902
+ setLspHealth(function (prev) {
903
+ var next = readLspHealth();
904
+ if (prev && prev.status === next.status && prev.reason === next.reason) return prev;
905
+ return next;
906
+ });
907
+ };
908
+ sync();
909
+ window.addEventListener('mbeditor:lsp-health', sync);
910
+ var tick = setInterval(sync, 10000);
911
+ return function () {
912
+ window.removeEventListener('mbeditor:lsp-health', sync);
913
+ clearInterval(tick);
914
+ };
915
+ }, []);
916
+
917
+ var restartRubyLsp = function restartRubyLsp() {
918
+ if (!FileService.rubyLspRequest) return;
919
+ EditorStore.setStatus('Restarting ruby-lsp…', 'info');
920
+ FileService.rubyLspRequest('restart', '', '', 1, 1).then(function (data) {
921
+ var ok = data && data.available && data.state !== 'failed';
922
+ if (ok) {
923
+ window.MBEDITOR_RUBY_LSP_AVAILABLE = true;
924
+ window.MBEDITOR_RUBY_LSP_DISABLED_UNTIL = 0;
925
+ window.MBEDITOR_RUBY_LSP_REASON = null;
926
+ } else {
927
+ window.MBEDITOR_RUBY_LSP_REASON =
928
+ (data && (data.reason || data.error)) || 'ruby-lsp did not come back';
929
+ }
930
+ EditorStore.setStatus(ok ? 'ruby-lsp restarted' : 'ruby-lsp unavailable', ok ? 'success' : 'warning');
931
+ setLspHealth(readLspHealth());
932
+ })["catch"](function (err) {
933
+ noteLspFailure(err);
934
+ EditorStore.setStatus('Could not restart ruby-lsp', 'error');
935
+ setLspHealth(readLspHealth());
936
+ });
937
+ };
938
+
715
939
  var applyMarkersForTab = function applyMarkersForTab(paneId, tabId, nextMarkers) {
716
940
  var currentPane = EditorStore.getState().panes.find(function (p) {
717
941
  return p.id === paneId;
@@ -744,14 +968,15 @@ var MbeditorApp = function MbeditorApp() {
744
968
  // Anything short of a usable answer falls through to the HTTP lint for
745
969
  // this call, so behaviour without ruby-lsp is unchanged.
746
970
  var lintRequest;
747
- if (window.MBEDITOR_RUBY_LSP_AVAILABLE && isRubyPath(tab.path) && FileService.lspDiagnostics) {
971
+ var lspUsable = window.MBEDITOR_RUBY_LSP_AVAILABLE &&
972
+ !(window.MbeditorEditorPlugins && MbeditorEditorPlugins.lspBackedOff());
973
+ if (lspUsable && isRubyPath(tab.path) && FileService.lspDiagnostics) {
748
974
  lintRequest = FileService.lspDiagnostics(tab.path, tab.content).then(function (res) {
749
975
  if (res && res.markers && !res.fallback && !res.error) return res;
976
+ noteLspFailure({ lspData: res || {} });
750
977
  return FileService.lintFile(tab.path, tab.content);
751
978
  })["catch"](function (err) {
752
- if (err && err.response && err.response.status === 422) {
753
- window.MBEDITOR_RUBY_LSP_AVAILABLE = false;
754
- }
979
+ noteLspFailure(err);
755
980
  return FileService.lintFile(tab.path, tab.content);
756
981
  });
757
982
  } else {
@@ -935,6 +1160,9 @@ var MbeditorApp = function MbeditorApp() {
935
1160
  if (t.isCombinedDiff || (t.path || '').startsWith('combined-diff://') || (t.path || '').startsWith('diff://')) {
936
1161
  return Promise.resolve({ content: '' });
937
1162
  }
1163
+ if (t.isModelGraph || t.path === 'mbeditor://model-graph') {
1164
+ return Promise.resolve({ content: '' });
1165
+ }
938
1166
  var sourcePath = t.isPreview || /::preview$/.test(t.path || '') ? t.previewFor || (t.path || '').replace(/::preview$/, '') : t.path;
939
1167
  return FileService.getFile(sourcePath, { allowMissing: true }).then(function (data) {
940
1168
  return {
@@ -958,7 +1186,7 @@ var MbeditorApp = function MbeditorApp() {
958
1186
  p.tabs.forEach(function (t) {
959
1187
  var res = results[resIdx++];
960
1188
  var isPlainFile = t.path && !t.isDiff && !t.isCombinedDiff && !t.isSettings &&
961
- !t.isChangelog && !t.isPreview &&
1189
+ !t.isChangelog && !t.isPreview && !t.isModelGraph &&
962
1190
  !/^(diff|combined-diff):\/\//.test(t.path) && !/::preview$/.test(t.path);
963
1191
  if (isPlainFile) {
964
1192
  if (seenPaths[t.path]) return;
@@ -967,7 +1195,11 @@ var MbeditorApp = function MbeditorApp() {
967
1195
  tabs.push(_extends({}, t, {
968
1196
  content: res.content,
969
1197
  externalContentVersion: (t.externalContentVersion || 0) + 1
970
- }, res._isDiffResult ? { diffOriginal: res.diffOriginal, diffModified: res.diffModified } : {},
1198
+ },
1199
+ // A state saved before this tab type existed carries the path but
1200
+ // not the flag, and would restore as a missing file.
1201
+ t.path === 'mbeditor://model-graph' ? { isModelGraph: true } : {},
1202
+ res._isDiffResult ? { diffOriginal: res.diffOriginal, diffModified: res.diffModified } : {},
971
1203
  typeof res.fileNotFound === 'boolean' ? { fileNotFound: res.fileNotFound, dirty: res.fileNotFound ? false : t.dirty } : {},
972
1204
  res.image === true ? { isImage: true } : {}));
973
1205
  });
@@ -1041,7 +1273,8 @@ var MbeditorApp = function MbeditorApp() {
1041
1273
  }
1042
1274
  return loadPaneState(panesToLoad, focusedPaneId);
1043
1275
  });
1044
- });
1276
+ })["catch"](function () { /* fall through to marking restore done */ })
1277
+ .then(function () { setSessionRestored(true); });
1045
1278
 
1046
1279
  // Watch for git branch changes and swap per-branch tab state
1047
1280
  var unsubBranch = EditorStore.subscribeToSlice(['gitBranch'], function (st) {
@@ -1077,7 +1310,7 @@ var MbeditorApp = function MbeditorApp() {
1077
1310
  return {
1078
1311
  id: p.id,
1079
1312
  activeTabId: p.activeTabId,
1080
- tabs: p.tabs.filter(function (t) { return !t.isCombinedDiff; }).map(function (t) {
1313
+ tabs: p.tabs.filter(function (t) { return !t.isCombinedDiff && !t.isModelGraph; }).map(function (t) {
1081
1314
  return {
1082
1315
  id: t.id, path: t.path, name: t.name, dirty: t.dirty, viewState: t.viewState,
1083
1316
  isSettings: !!t.isSettings, isPreview: !!t.isPreview, previewFor: t.previewFor || null,
@@ -1415,12 +1648,7 @@ var MbeditorApp = function MbeditorApp() {
1415
1648
  if (document.hidden) return;
1416
1649
  GitService.fetchStatus()["catch"](function () {});
1417
1650
  FileService.getTree().then(function (data) {
1418
- var newData = data || [];
1419
- setTreeData(function (prevData) {
1420
- var sig = function(d) { return d.length + ':' + d.map(function(n) { return n.name; }).join(','); };
1421
- if (sig(newData) !== sig(prevData)) SearchService.buildIndex(newData);
1422
- return newData;
1423
- });
1651
+ setTreeData(_treeUpdater(data || []));
1424
1652
  checkOpenTabsForExternalChanges();
1425
1653
  })["catch"](function () {});
1426
1654
  if (payload && payload.paths && searchQueryRef.current && searchPanelVisibleRef.current) {
@@ -1432,6 +1660,41 @@ var MbeditorApp = function MbeditorApp() {
1432
1660
  return function () { WebSocketService.offFilesChanged(handleFilesChanged); };
1433
1661
  }, []);
1434
1662
 
1663
+ // WebSocket push — when a peer saves a collaboratively-bound file, the CRDT has
1664
+ // already kept our buffer byte-identical, so the single on-disk write is enough
1665
+ // for everyone. Reset that tab's clean baseline and clear its dirty indicator
1666
+ // without touching disk or undo history. Gated on the file being collab-bound:
1667
+ // for non-collab tabs a peer's save is an external change handled by the
1668
+ // files_changed path above (which respects local unsaved edits).
1669
+ useEffect(function () {
1670
+ function handleFileSaved(data) {
1671
+ var path = data && data.path;
1672
+ if (!path) return;
1673
+ if (typeof CollaborationService === 'undefined' || !CollaborationService.isBound(path)) return;
1674
+
1675
+ var st = EditorStore.getState();
1676
+ var changed = false;
1677
+ var newPanes = st.panes.map(function (p) {
1678
+ return Object.assign({}, p, {
1679
+ tabs: p.tabs.map(function (t) {
1680
+ if (t.path !== path || !t.dirty) return t;
1681
+ changed = true;
1682
+ return Object.assign({}, t, { dirty: false, cleanContent: t.content });
1683
+ })
1684
+ });
1685
+ });
1686
+ if (changed) EditorStore.setState({ panes: newPanes });
1687
+
1688
+ // Reset the AVI clean baseline so undo past this peer's save shows dirty correctly.
1689
+ var _modelEntry = window.__mbeditorModels && window.__mbeditorModels[path];
1690
+ if (_modelEntry && _modelEntry.model && !_modelEntry.model.isDisposed()) {
1691
+ _modelEntry.cleanVersionId = _modelEntry.model.getAlternativeVersionId();
1692
+ }
1693
+ }
1694
+ WebSocketService.onFileSaved(handleFileSaved);
1695
+ return function () { WebSocketService.offFileSaved(handleFileSaved); };
1696
+ }, []);
1697
+
1435
1698
  function checkOpenTabsForExternalChanges() {
1436
1699
  var st = EditorStore.getState();
1437
1700
  var allTabs = st.panes.reduce(function (acc, p) {
@@ -1452,11 +1715,35 @@ var MbeditorApp = function MbeditorApp() {
1452
1715
  fileTabs.forEach(function (pt) {
1453
1716
  var savedAt = recentSavesRef.current[pt.tab.path];
1454
1717
  if (savedAt && Date.now() - savedAt < 3000) return;
1718
+ // A collaboratively-bound file's live buffer is the shared CRDT, kept
1719
+ // converged across peers and reconciled with disk on save (file_saved).
1720
+ // Re-applying an external on-disk snapshot over it would silently clobber
1721
+ // everyone's shared state, so skip detection here — the CRDT is authoritative
1722
+ // while peers are editing. Intentional local edits (Format/Load) still flow
1723
+ // through the binding and are unaffected.
1724
+ if (typeof CollaborationService !== 'undefined' &&
1725
+ CollaborationService.isAttached(pt.tab.path)) {
1726
+ return;
1727
+ }
1455
1728
  FileService.getFile(pt.tab.path, { allowMissing: true }).then(function (data) {
1456
1729
  if (!data || typeof data.content !== 'string') return;
1457
1730
  var serverNorm = data.content.replace(/\r\n/g, '\n');
1458
1731
  var tabNorm = (pt.tab.content || '').replace(/\r\n/g, '\n');
1732
+
1733
+ // Did the file on disk actually change? Compare disk against the last
1734
+ // disk content we saw, never against the buffer — a dirty buffer
1735
+ // differs from disk by definition, so the old comparison reported
1736
+ // every unsaved tab as "updated externally" whenever a files_changed
1737
+ // push arrived (which our own save of some *other* file triggers).
1738
+ // A clean tab's buffer IS the disk content, so it seeds the baseline;
1739
+ // a dirty tab with no baseline yet can't be judged, so record and wait.
1740
+ var prevDisk = lastDiskContentRef.current[pt.tab.path];
1741
+ lastDiskContentRef.current[pt.tab.path] = serverNorm;
1459
1742
  if (serverNorm === tabNorm) return;
1743
+ if (prevDisk === undefined && pt.tab.dirty) return;
1744
+ if (prevDisk === undefined) prevDisk = tabNorm;
1745
+ if (serverNorm === prevDisk) return;
1746
+
1460
1747
  if (!pt.tab.dirty) {
1461
1748
  EditorStore.setState({
1462
1749
  panes: EditorStore.getState().panes.map(function (p) {
@@ -1506,17 +1793,13 @@ var MbeditorApp = function MbeditorApp() {
1506
1793
  // broadcasts from mbeditor's own mutation endpoints, so a connected socket
1507
1794
  // meant external changes were never picked up at all. The push remains the
1508
1795
  // instant path for our own writes; this is what catches everything else.
1509
- // Uses functional setTreeData to skip the re-render when nothing has changed.
1796
+ // _treeUpdater keeps the previous array when nothing changed, so a quiet
1797
+ // workspace costs one fetch and no re-render at all.
1510
1798
  useEffect(function () {
1511
1799
  var intervalId = setInterval(function () {
1512
1800
  if (document.hidden) return;
1513
1801
  FileService.getTree().then(function (data) {
1514
- var newData = data || [];
1515
- setTreeData(function (prevData) {
1516
- var sig = function(d) { return d.length + ':' + d.map(function(n) { return n.name; }).join(','); };
1517
- if (sig(newData) !== sig(prevData)) SearchService.buildIndex(newData);
1518
- return newData;
1519
- });
1802
+ setTreeData(_treeUpdater(data || []));
1520
1803
  }).catch(function () {}); // silently ignore auto-refresh errors
1521
1804
  }, 10000);
1522
1805
  return function () { clearInterval(intervalId); };
@@ -1778,7 +2061,7 @@ var MbeditorApp = function MbeditorApp() {
1778
2061
  return {
1779
2062
  id: p.id,
1780
2063
  activeTabId: p.activeTabId,
1781
- tabs: p.tabs.filter(function(t) { return !t.isCombinedDiff; }).map(function (t) {
2064
+ tabs: p.tabs.filter(function(t) { return !t.isCombinedDiff && !t.isModelGraph; }).map(function (t) {
1782
2065
  return {
1783
2066
  id: t.id,
1784
2067
  path: t.path,
@@ -1830,22 +2113,50 @@ var MbeditorApp = function MbeditorApp() {
1830
2113
  useEffect(function() {
1831
2114
  FileService.getClientConfig().then(function(cfg) {
1832
2115
  setCustomPaths(Array.isArray(cfg.related_files_custom_paths) ? cfg.related_files_custom_paths : []);
2116
+ // Host-app override for the collaboration display name (user_name_callback).
2117
+ // Null/blank falls back to the browser-generated, user-editable name.
2118
+ if (typeof CollaborationIdentity !== 'undefined') {
2119
+ CollaborationIdentity.setServerName(cfg.user_name);
2120
+ }
1833
2121
  })['catch'](function() {});
1834
2122
  }, []);
1835
2123
 
2124
+ // Keep the presence chip in sync with name edits / host overrides.
2125
+ useEffect(function() {
2126
+ if (typeof CollaborationIdentity === 'undefined') return;
2127
+ setCollabIdentity(CollaborationIdentity.get());
2128
+ return CollaborationIdentity.onChange(function(id) { setCollabIdentity(id); });
2129
+ }, []);
2130
+
1836
2131
  // Version-update detection: open the changelog tab automatically when the
1837
2132
  // gem version has changed since last time the editor was opened.
2133
+ //
2134
+ // Gated on sessionRestored rather than a timer. Restoring the saved session
2135
+ // replaces every pane wholesale, so a changelog tab opened before that lands
2136
+ // is silently thrown away — which is exactly what happened whenever the
2137
+ // restore took longer than the old 800 ms guess (a big session, a slow or
2138
+ // remote host). Sequencing after the restore removes the race instead of
2139
+ // making the guess bigger.
2140
+ //
2141
+ // The seen-version write is deliberately NOT gated: it records that this
2142
+ // build has been seen, and re-showing the changelog on every reload until
2143
+ // the restore happens to succeed would be worse than missing it once.
1838
2144
  useEffect(function() {
1839
2145
  var SEEN_KEY = 'mbeditor_seen_version';
1840
2146
  var current = document.body.dataset.mbeditorVersion || '';
2147
+ if (!current) return;
1841
2148
  var seen = localStorage.getItem(SEEN_KEY) || '';
1842
- if (current && seen && seen !== current) {
1843
- // Delay slightly so the editor finishes restoring saved tabs first
1844
- setTimeout(function() { openChangelogTab(); }, 800);
1845
- }
1846
- if (current) localStorage.setItem(SEEN_KEY, current);
2149
+ localStorage.setItem(SEEN_KEY, current);
2150
+ if (!seen || seen === current) return;
2151
+ pendingChangelogRef.current = true;
1847
2152
  }, []);
1848
2153
 
2154
+ useEffect(function() {
2155
+ if (!sessionRestored || !pendingChangelogRef.current) return;
2156
+ pendingChangelogRef.current = false;
2157
+ openChangelogTab();
2158
+ }, [sessionRestored]);
2159
+
1849
2160
  var resourceLabelFromPath = function(p) {
1850
2161
  if (!p) return null;
1851
2162
 
@@ -1976,6 +2287,239 @@ var MbeditorApp = function MbeditorApp() {
1976
2287
  return t.id === focusedPane.activeTabId;
1977
2288
  });
1978
2289
 
2290
+ // ── Collaboration presence (slice 7) ──────────────────────────────────────
2291
+ // Only real, openable files belong in presence — virtual tabs (diffs, previews,
2292
+ // settings/changelog) are reported as "no file" so a peer's chip stays blank
2293
+ // rather than pointing at something click-to-jump can't open.
2294
+ var _presenceFileFor = function (tab) {
2295
+ if (!tab || !tab.path) return null;
2296
+ var p = tab.path;
2297
+ if (tab.isDiff || tab.isCombinedDiff || tab.isCommitGraph || tab.isPreview || tab.isSettings || tab.isChangelog) return null;
2298
+ if (p.indexOf('diff://') === 0 || p.indexOf('combined-diff://') === 0 || p.indexOf('mbeditor://') === 0) return null;
2299
+ if (p.indexOf('::preview') !== -1 || p === '__settings__') return null;
2300
+ return p;
2301
+ };
2302
+ var presenceFile = _presenceFileFor(activeTab);
2303
+
2304
+ // Latest heartbeat payload, read by the throttled sender and the late-join
2305
+ // re-announce so both always relay our current identity + file.
2306
+ // Round-trip time to the cable, in ms. Our heartbeat comes back on the same
2307
+ // stream, so timing it needs no clock comparison and no extra ping traffic. We
2308
+ // publish the result in the next heartbeat; a peer's hover card therefore shows
2309
+ // *their* server RTT, which is the number that explains why their edits lag.
2310
+ //
2311
+ // Matched by sequence number, not just "our entry appeared". Every participant's
2312
+ // heartbeat rebroadcasts the whole roster, so our entry comes back on other
2313
+ // people's beats too — timing against those measured the gap since our last send
2314
+ // instead of the round trip, and read as seconds.
2315
+ var presenceSentAtRef = useRef(0);
2316
+ var presenceSeqRef = useRef(0);
2317
+ var measuredSeqRef = useRef(-1);
2318
+ var ownRttRef = useRef(null);
2319
+ // Peer RTT + local arrival time, kept in a ref rather than roster state on
2320
+ // purpose: both change on every heartbeat, and folding them into the compared
2321
+ // roster fields would reinstate the 5s idle re-render this branch just removed.
2322
+ // The hover card reads them when it opens instead, and ticks only while open.
2323
+ var peerStatsRef = useRef({});
2324
+
2325
+ var presencePayloadRef = useRef(null);
2326
+ presencePayloadRef.current = collabIdentity ? {
2327
+ client_id: collabIdentity.clientId,
2328
+ name: collabIdentity.name,
2329
+ colour: collabIdentity.color,
2330
+ current_file: presenceFile,
2331
+ rtt: ownRttRef.current,
2332
+ seed: collabIdentity.seed
2333
+ } : null;
2334
+
2335
+ var _sendPresenceNow = function () {
2336
+ if (!presencePayloadRef.current) return;
2337
+ presenceSentAtRef.current = Date.now();
2338
+ presenceSeqRef.current += 1;
2339
+ WebSocketService.perform(
2340
+ 'presence',
2341
+ Object.assign({}, presencePayloadRef.current, { seq: presenceSeqRef.current })
2342
+ );
2343
+ };
2344
+ // Throttle heartbeats (presence is coarse — not cursor-level), trailing edge so
2345
+ // the final file/identity always lands.
2346
+ var sendPresenceRef = useRef(null);
2347
+ if (!sendPresenceRef.current) {
2348
+ sendPresenceRef.current = (window._ && window._.throttle)
2349
+ ? window._.throttle(_sendPresenceNow, 1000, { leading: true, trailing: true })
2350
+ : _sendPresenceNow;
2351
+ }
2352
+
2353
+ // Heartbeat: announce ourselves when the active file or our identity changes,
2354
+ // plus a keepalive that refreshes peers who joined in between.
2355
+ //
2356
+ // Deliberately NOT gated on cable availability. Whether cable is up is not
2357
+ // knowable at any single moment worth latching: the handshake completes after
2358
+ // the /workspace fetch that first reads it, and reconnects flip it again. Any
2359
+ // boolean captured for this decision goes stale and silently strands the page in
2360
+ // single-user mode. WebSocketService.perform() already no-ops while
2361
+ // disconnected, so an ungated heartbeat costs one dead call every 5s and starts
2362
+ // working the instant the socket does.
2363
+ useEffect(function () {
2364
+ sendPresenceRef.current();
2365
+ var id = setInterval(function () { sendPresenceRef.current(); }, 5000);
2366
+ return function () { clearInterval(id); };
2367
+ }, [presenceFile, collabIdentity ? collabIdentity.clientId : null,
2368
+ collabIdentity ? collabIdentity.name : null, collabIdentity ? collabIdentity.color : null]);
2369
+
2370
+ // Roster sync. The server sends the complete roster on every change and we
2371
+ // replace ours with it, rather than merging per-participant here/leave events.
2372
+ // Merging could not self-correct: one missed leave left a peer in the roster
2373
+ // permanently, and since the roster gates collaboration, that one phantom kept
2374
+ // persistent undo off and external-change detection suppressed for the whole
2375
+ // session. A dropped message now costs one stale interval instead.
2376
+ // Subscribed for the life of the app, for the same reason the heartbeat is:
2377
+ // no message arrives without a cable, so there is nothing to gate, and gating it
2378
+ // on a latched boolean is what left presence unsubscribed when the handshake
2379
+ // landed after startup.
2380
+ useEffect(function () {
2381
+ var handler = function (data) {
2382
+ var roster = data && data.roster;
2383
+ if (!roster) return;
2384
+ var me = (typeof CollaborationIdentity !== 'undefined') ? CollaborationIdentity.get().clientId : null;
2385
+
2386
+ // The first broadcast carrying our newest seq is the one our own heartbeat
2387
+ // caused — the server records then broadcasts in the same call. Later
2388
+ // broadcasts repeat that seq, hence measuring once per sequence number.
2389
+ var mineEcho = me && roster[me];
2390
+ if (mineEcho && presenceSentAtRef.current &&
2391
+ mineEcho.seq === presenceSeqRef.current &&
2392
+ measuredSeqRef.current !== presenceSeqRef.current) {
2393
+ measuredSeqRef.current = presenceSeqRef.current;
2394
+ ownRttRef.current = Date.now() - presenceSentAtRef.current;
2395
+ }
2396
+
2397
+ // rtt and idle change every broadcast, so they stay out of the compared
2398
+ // state entirely — folding them in would re-render the app every 5s to keep
2399
+ // a hover card fresh that nobody is looking at. The card reads this ref.
2400
+ var next = {};
2401
+ var stats = {};
2402
+ Object.keys(roster).forEach(function (cid) {
2403
+ if (cid === me) return;
2404
+ var p = roster[cid];
2405
+ // Validated once, here, rather than at each of the places that paints it.
2406
+ next[cid] = {
2407
+ name: p.name,
2408
+ colour: CollaborationIdentity.safeColor(p.colour),
2409
+ current_file: p.current_file,
2410
+ seed: p.seed
2411
+ };
2412
+ stats[cid] = { rtt: p.rtt, idle: p.idle };
2413
+ });
2414
+ peerStatsRef.current = stats;
2415
+
2416
+ // Re-evaluated on every roster message rather than only when the peer count
2417
+ // transitions, so availability recovers on its own after a reconnect. The
2418
+ // service compares the computed value, so a no-change call costs nothing.
2419
+ if (typeof CollaborationService !== 'undefined') {
2420
+ CollaborationService.setPeerPresent(Object.keys(next).length > 0);
2421
+ }
2422
+
2423
+ // Stop following someone who is no longer here.
2424
+ setFollowedClientId(function (cur) {
2425
+ if (cur && !next[cur]) {
2426
+ if (typeof CollaborationService !== 'undefined') CollaborationService.clearFollow();
2427
+ return null;
2428
+ }
2429
+ return cur;
2430
+ });
2431
+
2432
+ setCollabRoster(function (prev) {
2433
+ var prevIds = Object.keys(prev);
2434
+ var nextIds = Object.keys(next);
2435
+ var same = prevIds.length === nextIds.length && nextIds.every(function (cid) {
2436
+ var a = prev[cid], b = next[cid];
2437
+ return a && a.name === b.name && a.colour === b.colour &&
2438
+ a.current_file === b.current_file && a.seed === b.seed;
2439
+ });
2440
+ return same ? prev : next;
2441
+ });
2442
+ };
2443
+ WebSocketService.onPresence(handler);
2444
+ return function () { WebSocketService.offPresence(handler); };
2445
+ }, []);
2446
+
2447
+ // The roster is the only "is anyone actually pairing with me?" signal, so it is
2448
+ // what gates collaboration. Cable availability alone is not enough: it is up in
2449
+ // a normal dev setup, and gating on it silently disabled persistent undo and
2450
+ // external-change detection for solo users. The service is told about the roster
2451
+ // from the presence handler above, not from an effect here, so it hears about
2452
+ // every message rather than only about a change in the participant count.
2453
+ var collabPeerIds = Object.keys(collabRoster);
2454
+
2455
+ // A labelled peer chip costs ~110px (name + filename), and the titlebar button
2456
+ // cluster does not shrink or wrap: past three peers it squeezes the search pill
2457
+ // to its floor and then pushes Help / Install off the right edge. Drop to bare
2458
+ // colour dots instead of hiding peers behind a "+N more" summary — a dot is
2459
+ // ~20px, so ten peers still fit, every chip stays clickable to follow, and the
2460
+ // solid/hollow ring keeps working. The name and file live in the tooltip.
2461
+ var COLLAB_LABEL_LIMIT = 3;
2462
+ var collabPeerLabels = !toolbarIconOnly && collabPeerIds.length <= COLLAB_LABEL_LIMIT;
2463
+
2464
+ // Colour is minted from a hash before any peer is known, so it has to be
2465
+ // reconciled against the roster once one exists. Runs on every roster change;
2466
+ // reconcileColor no-ops unless we actually clash and lose the tie-break, so the
2467
+ // usual case costs one array map and no state write.
2468
+ useEffect(function () {
2469
+ if (typeof CollaborationIdentity === 'undefined') return;
2470
+ CollaborationIdentity.reconcileColor(collabPeerIds.map(function (cid) {
2471
+ return { clientId: cid, color: collabRoster[cid].colour, seed: collabRoster[cid].seed };
2472
+ }));
2473
+ }, [collabRoster]);
2474
+
2475
+ // Hover card. Anchored from the chip's own rect, right-aligned because these
2476
+ // chips sit against the right edge of the titlebar and a left-anchored card
2477
+ // would run off screen.
2478
+ var _useStateHover = useState(null);
2479
+ var _useStateHover2 = _slicedToArray(_useStateHover, 2);
2480
+ var collabHover = _useStateHover2[0];
2481
+ var setCollabHover = _useStateHover2[1];
2482
+
2483
+ var openCollabHover = function (cid, e) {
2484
+ var r = e.currentTarget.getBoundingClientRect();
2485
+ setCollabHover({ cid: cid, top: r.bottom + 4, right: window.innerWidth - r.right });
2486
+ };
2487
+
2488
+ // Latency and last-seen only need to tick while the card is actually on screen,
2489
+ // so the interval lives and dies with it. Idle cost stays zero.
2490
+ var _useStateHoverTick = useState(0);
2491
+ var _useStateHoverTick2 = _slicedToArray(_useStateHoverTick, 2);
2492
+ var setCollabHoverTick = _useStateHoverTick2[1];
2493
+ useEffect(function () {
2494
+ if (!collabHover) return;
2495
+ var id = setInterval(function () { setCollabHoverTick(function (n) { return n + 1; }); }, 1000);
2496
+ return function () { clearInterval(id); };
2497
+ }, [collabHover]);
2498
+
2499
+ // Follow mode (slice 8): toggle tracking a roster participant. Following sets up
2500
+ // the viewport scroll-tracking in CollaborationService; the file-open is handled
2501
+ // by the effect below (it also re-fires when the followed peer switches files).
2502
+ var followedFile = (followedClientId && collabRoster[followedClientId])
2503
+ ? collabRoster[followedClientId].current_file : null;
2504
+ var toggleFollow = function (cid) {
2505
+ if (followedClientId === cid) {
2506
+ setFollowedClientId(null);
2507
+ if (typeof CollaborationService !== 'undefined') CollaborationService.clearFollow();
2508
+ } else {
2509
+ setFollowedClientId(cid);
2510
+ if (typeof CollaborationService !== 'undefined') CollaborationService.setFollow(cid);
2511
+ }
2512
+ };
2513
+
2514
+ // While following, open/focus whatever file the followed participant currently
2515
+ // has open, and re-open when they switch files. Viewport tracking within that
2516
+ // file is handled by CollaborationService once both peers share the room.
2517
+ useEffect(function () {
2518
+ if (!followedClientId || !followedFile) return;
2519
+ if (activeTab && activeTab.path === followedFile) return;
2520
+ handleSelectFile(followedFile, followedFile.split('/').pop());
2521
+ }, [followedClientId, followedFile]);
2522
+
1979
2523
  // Phase 7: Per-file last-commit info shown in the status bar
1980
2524
  var _useState31 = useState(null);
1981
2525
  var _useState32 = _slicedToArray(_useState31, 2);
@@ -2173,6 +2717,10 @@ var MbeditorApp = function MbeditorApp() {
2173
2717
  if (_modelEntry && _modelEntry.model && !_modelEntry.model.isDisposed()) {
2174
2718
  _modelEntry.cleanVersionId = _modelEntry.model.getAlternativeVersionId();
2175
2719
  }
2720
+ // Collab: push a fresh snapshot so the server compacts the buffered deltas.
2721
+ if (typeof CollaborationService !== 'undefined' && CollaborationService.isBound(tab.path)) {
2722
+ CollaborationService.pushSnapshot(tab.path);
2723
+ }
2176
2724
  EditorStore.setStatus("Saved", "success");
2177
2725
  _clearDraft(tab.path);
2178
2726
  if (typeof HistoryService !== 'undefined') {
@@ -2379,12 +2927,7 @@ var MbeditorApp = function MbeditorApp() {
2379
2927
  });
2380
2928
  GitService.fetchStatus()["catch"](function () {});
2381
2929
  FileService.getTree().then(function (data) {
2382
- var newData = data || [];
2383
- setTreeData(function (prevData) {
2384
- if (JSON.stringify(newData) === JSON.stringify(prevData)) return prevData;
2385
- SearchService.buildIndex(newData);
2386
- return newData;
2387
- });
2930
+ setTreeData(_treeUpdater(data || []));
2388
2931
  checkOpenTabsForExternalChanges();
2389
2932
  EditorStore.setStatus("Workspace refreshed", "success");
2390
2933
  })["catch"](function (err) {
@@ -2396,6 +2939,34 @@ var MbeditorApp = function MbeditorApp() {
2396
2939
  });
2397
2940
  };
2398
2941
 
2942
+ // The toolbar button and Monaco's own Format Document must not disagree
2943
+ // about what "formatted" means, so both go through ruby-lsp when it can
2944
+ // answer and fall back to /format when it can't — the same order the
2945
+ // formatting provider uses. Returns { content: } either way, since the
2946
+ // button also wants to diff the result and flash the changed lines.
2947
+ var formatRubySource = function formatRubySource(path, code) {
2948
+ var viaLsp = window.MBEDITOR_RUBY_LSP_AVAILABLE &&
2949
+ !(window.MbeditorEditorPlugins && MbeditorEditorPlugins.lspBackedOff()) &&
2950
+ isRubyPath(path);
2951
+ if (!viaLsp) return FileService.formatFile(path, code);
2952
+
2953
+ return FileService.rubyLspRequest('formatting', path, code, 1, 1, { timeout: 15000 })
2954
+ .then(function (data) {
2955
+ var edits = data && data.result;
2956
+ // ruby-lsp answers a whole-document replacement, or null when RuboCop's
2957
+ // autocorrect cannot converge — in which case /format's `rubocop -A`
2958
+ // pass still gets a turn.
2959
+ if (Array.isArray(edits) && edits.length === 1 && typeof edits[0].newText === 'string') {
2960
+ return { content: edits[0].newText };
2961
+ }
2962
+ if (data) noteLspFailure({ lspData: data });
2963
+ return FileService.formatFile(path, code);
2964
+ })["catch"](function (err) {
2965
+ noteLspFailure(err);
2966
+ return FileService.formatFile(path, code);
2967
+ });
2968
+ };
2969
+
2399
2970
  var handleFormat = function handleFormat() {
2400
2971
  if (!activeTab) return;
2401
2972
 
@@ -2417,7 +2988,7 @@ var MbeditorApp = function MbeditorApp() {
2417
2988
  var detectedWidth = detectIndentWidth(originalContent);
2418
2989
  if (detectedWidth > 0) codeToFormat = spacesToTabs(originalContent, detectedWidth);
2419
2990
  }
2420
- FileService.formatFile(activeTab.path, codeToFormat).then(function (res) {
2991
+ formatRubySource(activeTab.path, codeToFormat).then(function (res) {
2421
2992
  if (res.content) {
2422
2993
  // Update content and mark dirty — user decides when to save.
2423
2994
  // The executeEdits path in EditorPanel preserves the undo stack.
@@ -2873,6 +3444,98 @@ var MbeditorApp = function MbeditorApp() {
2873
3444
  });
2874
3445
  };
2875
3446
 
3447
+ var finishImport = function finishImport(result) {
3448
+ var imported = (result.imported || []).length;
3449
+ var skipped = (result.conflicts || []).length;
3450
+ var failed = (result.errors || []).length;
3451
+
3452
+ var parts = [imported + ' file' + (imported === 1 ? '' : 's') + ' imported'];
3453
+ if (skipped > 0) parts.push(skipped + ' skipped');
3454
+ if (failed > 0) parts.push(failed + ' failed');
3455
+
3456
+ var level = failed === 0 ? 'success' : (imported === 0 ? 'error' : 'warning');
3457
+ EditorStore.setStatus(parts.join(', ') + '.', level);
3458
+
3459
+ if (imported > 0) {
3460
+ refreshProjectTree().then(function() { GitService.fetchStatus(); });
3461
+ }
3462
+ };
3463
+
3464
+ // A file dropped anywhere that isn't a drop target makes the browser
3465
+ // navigate away and open it, which loses the editor and every unsaved
3466
+ // buffer with it. Swallow those at the window so a near-miss is a no-op
3467
+ // rather than a disaster. Real targets stopPropagation before this runs.
3468
+ useEffect(function () {
3469
+ var swallow = function (e) {
3470
+ var types = (e.dataTransfer && e.dataTransfer.types) || [];
3471
+ if (Array.prototype.indexOf.call(types, 'Files') === -1) return;
3472
+ e.preventDefault();
3473
+ // dragover is left alone beyond preventDefault: setting dropEffect here
3474
+ // would override the 'copy' cursor the tree sets on a valid folder.
3475
+ if (e.type === 'drop') e.dataTransfer.dropEffect = 'none';
3476
+ };
3477
+ window.addEventListener('dragover', swallow);
3478
+ window.addEventListener('drop', swallow);
3479
+ return function () {
3480
+ window.removeEventListener('dragover', swallow);
3481
+ window.removeEventListener('drop', swallow);
3482
+ };
3483
+ }, []);
3484
+
3485
+ // Files dragged in from outside the browser. Pass one reports conflicts
3486
+ // without touching them; if there are any, the modal collects a resolution
3487
+ // and pass two re-sends just those entries.
3488
+ var handleImportFiles = function handleImportFiles(entries, targetFolderPath, meta) {
3489
+ if (meta && meta.truncated) {
3490
+ EditorStore.setStatus('That drop holds more than ' + FileImport.MAX_ENTRIES +
3491
+ ' files — only the first ' + FileImport.MAX_ENTRIES + ' will be imported.', 'warning');
3492
+ } else if (meta && meta.foldersSkipped) {
3493
+ EditorStore.setStatus('This browser cannot read dropped folders — only loose files were imported.', 'warning');
3494
+ } else {
3495
+ EditorStore.setStatus('Importing ' + entries.length + ' file' +
3496
+ (entries.length === 1 ? '' : 's') + '...', 'info');
3497
+ }
3498
+
3499
+ return FileService.importFiles(FileImport.buildFormData(entries, targetFolderPath, 'ask'))
3500
+ .then(function(result) {
3501
+ if (result.conflicts && result.conflicts.length > 0) {
3502
+ setImportConflict({ result: result, entries: entries, targetFolderPath: targetFolderPath });
3503
+ } else {
3504
+ finishImport(result);
3505
+ }
3506
+ })['catch'](function(err) {
3507
+ var message = err && err.response && err.response.data && err.response.data.error || err.message;
3508
+ EditorStore.setStatus('Import failed: ' + message, 'error');
3509
+ });
3510
+ };
3511
+
3512
+ var resolveImportConflict = function resolveImportConflict(mode) {
3513
+ var pending = importConflict;
3514
+ setImportConflict(null);
3515
+ if (!pending) return;
3516
+
3517
+ if (mode === 'skip') { finishImport(pending.result); return; }
3518
+
3519
+ var retry = FileImport.conflictedEntries(
3520
+ pending.entries,
3521
+ pending.targetFolderPath,
3522
+ pending.result.conflicts
3523
+ );
3524
+ if (retry.length === 0) { finishImport(pending.result); return; }
3525
+
3526
+ FileService.importFiles(FileImport.buildFormData(retry, pending.targetFolderPath, mode))
3527
+ .then(function(second) {
3528
+ finishImport({
3529
+ imported: (pending.result.imported || []).concat(second.imported || []),
3530
+ conflicts: [],
3531
+ errors: (pending.result.errors || []).concat(second.errors || [])
3532
+ });
3533
+ })['catch'](function(err) {
3534
+ var message = err && err.response && err.response.data && err.response.data.error || err.message;
3535
+ EditorStore.setStatus('Import failed: ' + message, 'error');
3536
+ });
3537
+ };
3538
+
2876
3539
  var openContextMenu = function openContextMenu(e, node) {
2877
3540
  setContextMenu({ x: e.clientX, y: e.clientY, node: node });
2878
3541
  handleNodeSelect(node);
@@ -2939,6 +3602,12 @@ var MbeditorApp = function MbeditorApp() {
2939
3602
  openSettingsTab();
2940
3603
  return;
2941
3604
  }
3605
+ // The model graph is a view, not a panel: it takes over the central area
3606
+ // and needs the width. There is no sidebar half to show.
3607
+ if (tab === 'models') {
3608
+ openModelGraphTab();
3609
+ return;
3610
+ }
2942
3611
  if (!sidebarCollapsed && activeSidebarTab === tab) {
2943
3612
  setSidebarCollapsed(true);
2944
3613
  } else {
@@ -3364,6 +4033,48 @@ var MbeditorApp = function MbeditorApp() {
3364
4033
  EditorStore.setState({ panes: newPanes2, focusedPaneId: paneId, activeTabId: '__settings__' });
3365
4034
  }
3366
4035
 
4036
+ // The diagram lives in an editor tab, not the sidebar: a layered graph is
4037
+ // inherently wide and a ~300px panel can only ever show its first column.
4038
+ // The sidebar tab is the entry point and the searchable model list.
4039
+ var MODEL_GRAPH_TAB_ID = 'mbeditor://model-graph';
4040
+ function openModelGraphTab() {
4041
+ var st = EditorStore.getState();
4042
+ var paneId = st.focusedPaneId;
4043
+
4044
+ var existing = null;
4045
+ st.panes.forEach(function (p) {
4046
+ if (!existing && p.tabs.some(function (t) { return t.id === MODEL_GRAPH_TAB_ID; })) {
4047
+ existing = p.id;
4048
+ }
4049
+ });
4050
+ if (existing) {
4051
+ EditorStore.setState({
4052
+ panes: st.panes.map(function (p) {
4053
+ return p.id === existing ? Object.assign({}, p, { activeTabId: MODEL_GRAPH_TAB_ID }) : p;
4054
+ }),
4055
+ focusedPaneId: existing
4056
+ });
4057
+ return;
4058
+ }
4059
+
4060
+ var pane = st.panes.find(function (p) { return p.id === paneId; }) || st.panes[0];
4061
+ if (!pane) return;
4062
+
4063
+ var newTab = {
4064
+ id: MODEL_GRAPH_TAB_ID, path: MODEL_GRAPH_TAB_ID, name: 'Model Graph',
4065
+ dirty: false, content: '', isModelGraph: true
4066
+ };
4067
+ EditorStore.setState({
4068
+ panes: st.panes.map(function (p) {
4069
+ return p.id === pane.id
4070
+ ? Object.assign({}, p, { tabs: p.tabs.concat(newTab), activeTabId: MODEL_GRAPH_TAB_ID })
4071
+ : p;
4072
+ }),
4073
+ focusedPaneId: pane.id
4074
+ });
4075
+ loadModelGraph(false);
4076
+ }
4077
+
3367
4078
  var CHANGELOG_TAB_ID = 'mbeditor://changelog';
3368
4079
  function openChangelogTab() {
3369
4080
  var st = EditorStore.getState();
@@ -3454,7 +4165,7 @@ var MbeditorApp = function MbeditorApp() {
3454
4165
  return activeTab && handleSave(focusedPane.id, activeTab);
3455
4166
  }, disabled: loading.save || !activeTab || !activeTab.dirty, 'aria-busy': !!loading.save },
3456
4167
  !loading.save && React.createElement("i", { className: "fas fa-save" }),
3457
- !editorPrefs.toolbarIconOnly && !loading.save && " Save",
4168
+ !toolbarIconOnly && !loading.save && " Save",
3458
4169
  !loading.save && activeTab && activeTab.dirty ? " ●" : ""
3459
4170
  ),
3460
4171
  React.createElement(
@@ -3469,7 +4180,7 @@ var MbeditorApp = function MbeditorApp() {
3469
4180
  { className: "fas fa-save", style: { position: 'relative' } },
3470
4181
  React.createElement("i", { className: "fas fa-save", style: { position: 'absolute', top: '-2px', left: '3px', fontSize: '9px', opacity: 0.8 } })
3471
4182
  ),
3472
- !editorPrefs.toolbarIconOnly && !loading.saveAll && " Save All"
4183
+ !toolbarIconOnly && !loading.saveAll && " Save All"
3473
4184
  ),
3474
4185
  React.createElement("div", { className: "statusbar-sep" }),
3475
4186
  React.createElement(
@@ -3479,13 +4190,13 @@ var MbeditorApp = function MbeditorApp() {
3479
4190
  "button",
3480
4191
  { className: "statusbar-btn", onClick: function() { var ed = window.__mbeditorActiveEditor; if (ed) ed.trigger('keyboard', 'undo', null); }, disabled: !activeTab || !state.canUndo, title: "Undo (Ctrl+Z)" },
3481
4192
  React.createElement("i", { className: "fas fa-undo" }),
3482
- !editorPrefs.toolbarIconOnly && " Undo"
4193
+ !toolbarIconOnly && " Undo"
3483
4194
  ),
3484
4195
  React.createElement(
3485
4196
  "button",
3486
4197
  { className: "statusbar-btn", onClick: function() { var ed = window.__mbeditorActiveEditor; if (ed) ed.trigger('keyboard', 'redo', null); }, disabled: !activeTab || !state.canRedo, title: "Redo (Ctrl+Y)" },
3487
4198
  React.createElement("i", { className: "fas fa-redo" }),
3488
- !editorPrefs.toolbarIconOnly && " Redo"
4199
+ !toolbarIconOnly && " Redo"
3489
4200
  )
3490
4201
  ),
3491
4202
  React.createElement("div", { className: "statusbar-sep" }),
@@ -3493,7 +4204,7 @@ var MbeditorApp = function MbeditorApp() {
3493
4204
  "button",
3494
4205
  { className: "statusbar-btn", onClick: handleFormat, disabled: loading.format || !canLintAndFormat, 'aria-busy': !!loading.format },
3495
4206
  !loading.format && React.createElement("i", { className: "fas fa-magic" }),
3496
- !editorPrefs.toolbarIconOnly && !loading.format && " Format"
4207
+ !toolbarIconOnly && !loading.format && " Format"
3497
4208
  ),
3498
4209
  hasGitBranch && React.createElement(
3499
4210
  React.Fragment,
@@ -3503,15 +4214,79 @@ var MbeditorApp = function MbeditorApp() {
3503
4214
  "button",
3504
4215
  { type: "button", className: "statusbar-btn", onClick: toggleGitPanel },
3505
4216
  React.createElement("i", { className: "fas fa-code-branch" }),
3506
- !editorPrefs.toolbarIconOnly && " Git"
4217
+ !toolbarIconOnly && " Git"
4218
+ )
4219
+ ),
4220
+ collabEnabled && collabIdentity && React.createElement(
4221
+ React.Fragment,
4222
+ null,
4223
+ React.createElement("div", { className: "statusbar-sep" }),
4224
+ React.createElement(
4225
+ "button",
4226
+ {
4227
+ type: "button",
4228
+ className: "statusbar-btn",
4229
+ onMouseEnter: function (e) { openCollabHover('__me__', e); },
4230
+ onMouseLeave: function () { setCollabHover(null); },
4231
+ onClick: function () { CollaborationIdentity.editName(); }
4232
+ },
4233
+ React.createElement("i", {
4234
+ className: "fas fa-circle collab-pulse",
4235
+ style: { color: collabIdentity.color, fontSize: "0.7em", marginRight: "2px" }
4236
+ }),
4237
+ !toolbarIconOnly && (" " + collabIdentity.name)
3507
4238
  )
3508
4239
  ),
4240
+ collabEnabled && collabPeerIds.length > 0 && React.createElement(
4241
+ React.Fragment,
4242
+ null,
4243
+ React.createElement("div", { className: "statusbar-sep" }),
4244
+ collabPeerIds.map(function (cid) {
4245
+ var peer = collabRoster[cid];
4246
+ var file = peer.current_file;
4247
+ var name = peer.name || 'Anonymous';
4248
+ var colour = peer.colour || '#888888';
4249
+ var following = followedClientId === cid;
4250
+ // Solid dot: they are in the file you are looking at, so their caret
4251
+ // is on screen. Hollow ring: they are somewhere else and there is
4252
+ // nothing to see — without this the chip looked identical either way
4253
+ // and a peer's caret just vanished with no explanation.
4254
+ var elsewhere = file !== presenceFile;
4255
+ return React.createElement(
4256
+ "button",
4257
+ {
4258
+ key: cid,
4259
+ type: "button",
4260
+ className: "statusbar-btn",
4261
+ style: following
4262
+ ? { background: 'color-mix(in srgb, ' + colour + ' 28%, transparent)' }
4263
+ : undefined,
4264
+ onMouseEnter: function (e) { openCollabHover(cid, e); },
4265
+ onMouseLeave: function () { setCollabHover(null); },
4266
+ onClick: function () { toggleFollow(cid); }
4267
+ },
4268
+ React.createElement("i", {
4269
+ className: (following ? "fas fa-eye" : (elsewhere ? "far fa-circle" : "fas fa-circle")) +
4270
+ " collab-pulse",
4271
+ style: { color: colour, fontSize: "0.7em", marginRight: "2px" }
4272
+ }),
4273
+ collabPeerLabels && (" " + name),
4274
+ // Where they went, when they are not where you are. Basename only —
4275
+ // the chip has ~110px to spend and the full path is in the tooltip.
4276
+ collabPeerLabels && elsewhere && file && React.createElement(
4277
+ "span",
4278
+ { style: { opacity: 0.65, marginLeft: "4px" } },
4279
+ file.split('/').pop()
4280
+ )
4281
+ );
4282
+ })
4283
+ ),
3509
4284
  React.createElement("div", { className: "statusbar-sep" }),
3510
4285
  React.createElement(
3511
4286
  "button",
3512
4287
  { type: "button", className: "statusbar-btn", onClick: function () { return setShowHelp(true); }, title: "Keyboard shortcuts & help" },
3513
4288
  React.createElement("i", { className: "fas fa-keyboard" }),
3514
- !editorPrefs.toolbarIconOnly && " Help"
4289
+ !toolbarIconOnly && " Help"
3515
4290
  ),
3516
4291
  pwaInstallPrompt && React.createElement(
3517
4292
  React.Fragment,
@@ -3529,11 +4304,55 @@ var MbeditorApp = function MbeditorApp() {
3529
4304
  }
3530
4305
  },
3531
4306
  React.createElement("i", { className: "fas fa-download" }),
3532
- !editorPrefs.toolbarIconOnly && " Install"
4307
+ !toolbarIconOnly && " Install"
3533
4308
  )
3534
4309
  )
3535
4310
  )
3536
4311
  ),
4312
+ collabHover && (function () {
4313
+ var isMe = collabHover.cid === '__me__';
4314
+ var peer = isMe ? null : collabRoster[collabHover.cid];
4315
+ // The peer can leave between hover and paint — the roster is the authority.
4316
+ if (!isMe && !peer) return null;
4317
+
4318
+ var stats = isMe ? { rtt: ownRttRef.current } : (peerStatsRef.current[collabHover.cid] || {});
4319
+ var name = isMe ? collabIdentity.name : (peer.name || 'Anonymous');
4320
+ var colour = isMe ? collabIdentity.color : (peer.colour || '#888888');
4321
+ var file = isMe ? presenceFile : peer.current_file;
4322
+ // Server-measured against a monotonic clock, so it is not our arrival time
4323
+ // and no clock skew enters into it.
4324
+ var idle = typeof stats.idle === 'number' ? stats.idle : null;
4325
+
4326
+ return React.createElement(
4327
+ 'div',
4328
+ { className: 'collab-hovercard', style: { top: collabHover.top + 'px', right: collabHover.right + 'px' } },
4329
+ React.createElement(
4330
+ 'div',
4331
+ { className: 'collab-hovercard-name' },
4332
+ React.createElement('span', { className: 'collab-hovercard-swatch', style: { background: colour } }),
4333
+ name,
4334
+ isMe && React.createElement('span', { style: { opacity: 0.6, fontWeight: 400 } }, ' (you)')
4335
+ ),
4336
+ React.createElement('div', { className: 'collab-hovercard-row' }, file || 'No file open'),
4337
+ typeof stats.rtt === 'number' && React.createElement(
4338
+ 'div', { className: 'collab-hovercard-row' }, 'Latency ' + Math.round(stats.rtt) + ' ms'
4339
+ ),
4340
+ // Everyone in the roster is connected — the server evicts on disconnect —
4341
+ // so this is not a liveness warning. It says their heartbeat has slowed,
4342
+ // which is what a browser does to a backgrounded tab's timers, and is why
4343
+ // their caret may be behind. Silent under 20s, where it would only ever
4344
+ // read "5s ago".
4345
+ idle !== null && idle >= 20 && React.createElement(
4346
+ 'div', { className: 'collab-hovercard-row' }, 'Idle ' + idle + 's'
4347
+ ),
4348
+ React.createElement(
4349
+ 'div',
4350
+ { className: 'collab-hovercard-hint' },
4351
+ isMe ? 'Click to change your name'
4352
+ : (followedClientId === collabHover.cid ? 'Click to stop following' : 'Click to follow')
4353
+ )
4354
+ );
4355
+ })(),
3537
4356
  showHelp && React.createElement(ShortcutHelp, { onClose: function () { return setShowHelp(false); } }),
3538
4357
  React.createElement(
3539
4358
  "div",
@@ -3574,6 +4393,16 @@ var MbeditorApp = function MbeditorApp() {
3574
4393
  onClick: function() { handleActivityBarClick('rails'); }
3575
4394
  },
3576
4395
  React.createElement("i", { className: "far fa-gem" })
4396
+ ),
4397
+ React.createElement(
4398
+ "button",
4399
+ {
4400
+ type: "button",
4401
+ className: "ide-activity-btn" + (activeTab && activeTab.isModelGraph ? ' active' : ''),
4402
+ title: "Model graph",
4403
+ onClick: function() { handleActivityBarClick('models'); }
4404
+ },
4405
+ React.createElement("i", { className: "fas fa-project-diagram" })
3577
4406
  )
3578
4407
  ),
3579
4408
  React.createElement(
@@ -3793,6 +4622,7 @@ var MbeditorApp = function MbeditorApp() {
3793
4622
  onNodeSelect: handleNodeSelect,
3794
4623
  onMultiSelect: handleMultiSelect,
3795
4624
  onMove: handleMoveNodes,
4625
+ onImportFiles: handleImportFiles,
3796
4626
  gitFiles: state.gitFiles,
3797
4627
  expandedDirs: expandedDirs,
3798
4628
  onExpandedDirsChange: setExpandedDirs,
@@ -4014,25 +4844,7 @@ var MbeditorApp = function MbeditorApp() {
4014
4844
  title: 'View database schema for ' + label,
4015
4845
  onClick: (function(lbl) { return function(e) {
4016
4846
  e.stopPropagation();
4017
- if (schemaLoadingLabel === lbl) return;
4018
- setSchemaLoadingLabel(lbl);
4019
- var modelName = lbl.replace(/\s+/g, '');
4020
- FileService.getModelSchema(modelName)
4021
- .then(function(data) {
4022
- setSchemaLoadingLabel(null);
4023
- if (data && data.columns) {
4024
- setSchemaModal({ label: lbl, data: data });
4025
- } else {
4026
- setSchemaModal({ label: lbl, error: 'No schema found for ' + lbl });
4027
- }
4028
- })
4029
- ['catch'](function(err) {
4030
- setSchemaLoadingLabel(null);
4031
- var msg = (err && err.response && err.response.data && err.response.data.error)
4032
- ? err.response.data.error
4033
- : 'No db/schema.rb found or table not defined';
4034
- setSchemaModal({ label: lbl, error: msg });
4035
- });
4847
+ openSchemaModal(lbl);
4036
4848
  }; })(label)
4037
4849
  },
4038
4850
  React.createElement('i', {
@@ -4094,12 +4906,18 @@ var MbeditorApp = function MbeditorApp() {
4094
4906
  "aria-orientation": "vertical",
4095
4907
  "aria-label": "Resize explorer panel"
4096
4908
  }),
4909
+ // Column wrapping the split panes and the bottom drawers. ide-main is a
4910
+ // row of panes, so the drawers need a vertical parent to push against;
4911
+ // as absolute overlays they covered the editor instead.
4912
+ React.createElement(
4913
+ "div",
4914
+ { className: "ide-center-column" },
4097
4915
  React.createElement(
4098
4916
  "div",
4099
4917
  {
4100
4918
  id: "ide-main-split-container",
4101
4919
  className: "ide-main",
4102
- style: { position: 'relative', display: 'flex', flexDirection: 'row', width: '100%', height: '100%', cursor: activeResizeMode === 'pane' ? 'col-resize' : 'default', userSelect: activeResizeMode ? 'none' : 'auto' },
4920
+ style: { position: 'relative', display: 'flex', flexDirection: 'row', width: '100%', flex: '1 1 auto', minHeight: 0, cursor: activeResizeMode === 'pane' ? 'col-resize' : 'default', userSelect: activeResizeMode ? 'none' : 'auto' },
4103
4921
  onDragOverCapture: function (e) {
4104
4922
  if (!draggedTab) return;
4105
4923
  e.preventDefault();
@@ -4189,6 +5007,13 @@ var MbeditorApp = function MbeditorApp() {
4189
5007
  commits: pActiveTab.commits || [],
4190
5008
  onSelectCommit: handleSelectCommit
4191
5009
  });
5010
+ } else if (pActiveTab.isModelGraph) {
5011
+ content = React.createElement(ModelGraph, {
5012
+ graph: modelGraph,
5013
+ loading: modelGraphLoading,
5014
+ onRefresh: function () { loadModelGraph(true); },
5015
+ onOpenModel: function (model) { openSchemaModal(model.name); }
5016
+ });
4192
5017
  } else if (pActiveTab.isChangelog) {
4193
5018
  content = React.createElement(ChangelogView, {
4194
5019
  changelogState: changelogState,
@@ -4723,6 +5548,9 @@ var MbeditorApp = function MbeditorApp() {
4723
5548
  React.createElement('input', {
4724
5549
  type: 'checkbox',
4725
5550
  className: 'ide-settings-checkbox',
5551
+ // The stored preference, not the derived value: at a
5552
+ // narrow width the box would otherwise show as checked
5553
+ // and unchecking it would appear to do nothing.
4726
5554
  checked: !!(editorPrefs.toolbarIconOnly),
4727
5555
  onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { toolbarIconOnly: v }); }); }
4728
5556
  })
@@ -4993,6 +5821,16 @@ var MbeditorApp = function MbeditorApp() {
4993
5821
  );
4994
5822
  })
4995
5823
  ),
5824
+ showLogPanel && !zenMode && React.createElement(window.LogPanel || LogPanel, {
5825
+ onClose: function () { setShowLogPanel(false); }
5826
+ }),
5827
+ showProblemsPanel && !zenMode && React.createElement(window.ProblemsPanel || ProblemsPanel, {
5828
+ onClose: function () { setShowProblemsPanel(false); },
5829
+ onOpenFile: function (path, line, col) {
5830
+ handleSelectFile(path, path.split('/').pop(), line, col);
5831
+ }
5832
+ })
5833
+ ),
4996
5834
 
4997
5835
  // Right-side Git panel (children of ide-body, alongside sidebar and ide-main)
4998
5836
  showGitPanel && !zenMode && React.createElement("div", {
@@ -5017,15 +5855,6 @@ var MbeditorApp = function MbeditorApp() {
5017
5855
  onSelectCommit: handleSelectCommit
5018
5856
  })
5019
5857
  ),
5020
- showLogPanel && !zenMode && React.createElement(window.LogPanel || LogPanel, {
5021
- onClose: function () { setShowLogPanel(false); }
5022
- }),
5023
- showProblemsPanel && !zenMode && React.createElement(window.ProblemsPanel || ProblemsPanel, {
5024
- onClose: function () { setShowProblemsPanel(false); },
5025
- onOpenFile: function (path, line, col) {
5026
- handleSelectFile(path, path.split('/').pop(), line, col);
5027
- }
5028
- })
5029
5858
  ),
5030
5859
  React.createElement(
5031
5860
  "div",
@@ -5066,6 +5895,27 @@ var MbeditorApp = function MbeditorApp() {
5066
5895
  }),
5067
5896
  React.createElement("span", { className: "statusbar-problems-count" }, problemCounts.warnings)
5068
5897
  ),
5898
+ // ruby-lsp indicator. Hidden entirely when ruby-lsp was never available
5899
+ // and nothing has gone wrong — a permanent "off" badge in a project with
5900
+ // no Ruby is noise. A healthy server gets a quiet icon; a degraded one
5901
+ // gets an amber chip you can click to restart.
5902
+ (lspHealth.status !== 'off' || lspHealth.reason) && React.createElement(
5903
+ "button",
5904
+ {
5905
+ type: "button",
5906
+ className: "statusbar-btn statusbar-lsp statusbar-lsp-" + lspHealth.status,
5907
+ onClick: restartRubyLsp,
5908
+ title: lspHealth.status === 'ok'
5909
+ ? 'ruby-lsp is running — click to restart'
5910
+ : 'ruby-lsp unavailable' + (lspHealth.reason ? ': ' + lspHealth.reason : '') +
5911
+ '. Falling back to search-based lookups. Click to retry.'
5912
+ },
5913
+ React.createElement("i", {
5914
+ className: "fas " + (lspHealth.status === 'ok' ? 'fa-gem' : 'fa-plug'),
5915
+ "aria-hidden": "true"
5916
+ }),
5917
+ lspHealth.status !== 'ok' && React.createElement("span", null, " ruby-lsp")
5918
+ ),
5069
5919
  !serverOnline && (function () {
5070
5920
  var dirtyCount = state.panes.reduce(function (acc, p) {
5071
5921
  return acc + p.tabs.filter(function (t) { return t.dirty; }).length;
@@ -5106,6 +5956,25 @@ var MbeditorApp = function MbeditorApp() {
5106
5956
  React.createElement("i", { className: "fas fa-stream" }),
5107
5957
  " Logs"
5108
5958
  ),
5959
+ React.createElement(
5960
+ "button",
5961
+ {
5962
+ type: "button",
5963
+ className: "statusbar-btn" + (editorPrefs.renderWhitespace === 'all' ? " active" : ""),
5964
+ title: editorPrefs.renderWhitespace === 'all'
5965
+ ? "Hide whitespace characters"
5966
+ : "Show whitespace characters (tabs, spaces, control characters)",
5967
+ "aria-pressed": editorPrefs.renderWhitespace === 'all',
5968
+ onClick: function () {
5969
+ setEditorPrefs(function (p) {
5970
+ return _extends({}, p, {
5971
+ renderWhitespace: p.renderWhitespace === 'all' ? 'none' : 'all'
5972
+ });
5973
+ });
5974
+ }
5975
+ },
5976
+ React.createElement("i", { className: "fas fa-paragraph" })
5977
+ ),
5109
5978
  activeEOL && React.createElement(
5110
5979
  "button",
5111
5980
  {
@@ -5440,6 +6309,13 @@ var MbeditorApp = function MbeditorApp() {
5440
6309
  )
5441
6310
  ),
5442
6311
 
6312
+ /* ── Import conflict modal ─────────────────────────────────────────── */
6313
+ importConflict && React.createElement(ImportConflictModal, {
6314
+ conflicts: importConflict.result.conflicts,
6315
+ errors: importConflict.result.errors,
6316
+ onResolve: resolveImportConflict
6317
+ }),
6318
+
5443
6319
  /* ── Schema modal ──────────────────────────────────────────────────── */
5444
6320
  schemaModal && React.createElement(
5445
6321
  'div',
@@ -5533,4 +6409,4 @@ var MbeditorApp = function MbeditorApp() {
5533
6409
  };
5534
6410
 
5535
6411
  window.MbeditorApp = MbeditorApp;
5536
- /* TITLE BAR */ /* SIDEBAR */ /* EDITOR AREA */ /* STATUS BAR */ /* Right-click context menu */
6412
+ /* TITLE BAR */ /* SIDEBAR */ /* EDITOR AREA */ /* STATUS BAR */ /* Right-click context menu */