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