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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -0
- data/README.md +153 -3
- data/app/assets/javascripts/mbeditor/application.js +5 -0
- data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
- data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
- data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
- data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +911 -72
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
- data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
- data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
- data/app/assets/javascripts/mbeditor/file_import.js +146 -0
- data/app/assets/javascripts/mbeditor/file_service.js +52 -3
- data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
- data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
- data/app/assets/stylesheets/mbeditor/editor.css +273 -10
- data/app/channels/mbeditor/channel_authentication.rb +94 -0
- data/app/channels/mbeditor/collaboration_channel.rb +84 -0
- data/app/channels/mbeditor/editor_channel.rb +40 -1
- data/app/controllers/mbeditor/application_controller.rb +5 -1
- data/app/controllers/mbeditor/editors_controller.rb +465 -19
- data/app/controllers/mbeditor/git_controller.rb +9 -2
- data/app/services/mbeditor/availability_probe.rb +76 -17
- data/app/services/mbeditor/code_search_service.rb +23 -3
- data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
- data/app/services/mbeditor/file_import_service.rb +103 -0
- data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
- data/app/services/mbeditor/git_info_service.rb +6 -0
- data/app/services/mbeditor/git_service.rb +22 -6
- data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
- data/app/services/mbeditor/model_graph_service.rb +232 -0
- data/app/services/mbeditor/presence_registry.rb +83 -0
- data/app/services/mbeditor/ri_definition_service.rb +39 -5
- data/app/services/mbeditor/search_replace_service.rb +24 -4
- data/app/views/layouts/mbeditor/application.html.erb +2 -0
- data/lib/mbeditor/configuration.rb +33 -3
- data/lib/mbeditor/engine.rb +34 -0
- data/lib/mbeditor/exception_log.rb +84 -0
- data/lib/mbeditor/route_map.rb +5 -0
- data/lib/mbeditor/ruby_lsp_client.rb +28 -1
- data/lib/mbeditor/version.rb +1 -1
- data/lib/mbeditor.rb +1 -0
- data/vendor/assets/javascripts/yjs-collab.js +12 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
!
|
|
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
|
-
!
|
|
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
|
-
!
|
|
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
|
-
!
|
|
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
|
-
!
|
|
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
|
-
!
|
|
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
|
-
!
|
|
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
|
-
!
|
|
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
|
-
|
|
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%',
|
|
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 */
|