mbeditor 0.11.0 → 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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +153 -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 +911 -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 +517 -111
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +52 -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 +465 -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/lsp_diagnostics_translator.rb +99 -5
  37. data/app/services/mbeditor/model_graph_service.rb +232 -0
  38. data/app/services/mbeditor/presence_registry.rb +83 -0
  39. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  40. data/app/services/mbeditor/search_replace_service.rb +24 -4
  41. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  42. data/lib/mbeditor/configuration.rb +33 -3
  43. data/lib/mbeditor/engine.rb +34 -0
  44. data/lib/mbeditor/exception_log.rb +84 -0
  45. data/lib/mbeditor/route_map.rb +5 -0
  46. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  47. data/lib/mbeditor/version.rb +1 -1
  48. data/lib/mbeditor.rb +1 -0
  49. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  50. metadata +15 -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,44 @@ 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);
602
716
  // path -> the file's content as last seen ON DISK (newline-normalised).
603
717
  // External-change detection compares disk-to-disk; comparing disk to the
604
718
  // buffer flags every dirty tab, which is just the definition of "dirty".
@@ -716,6 +830,112 @@ var MbeditorApp = function MbeditorApp() {
716
830
  return path && (path.endsWith('.rb') || path.endsWith('.gemspec') || path.endsWith('Rakefile') || path.endsWith('Gemfile'));
717
831
  };
718
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
+
719
939
  var applyMarkersForTab = function applyMarkersForTab(paneId, tabId, nextMarkers) {
720
940
  var currentPane = EditorStore.getState().panes.find(function (p) {
721
941
  return p.id === paneId;
@@ -748,14 +968,15 @@ var MbeditorApp = function MbeditorApp() {
748
968
  // Anything short of a usable answer falls through to the HTTP lint for
749
969
  // this call, so behaviour without ruby-lsp is unchanged.
750
970
  var lintRequest;
751
- 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) {
752
974
  lintRequest = FileService.lspDiagnostics(tab.path, tab.content).then(function (res) {
753
975
  if (res && res.markers && !res.fallback && !res.error) return res;
976
+ noteLspFailure({ lspData: res || {} });
754
977
  return FileService.lintFile(tab.path, tab.content);
755
978
  })["catch"](function (err) {
756
- if (err && err.response && err.response.status === 422) {
757
- window.MBEDITOR_RUBY_LSP_AVAILABLE = false;
758
- }
979
+ noteLspFailure(err);
759
980
  return FileService.lintFile(tab.path, tab.content);
760
981
  });
761
982
  } else {
@@ -939,6 +1160,9 @@ var MbeditorApp = function MbeditorApp() {
939
1160
  if (t.isCombinedDiff || (t.path || '').startsWith('combined-diff://') || (t.path || '').startsWith('diff://')) {
940
1161
  return Promise.resolve({ content: '' });
941
1162
  }
1163
+ if (t.isModelGraph || t.path === 'mbeditor://model-graph') {
1164
+ return Promise.resolve({ content: '' });
1165
+ }
942
1166
  var sourcePath = t.isPreview || /::preview$/.test(t.path || '') ? t.previewFor || (t.path || '').replace(/::preview$/, '') : t.path;
943
1167
  return FileService.getFile(sourcePath, { allowMissing: true }).then(function (data) {
944
1168
  return {
@@ -962,7 +1186,7 @@ var MbeditorApp = function MbeditorApp() {
962
1186
  p.tabs.forEach(function (t) {
963
1187
  var res = results[resIdx++];
964
1188
  var isPlainFile = t.path && !t.isDiff && !t.isCombinedDiff && !t.isSettings &&
965
- !t.isChangelog && !t.isPreview &&
1189
+ !t.isChangelog && !t.isPreview && !t.isModelGraph &&
966
1190
  !/^(diff|combined-diff):\/\//.test(t.path) && !/::preview$/.test(t.path);
967
1191
  if (isPlainFile) {
968
1192
  if (seenPaths[t.path]) return;
@@ -971,7 +1195,11 @@ var MbeditorApp = function MbeditorApp() {
971
1195
  tabs.push(_extends({}, t, {
972
1196
  content: res.content,
973
1197
  externalContentVersion: (t.externalContentVersion || 0) + 1
974
- }, 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 } : {},
975
1203
  typeof res.fileNotFound === 'boolean' ? { fileNotFound: res.fileNotFound, dirty: res.fileNotFound ? false : t.dirty } : {},
976
1204
  res.image === true ? { isImage: true } : {}));
977
1205
  });
@@ -1045,7 +1273,8 @@ var MbeditorApp = function MbeditorApp() {
1045
1273
  }
1046
1274
  return loadPaneState(panesToLoad, focusedPaneId);
1047
1275
  });
1048
- });
1276
+ })["catch"](function () { /* fall through to marking restore done */ })
1277
+ .then(function () { setSessionRestored(true); });
1049
1278
 
1050
1279
  // Watch for git branch changes and swap per-branch tab state
1051
1280
  var unsubBranch = EditorStore.subscribeToSlice(['gitBranch'], function (st) {
@@ -1081,7 +1310,7 @@ var MbeditorApp = function MbeditorApp() {
1081
1310
  return {
1082
1311
  id: p.id,
1083
1312
  activeTabId: p.activeTabId,
1084
- 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) {
1085
1314
  return {
1086
1315
  id: t.id, path: t.path, name: t.name, dirty: t.dirty, viewState: t.viewState,
1087
1316
  isSettings: !!t.isSettings, isPreview: !!t.isPreview, previewFor: t.previewFor || null,
@@ -1419,12 +1648,7 @@ var MbeditorApp = function MbeditorApp() {
1419
1648
  if (document.hidden) return;
1420
1649
  GitService.fetchStatus()["catch"](function () {});
1421
1650
  FileService.getTree().then(function (data) {
1422
- var newData = data || [];
1423
- setTreeData(function (prevData) {
1424
- var sig = function(d) { return d.length + ':' + d.map(function(n) { return n.name; }).join(','); };
1425
- if (sig(newData) !== sig(prevData)) SearchService.buildIndex(newData);
1426
- return newData;
1427
- });
1651
+ setTreeData(_treeUpdater(data || []));
1428
1652
  checkOpenTabsForExternalChanges();
1429
1653
  })["catch"](function () {});
1430
1654
  if (payload && payload.paths && searchQueryRef.current && searchPanelVisibleRef.current) {
@@ -1436,6 +1660,41 @@ var MbeditorApp = function MbeditorApp() {
1436
1660
  return function () { WebSocketService.offFilesChanged(handleFilesChanged); };
1437
1661
  }, []);
1438
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
+
1439
1698
  function checkOpenTabsForExternalChanges() {
1440
1699
  var st = EditorStore.getState();
1441
1700
  var allTabs = st.panes.reduce(function (acc, p) {
@@ -1456,6 +1715,16 @@ var MbeditorApp = function MbeditorApp() {
1456
1715
  fileTabs.forEach(function (pt) {
1457
1716
  var savedAt = recentSavesRef.current[pt.tab.path];
1458
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
+ }
1459
1728
  FileService.getFile(pt.tab.path, { allowMissing: true }).then(function (data) {
1460
1729
  if (!data || typeof data.content !== 'string') return;
1461
1730
  var serverNorm = data.content.replace(/\r\n/g, '\n');
@@ -1524,17 +1793,13 @@ var MbeditorApp = function MbeditorApp() {
1524
1793
  // broadcasts from mbeditor's own mutation endpoints, so a connected socket
1525
1794
  // meant external changes were never picked up at all. The push remains the
1526
1795
  // instant path for our own writes; this is what catches everything else.
1527
- // 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.
1528
1798
  useEffect(function () {
1529
1799
  var intervalId = setInterval(function () {
1530
1800
  if (document.hidden) return;
1531
1801
  FileService.getTree().then(function (data) {
1532
- var newData = data || [];
1533
- setTreeData(function (prevData) {
1534
- var sig = function(d) { return d.length + ':' + d.map(function(n) { return n.name; }).join(','); };
1535
- if (sig(newData) !== sig(prevData)) SearchService.buildIndex(newData);
1536
- return newData;
1537
- });
1802
+ setTreeData(_treeUpdater(data || []));
1538
1803
  }).catch(function () {}); // silently ignore auto-refresh errors
1539
1804
  }, 10000);
1540
1805
  return function () { clearInterval(intervalId); };
@@ -1796,7 +2061,7 @@ var MbeditorApp = function MbeditorApp() {
1796
2061
  return {
1797
2062
  id: p.id,
1798
2063
  activeTabId: p.activeTabId,
1799
- 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) {
1800
2065
  return {
1801
2066
  id: t.id,
1802
2067
  path: t.path,
@@ -1848,22 +2113,50 @@ var MbeditorApp = function MbeditorApp() {
1848
2113
  useEffect(function() {
1849
2114
  FileService.getClientConfig().then(function(cfg) {
1850
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
+ }
1851
2121
  })['catch'](function() {});
1852
2122
  }, []);
1853
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
+
1854
2131
  // Version-update detection: open the changelog tab automatically when the
1855
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.
1856
2144
  useEffect(function() {
1857
2145
  var SEEN_KEY = 'mbeditor_seen_version';
1858
2146
  var current = document.body.dataset.mbeditorVersion || '';
2147
+ if (!current) return;
1859
2148
  var seen = localStorage.getItem(SEEN_KEY) || '';
1860
- if (current && seen && seen !== current) {
1861
- // Delay slightly so the editor finishes restoring saved tabs first
1862
- setTimeout(function() { openChangelogTab(); }, 800);
1863
- }
1864
- if (current) localStorage.setItem(SEEN_KEY, current);
2149
+ localStorage.setItem(SEEN_KEY, current);
2150
+ if (!seen || seen === current) return;
2151
+ pendingChangelogRef.current = true;
1865
2152
  }, []);
1866
2153
 
2154
+ useEffect(function() {
2155
+ if (!sessionRestored || !pendingChangelogRef.current) return;
2156
+ pendingChangelogRef.current = false;
2157
+ openChangelogTab();
2158
+ }, [sessionRestored]);
2159
+
1867
2160
  var resourceLabelFromPath = function(p) {
1868
2161
  if (!p) return null;
1869
2162
 
@@ -1994,6 +2287,239 @@ var MbeditorApp = function MbeditorApp() {
1994
2287
  return t.id === focusedPane.activeTabId;
1995
2288
  });
1996
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
+
1997
2523
  // Phase 7: Per-file last-commit info shown in the status bar
1998
2524
  var _useState31 = useState(null);
1999
2525
  var _useState32 = _slicedToArray(_useState31, 2);
@@ -2191,6 +2717,10 @@ var MbeditorApp = function MbeditorApp() {
2191
2717
  if (_modelEntry && _modelEntry.model && !_modelEntry.model.isDisposed()) {
2192
2718
  _modelEntry.cleanVersionId = _modelEntry.model.getAlternativeVersionId();
2193
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
+ }
2194
2724
  EditorStore.setStatus("Saved", "success");
2195
2725
  _clearDraft(tab.path);
2196
2726
  if (typeof HistoryService !== 'undefined') {
@@ -2397,12 +2927,7 @@ var MbeditorApp = function MbeditorApp() {
2397
2927
  });
2398
2928
  GitService.fetchStatus()["catch"](function () {});
2399
2929
  FileService.getTree().then(function (data) {
2400
- var newData = data || [];
2401
- setTreeData(function (prevData) {
2402
- if (JSON.stringify(newData) === JSON.stringify(prevData)) return prevData;
2403
- SearchService.buildIndex(newData);
2404
- return newData;
2405
- });
2930
+ setTreeData(_treeUpdater(data || []));
2406
2931
  checkOpenTabsForExternalChanges();
2407
2932
  EditorStore.setStatus("Workspace refreshed", "success");
2408
2933
  })["catch"](function (err) {
@@ -2414,6 +2939,34 @@ var MbeditorApp = function MbeditorApp() {
2414
2939
  });
2415
2940
  };
2416
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
+
2417
2970
  var handleFormat = function handleFormat() {
2418
2971
  if (!activeTab) return;
2419
2972
 
@@ -2435,7 +2988,7 @@ var MbeditorApp = function MbeditorApp() {
2435
2988
  var detectedWidth = detectIndentWidth(originalContent);
2436
2989
  if (detectedWidth > 0) codeToFormat = spacesToTabs(originalContent, detectedWidth);
2437
2990
  }
2438
- FileService.formatFile(activeTab.path, codeToFormat).then(function (res) {
2991
+ formatRubySource(activeTab.path, codeToFormat).then(function (res) {
2439
2992
  if (res.content) {
2440
2993
  // Update content and mark dirty — user decides when to save.
2441
2994
  // The executeEdits path in EditorPanel preserves the undo stack.
@@ -2891,6 +3444,98 @@ var MbeditorApp = function MbeditorApp() {
2891
3444
  });
2892
3445
  };
2893
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
+
2894
3539
  var openContextMenu = function openContextMenu(e, node) {
2895
3540
  setContextMenu({ x: e.clientX, y: e.clientY, node: node });
2896
3541
  handleNodeSelect(node);
@@ -2957,6 +3602,12 @@ var MbeditorApp = function MbeditorApp() {
2957
3602
  openSettingsTab();
2958
3603
  return;
2959
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
+ }
2960
3611
  if (!sidebarCollapsed && activeSidebarTab === tab) {
2961
3612
  setSidebarCollapsed(true);
2962
3613
  } else {
@@ -3382,6 +4033,48 @@ var MbeditorApp = function MbeditorApp() {
3382
4033
  EditorStore.setState({ panes: newPanes2, focusedPaneId: paneId, activeTabId: '__settings__' });
3383
4034
  }
3384
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
+
3385
4078
  var CHANGELOG_TAB_ID = 'mbeditor://changelog';
3386
4079
  function openChangelogTab() {
3387
4080
  var st = EditorStore.getState();
@@ -3472,7 +4165,7 @@ var MbeditorApp = function MbeditorApp() {
3472
4165
  return activeTab && handleSave(focusedPane.id, activeTab);
3473
4166
  }, disabled: loading.save || !activeTab || !activeTab.dirty, 'aria-busy': !!loading.save },
3474
4167
  !loading.save && React.createElement("i", { className: "fas fa-save" }),
3475
- !editorPrefs.toolbarIconOnly && !loading.save && " Save",
4168
+ !toolbarIconOnly && !loading.save && " Save",
3476
4169
  !loading.save && activeTab && activeTab.dirty ? " ●" : ""
3477
4170
  ),
3478
4171
  React.createElement(
@@ -3487,7 +4180,7 @@ var MbeditorApp = function MbeditorApp() {
3487
4180
  { className: "fas fa-save", style: { position: 'relative' } },
3488
4181
  React.createElement("i", { className: "fas fa-save", style: { position: 'absolute', top: '-2px', left: '3px', fontSize: '9px', opacity: 0.8 } })
3489
4182
  ),
3490
- !editorPrefs.toolbarIconOnly && !loading.saveAll && " Save All"
4183
+ !toolbarIconOnly && !loading.saveAll && " Save All"
3491
4184
  ),
3492
4185
  React.createElement("div", { className: "statusbar-sep" }),
3493
4186
  React.createElement(
@@ -3497,13 +4190,13 @@ var MbeditorApp = function MbeditorApp() {
3497
4190
  "button",
3498
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)" },
3499
4192
  React.createElement("i", { className: "fas fa-undo" }),
3500
- !editorPrefs.toolbarIconOnly && " Undo"
4193
+ !toolbarIconOnly && " Undo"
3501
4194
  ),
3502
4195
  React.createElement(
3503
4196
  "button",
3504
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)" },
3505
4198
  React.createElement("i", { className: "fas fa-redo" }),
3506
- !editorPrefs.toolbarIconOnly && " Redo"
4199
+ !toolbarIconOnly && " Redo"
3507
4200
  )
3508
4201
  ),
3509
4202
  React.createElement("div", { className: "statusbar-sep" }),
@@ -3511,7 +4204,7 @@ var MbeditorApp = function MbeditorApp() {
3511
4204
  "button",
3512
4205
  { className: "statusbar-btn", onClick: handleFormat, disabled: loading.format || !canLintAndFormat, 'aria-busy': !!loading.format },
3513
4206
  !loading.format && React.createElement("i", { className: "fas fa-magic" }),
3514
- !editorPrefs.toolbarIconOnly && !loading.format && " Format"
4207
+ !toolbarIconOnly && !loading.format && " Format"
3515
4208
  ),
3516
4209
  hasGitBranch && React.createElement(
3517
4210
  React.Fragment,
@@ -3521,15 +4214,79 @@ var MbeditorApp = function MbeditorApp() {
3521
4214
  "button",
3522
4215
  { type: "button", className: "statusbar-btn", onClick: toggleGitPanel },
3523
4216
  React.createElement("i", { className: "fas fa-code-branch" }),
3524
- !editorPrefs.toolbarIconOnly && " Git"
4217
+ !toolbarIconOnly && " Git"
3525
4218
  )
3526
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)
4238
+ )
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
+ ),
3527
4284
  React.createElement("div", { className: "statusbar-sep" }),
3528
4285
  React.createElement(
3529
4286
  "button",
3530
4287
  { type: "button", className: "statusbar-btn", onClick: function () { return setShowHelp(true); }, title: "Keyboard shortcuts & help" },
3531
4288
  React.createElement("i", { className: "fas fa-keyboard" }),
3532
- !editorPrefs.toolbarIconOnly && " Help"
4289
+ !toolbarIconOnly && " Help"
3533
4290
  ),
3534
4291
  pwaInstallPrompt && React.createElement(
3535
4292
  React.Fragment,
@@ -3547,11 +4304,55 @@ var MbeditorApp = function MbeditorApp() {
3547
4304
  }
3548
4305
  },
3549
4306
  React.createElement("i", { className: "fas fa-download" }),
3550
- !editorPrefs.toolbarIconOnly && " Install"
4307
+ !toolbarIconOnly && " Install"
3551
4308
  )
3552
4309
  )
3553
4310
  )
3554
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
+ })(),
3555
4356
  showHelp && React.createElement(ShortcutHelp, { onClose: function () { return setShowHelp(false); } }),
3556
4357
  React.createElement(
3557
4358
  "div",
@@ -3592,6 +4393,16 @@ var MbeditorApp = function MbeditorApp() {
3592
4393
  onClick: function() { handleActivityBarClick('rails'); }
3593
4394
  },
3594
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" })
3595
4406
  )
3596
4407
  ),
3597
4408
  React.createElement(
@@ -3811,6 +4622,7 @@ var MbeditorApp = function MbeditorApp() {
3811
4622
  onNodeSelect: handleNodeSelect,
3812
4623
  onMultiSelect: handleMultiSelect,
3813
4624
  onMove: handleMoveNodes,
4625
+ onImportFiles: handleImportFiles,
3814
4626
  gitFiles: state.gitFiles,
3815
4627
  expandedDirs: expandedDirs,
3816
4628
  onExpandedDirsChange: setExpandedDirs,
@@ -4032,25 +4844,7 @@ var MbeditorApp = function MbeditorApp() {
4032
4844
  title: 'View database schema for ' + label,
4033
4845
  onClick: (function(lbl) { return function(e) {
4034
4846
  e.stopPropagation();
4035
- if (schemaLoadingLabel === lbl) return;
4036
- setSchemaLoadingLabel(lbl);
4037
- var modelName = lbl.replace(/\s+/g, '');
4038
- FileService.getModelSchema(modelName)
4039
- .then(function(data) {
4040
- setSchemaLoadingLabel(null);
4041
- if (data && data.columns) {
4042
- setSchemaModal({ label: lbl, data: data });
4043
- } else {
4044
- setSchemaModal({ label: lbl, error: 'No schema found for ' + lbl });
4045
- }
4046
- })
4047
- ['catch'](function(err) {
4048
- setSchemaLoadingLabel(null);
4049
- var msg = (err && err.response && err.response.data && err.response.data.error)
4050
- ? err.response.data.error
4051
- : 'No db/schema.rb found or table not defined';
4052
- setSchemaModal({ label: lbl, error: msg });
4053
- });
4847
+ openSchemaModal(lbl);
4054
4848
  }; })(label)
4055
4849
  },
4056
4850
  React.createElement('i', {
@@ -4112,12 +4906,18 @@ var MbeditorApp = function MbeditorApp() {
4112
4906
  "aria-orientation": "vertical",
4113
4907
  "aria-label": "Resize explorer panel"
4114
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" },
4115
4915
  React.createElement(
4116
4916
  "div",
4117
4917
  {
4118
4918
  id: "ide-main-split-container",
4119
4919
  className: "ide-main",
4120
- 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' },
4121
4921
  onDragOverCapture: function (e) {
4122
4922
  if (!draggedTab) return;
4123
4923
  e.preventDefault();
@@ -4207,6 +5007,13 @@ var MbeditorApp = function MbeditorApp() {
4207
5007
  commits: pActiveTab.commits || [],
4208
5008
  onSelectCommit: handleSelectCommit
4209
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
+ });
4210
5017
  } else if (pActiveTab.isChangelog) {
4211
5018
  content = React.createElement(ChangelogView, {
4212
5019
  changelogState: changelogState,
@@ -4741,6 +5548,9 @@ var MbeditorApp = function MbeditorApp() {
4741
5548
  React.createElement('input', {
4742
5549
  type: 'checkbox',
4743
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.
4744
5554
  checked: !!(editorPrefs.toolbarIconOnly),
4745
5555
  onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { toolbarIconOnly: v }); }); }
4746
5556
  })
@@ -5011,6 +5821,16 @@ var MbeditorApp = function MbeditorApp() {
5011
5821
  );
5012
5822
  })
5013
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
+ ),
5014
5834
 
5015
5835
  // Right-side Git panel (children of ide-body, alongside sidebar and ide-main)
5016
5836
  showGitPanel && !zenMode && React.createElement("div", {
@@ -5035,15 +5855,6 @@ var MbeditorApp = function MbeditorApp() {
5035
5855
  onSelectCommit: handleSelectCommit
5036
5856
  })
5037
5857
  ),
5038
- showLogPanel && !zenMode && React.createElement(window.LogPanel || LogPanel, {
5039
- onClose: function () { setShowLogPanel(false); }
5040
- }),
5041
- showProblemsPanel && !zenMode && React.createElement(window.ProblemsPanel || ProblemsPanel, {
5042
- onClose: function () { setShowProblemsPanel(false); },
5043
- onOpenFile: function (path, line, col) {
5044
- handleSelectFile(path, path.split('/').pop(), line, col);
5045
- }
5046
- })
5047
5858
  ),
5048
5859
  React.createElement(
5049
5860
  "div",
@@ -5084,6 +5895,27 @@ var MbeditorApp = function MbeditorApp() {
5084
5895
  }),
5085
5896
  React.createElement("span", { className: "statusbar-problems-count" }, problemCounts.warnings)
5086
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
+ ),
5087
5919
  !serverOnline && (function () {
5088
5920
  var dirtyCount = state.panes.reduce(function (acc, p) {
5089
5921
  return acc + p.tabs.filter(function (t) { return t.dirty; }).length;
@@ -5477,6 +6309,13 @@ var MbeditorApp = function MbeditorApp() {
5477
6309
  )
5478
6310
  ),
5479
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
+
5480
6319
  /* ── Schema modal ──────────────────────────────────────────────────── */
5481
6320
  schemaModal && React.createElement(
5482
6321
  'div',
@@ -5570,4 +6409,4 @@ var MbeditorApp = function MbeditorApp() {
5570
6409
  };
5571
6410
 
5572
6411
  window.MbeditorApp = MbeditorApp;
5573
- /* TITLE BAR */ /* SIDEBAR */ /* EDITOR AREA */ /* STATUS BAR */ /* Right-click context menu */
6412
+ /* TITLE BAR */ /* SIDEBAR */ /* EDITOR AREA */ /* STATUS BAR */ /* Right-click context menu */