mbeditor 0.11.0 → 0.12.1
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 +193 -0
- data/README.md +190 -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 +916 -72
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +934 -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 +365 -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 +492 -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 +37 -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
|
@@ -13,6 +13,10 @@ var _hamlLangRegistered = false;
|
|
|
13
13
|
var _erbLangRegistered = false;
|
|
14
14
|
var _jsErbLangRegistered = false;
|
|
15
15
|
|
|
16
|
+
// Backend marker severity -> monaco.MarkerSeverity. Literals rather than a
|
|
17
|
+
// reference to window.monaco, which does not exist when this file is parsed.
|
|
18
|
+
var MARKER_SEVERITY = { hint: 1, info: 2, warning: 4, error: 8 };
|
|
19
|
+
|
|
16
20
|
var EditorPanel = function EditorPanel(_ref) {
|
|
17
21
|
var tab = _ref.tab;
|
|
18
22
|
var paneId = _ref.paneId;
|
|
@@ -41,6 +45,22 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
41
45
|
var conflictBlocksRef = React.useRef([]);
|
|
42
46
|
var aviBaseRef = useRef(0);
|
|
43
47
|
var aviMaxRef = useRef(0);
|
|
48
|
+
// True while this file participates in collaborative editing (a live Yjs room).
|
|
49
|
+
// When set, the persistent-undo machinery (HistoryService + Phase-2 model swap)
|
|
50
|
+
// is suspended and undo is routed through the room's local Yjs UndoManager.
|
|
51
|
+
var collabActiveRef = useRef(false);
|
|
52
|
+
|
|
53
|
+
// Collaboration becomes available when a peer joins, which can be at any point
|
|
54
|
+
// in the session. This flips with that transition and is a dependency of the
|
|
55
|
+
// editor-creation effect below, so an already-open tab rebuilds and joins the
|
|
56
|
+
// room then — reusing the persistent model, exactly like reopening.
|
|
57
|
+
var _collabReadyState = useState(function () {
|
|
58
|
+
return typeof CollaborationService !== 'undefined' &&
|
|
59
|
+
typeof CollaborationService.isAvailable === 'function' &&
|
|
60
|
+
CollaborationService.isAvailable();
|
|
61
|
+
});
|
|
62
|
+
var collabReady = _collabReadyState[0];
|
|
63
|
+
var setCollabReady = _collabReadyState[1];
|
|
44
64
|
|
|
45
65
|
var _conflictState = React.useState(0);
|
|
46
66
|
var conflictCount = _conflictState[0];
|
|
@@ -189,6 +209,18 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
189
209
|
return null;
|
|
190
210
|
};
|
|
191
211
|
|
|
212
|
+
// Watch for collaboration becoming available after the editor already mounted.
|
|
213
|
+
// Event-driven, not polled: a peer can join at any point in the session, so a
|
|
214
|
+
// bounded poll would either miss a late arrival or burn a timer per open tab
|
|
215
|
+
// forever. CollaborationService fires only on a real transition.
|
|
216
|
+
useEffect(function () {
|
|
217
|
+
if (typeof CollaborationService === 'undefined' ||
|
|
218
|
+
typeof CollaborationService.onAvailabilityChange !== 'function') return;
|
|
219
|
+
return CollaborationService.onAvailabilityChange(function (available) {
|
|
220
|
+
setCollabReady(available);
|
|
221
|
+
});
|
|
222
|
+
}, []);
|
|
223
|
+
|
|
192
224
|
useEffect(function () {
|
|
193
225
|
if (tab.isPreview) return;
|
|
194
226
|
if (!monacoReady || !editorRef.current || !window.monaco) return;
|
|
@@ -560,7 +592,17 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
560
592
|
_modelEntry = window.__mbeditorModels[tab.path];
|
|
561
593
|
}
|
|
562
594
|
|
|
563
|
-
|
|
595
|
+
// Collaboration: open the per-file Yjs room when cable is available and this
|
|
596
|
+
// is a real, editable file. When active, persistent-undo (HistoryService) and
|
|
597
|
+
// the Phase-2 background model-swap replay are suspended for this file — the
|
|
598
|
+
// model swap would detach a live binding and corrupt the shared document.
|
|
599
|
+
var _collabActive = typeof CollaborationService !== 'undefined' &&
|
|
600
|
+
!tab.truncated && !tab.fileNotFound &&
|
|
601
|
+
CollaborationService.isEnabledFor(tab.path) &&
|
|
602
|
+
CollaborationService.ensureRoom(tab.path);
|
|
603
|
+
collabActiveRef.current = _collabActive;
|
|
604
|
+
|
|
605
|
+
if (!_collabActive && typeof HistoryService !== 'undefined') {
|
|
564
606
|
var _histBranch = EditorStore.getState().gitBranch || '';
|
|
565
607
|
if (_histBranch) {
|
|
566
608
|
if (_reusingModel) {
|
|
@@ -668,6 +710,30 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
668
710
|
// can identify which file they are operating on without needing React state.
|
|
669
711
|
if (modelObj) modelObj._mbeditorPath = tab.path;
|
|
670
712
|
|
|
713
|
+
if (_collabActive) {
|
|
714
|
+
// Attach this editor to the file's Yjs room. onSeeded rebaselines the
|
|
715
|
+
// clean-state once the binding has set the model, so seeding / late-join
|
|
716
|
+
// does not spuriously mark the tab dirty.
|
|
717
|
+
CollaborationService.bindEditor(tab.path, editor, modelObj, {
|
|
718
|
+
onSeeded: function () {
|
|
719
|
+
var m = editor.getModel();
|
|
720
|
+
if (!m) return;
|
|
721
|
+
var v = m.getValue();
|
|
722
|
+
var avi = m.getAlternativeVersionId();
|
|
723
|
+
aviBaseRef.current = avi;
|
|
724
|
+
aviMaxRef.current = avi;
|
|
725
|
+
var _e = window.__mbeditorModels && window.__mbeditorModels[tab.path];
|
|
726
|
+
if (_e) _e.cleanVersionId = avi;
|
|
727
|
+
latestContentRef.current = v;
|
|
728
|
+
lastAppliedExternalVersionRef.current = Math.max(
|
|
729
|
+
lastAppliedExternalVersionRef.current, tab.externalContentVersion || 0
|
|
730
|
+
);
|
|
731
|
+
EditorStore.setState({ canUndo: false, canRedo: false });
|
|
732
|
+
TabManager.markClean(paneId, tab.id, v);
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
671
737
|
// Run only the test under the cursor. Registered as an editor action so it
|
|
672
738
|
// gets a context-menu entry and a keybinding without another toolbar button;
|
|
673
739
|
// the flask button stays whole-file.
|
|
@@ -683,15 +749,11 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
683
749
|
}
|
|
684
750
|
});
|
|
685
751
|
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
run: function() {
|
|
692
|
-
if (onFormatRef.current) onFormatRef.current();
|
|
693
|
-
}
|
|
694
|
-
});
|
|
752
|
+
// No custom "Format Document" action: Ruby now has a real formatting
|
|
753
|
+
// provider, so Monaco contributes that context-menu entry itself, and a
|
|
754
|
+
// second one of our own showed up as a duplicate. Languages with no
|
|
755
|
+
// provider keep reaching formatting through the toolbar button.
|
|
756
|
+
var formatActionDisposable = null;
|
|
695
757
|
|
|
696
758
|
// Ctrl/Cmd+P → quick-open. Bound at the Monaco level (not just the window
|
|
697
759
|
// listener) so the editor intercepts the key while it has focus; otherwise
|
|
@@ -701,6 +763,16 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
701
763
|
EditorStore.setState({ isQuickOpenVisible: true });
|
|
702
764
|
});
|
|
703
765
|
|
|
766
|
+
// PageUp/PageDown cycle between the current cursor position and where the
|
|
767
|
+
// cursor was before the last jump (go-to-definition, search result, etc.),
|
|
768
|
+
// replacing the default page-scroll behaviour.
|
|
769
|
+
editor.addCommand(window.monaco.KeyCode.PageUp, function() {
|
|
770
|
+
TabManager.toggleJumpOrigin();
|
|
771
|
+
});
|
|
772
|
+
editor.addCommand(window.monaco.KeyCode.PageDown, function() {
|
|
773
|
+
TabManager.toggleJumpOrigin();
|
|
774
|
+
});
|
|
775
|
+
|
|
704
776
|
var editorPluginDisposable = null;
|
|
705
777
|
if (window.MbeditorEditorPlugins && window.MbeditorEditorPlugins.attachEditorFeatures) {
|
|
706
778
|
editorPluginDisposable = window.MbeditorEditorPlugins.attachEditorFeatures(editor, language);
|
|
@@ -767,7 +839,7 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
767
839
|
// a listener left on the old model would silently stop tracking edits.
|
|
768
840
|
var _attachContentListener = function (model) {
|
|
769
841
|
return model.onDidChangeContent(function (e) {
|
|
770
|
-
if (typeof HistoryService !== 'undefined') {
|
|
842
|
+
if (!_collabActive && typeof HistoryService !== 'undefined') {
|
|
771
843
|
HistoryService.recordOps(tab.path, e.changes);
|
|
772
844
|
}
|
|
773
845
|
var currentAvi = model.getAlternativeVersionId();
|
|
@@ -818,7 +890,7 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
818
890
|
// Phase 2: background undo-history replay.
|
|
819
891
|
// Only run for newly-created models (reused models already have their undo stack).
|
|
820
892
|
var _phase2CleanupFn = null;
|
|
821
|
-
if (!_reusingModel && typeof HistoryService !== 'undefined') {
|
|
893
|
+
if (!_collabActive && !_reusingModel && typeof HistoryService !== 'undefined') {
|
|
822
894
|
var _phase2Branch = EditorStore.getState().gitBranch || '';
|
|
823
895
|
var _phase2Path = tab.path;
|
|
824
896
|
var _phase2Content = tab.content || '';
|
|
@@ -980,18 +1052,19 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
980
1052
|
window.__mbeditorActiveEditor = null;
|
|
981
1053
|
}
|
|
982
1054
|
if (editorPluginDisposable) editorPluginDisposable.dispose();
|
|
983
|
-
formatActionDisposable.dispose();
|
|
1055
|
+
if (formatActionDisposable) formatActionDisposable.dispose();
|
|
984
1056
|
runTestAtCursorDisposable.dispose();
|
|
985
1057
|
columnSelectDisposable.dispose();
|
|
986
1058
|
contentDisposable.dispose();
|
|
987
1059
|
EditorStore.setState({ canUndo: false, canRedo: false });
|
|
988
1060
|
if (_phase2CleanupFn) _phase2CleanupFn();
|
|
1061
|
+
if (_collabActive) CollaborationService.unbindEditor(tab.path);
|
|
989
1062
|
// Detach the model before disposing the editor so the model (and its undo
|
|
990
1063
|
// history) survives for when the user returns to this tab.
|
|
991
1064
|
editor.setModel(null);
|
|
992
1065
|
editor.dispose();
|
|
993
1066
|
};
|
|
994
|
-
}, [tab.id, tab.isPreview, monacoReady]); // re-run on tab switch or when
|
|
1067
|
+
}, [tab.id, tab.isPreview, monacoReady, collabReady]); // re-run on tab switch, when Monaco becomes ready, or when collaboration becomes available
|
|
995
1068
|
|
|
996
1069
|
// Listen for external content changes (e.g. after Format/Load)
|
|
997
1070
|
// Only applies when externalContentVersion advances — prevents stale typing-originated
|
|
@@ -1003,6 +1076,14 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
1003
1076
|
var extVersion = tab.externalContentVersion || 0;
|
|
1004
1077
|
if (extVersion <= lastAppliedExternalVersionRef.current) return;
|
|
1005
1078
|
|
|
1079
|
+
// For a collaboration late-join the shared document is authoritative; applying
|
|
1080
|
+
// the disk content would clobber it. Mark this version consumed and skip.
|
|
1081
|
+
if (collabActiveRef.current && typeof CollaborationService !== 'undefined' &&
|
|
1082
|
+
CollaborationService.consumesDiskLoad(tab.path)) {
|
|
1083
|
+
lastAppliedExternalVersionRef.current = extVersion;
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1006
1087
|
lastAppliedExternalVersionRef.current = extVersion;
|
|
1007
1088
|
latestContentRef.current = tab.content; // keep ref in sync for onDidChangeContent closure
|
|
1008
1089
|
|
|
@@ -1232,16 +1313,25 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
1232
1313
|
var model = monacoRef.current.getModel();
|
|
1233
1314
|
if (model) {
|
|
1234
1315
|
var monacoMarkers = markers.map(function (m) {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1316
|
+
// Backend severities map 1:1 onto MarkerSeverity. Grading them
|
|
1317
|
+
// properly is what stops RuboCop's convention offenses — the bulk of
|
|
1318
|
+
// any lint run — from rendering as a wall of yellow warnings.
|
|
1319
|
+
var sev = MARKER_SEVERITY[m.severity];
|
|
1238
1320
|
return {
|
|
1239
|
-
severity: sev,
|
|
1321
|
+
severity: sev != null ? sev : window.monaco.MarkerSeverity.Warning,
|
|
1240
1322
|
// RuboCop-sourced markers keep the 'rubocop' source so the quick-fix
|
|
1241
1323
|
// code action provider offers a lightbulb; other sources (e.g. Prism
|
|
1242
1324
|
// syntax errors from ruby-lsp) correctly get none.
|
|
1243
1325
|
source: m.source || 'rubocop',
|
|
1244
|
-
code
|
|
1326
|
+
// An object code renders the cop name as a link to its docs page.
|
|
1327
|
+
// Readers must cope with both shapes — see codeValue() in
|
|
1328
|
+
// editor_plugins.js.
|
|
1329
|
+
code: m.codeHref
|
|
1330
|
+
? { value: m.copName || '', target: window.monaco.Uri.parse(m.codeHref) }
|
|
1331
|
+
: (m.copName || ''),
|
|
1332
|
+
// Fades the range instead of squiggling it: dead code reads as
|
|
1333
|
+
// absent rather than as a problem.
|
|
1334
|
+
tags: m.unnecessary ? [window.monaco.MarkerTag.Unnecessary] : undefined,
|
|
1245
1335
|
message: m.message,
|
|
1246
1336
|
startLineNumber: m.startLine,
|
|
1247
1337
|
startColumn: m.startCol,
|
|
@@ -1255,6 +1345,17 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
1255
1345
|
model._mbeditorCorrectableCops = new Set(
|
|
1256
1346
|
markers.filter(function(m) { return m.correctable && m.copName; }).map(function(m) { return m.copName; })
|
|
1257
1347
|
);
|
|
1348
|
+
// ruby-lsp ships the complete edits for each fix inside the diagnostic,
|
|
1349
|
+
// so the code-action provider can apply one without any request at all.
|
|
1350
|
+
// They ride a side map rather than the marker because setModelMarkers
|
|
1351
|
+
// normalises marker objects and drops unknown properties.
|
|
1352
|
+
var fixes = {};
|
|
1353
|
+
markers.forEach(function (m) {
|
|
1354
|
+
if (m.fixes && m.fixes.length) {
|
|
1355
|
+
fixes[MbeditorEditorPlugins.markerFixKey(m)] = m.fixes;
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
model._mbeditorFixes = fixes;
|
|
1258
1359
|
}
|
|
1259
1360
|
}
|
|
1260
1361
|
}, [markers, tab.id]);
|
|
@@ -28,6 +28,7 @@ var FileTree = function FileTree(_ref) {
|
|
|
28
28
|
var onNodeSelect = _ref.onNodeSelect; // fn(node) — single select (also clears multi)
|
|
29
29
|
var onMultiSelect = _ref.onMultiSelect; // fn(Set<string>) — multi-select update
|
|
30
30
|
var onMove = _ref.onMove; // fn(srcPaths[], destFolderPath) — DnD move
|
|
31
|
+
var onImportFiles = _ref.onImportFiles; // fn(entries[], destFolderPath, meta) — external file drop
|
|
31
32
|
var gitFiles = _ref.gitFiles;
|
|
32
33
|
var expandedDirs = _ref.expandedDirs;
|
|
33
34
|
var onExpandedDirsChange = _ref.onExpandedDirsChange;
|
|
@@ -53,6 +54,14 @@ var FileTree = function FileTree(_ref) {
|
|
|
53
54
|
var dragOverFolder = _useStateDnD2[0];
|
|
54
55
|
var setDragOverFolder = _useStateDnD2[1];
|
|
55
56
|
|
|
57
|
+
// Target of an in-flight *external* drag: a folder path, '' for the tree
|
|
58
|
+
// root, or null when no external drag is over the tree. Kept separate from
|
|
59
|
+
// dragOverFolder so the two drop kinds highlight differently.
|
|
60
|
+
var _useStateExt = useState(null);
|
|
61
|
+
var _useStateExt2 = _slicedToArray(_useStateExt, 2);
|
|
62
|
+
var externalDragOver = _useStateExt2[0];
|
|
63
|
+
var setExternalDragOver = _useStateExt2[1];
|
|
64
|
+
|
|
56
65
|
var inlineRef = useRef(null);
|
|
57
66
|
var committedRef = useRef(false);
|
|
58
67
|
var containerRef = useRef(null);
|
|
@@ -421,6 +430,71 @@ var FileTree = function FileTree(_ref) {
|
|
|
421
430
|
var startIdx = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER);
|
|
422
431
|
var endIdx = Math.min(flatItems.length - 1, Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + BUFFER);
|
|
423
432
|
|
|
433
|
+
// collectEntries must be kicked off synchronously inside the drop handler —
|
|
434
|
+
// see the note in file_import.js.
|
|
435
|
+
var startExternalImport = function(dataTransfer, targetFolderPath) {
|
|
436
|
+
if (!onImportFiles) return;
|
|
437
|
+
FileImport.collectEntries(dataTransfer).then(function(res) {
|
|
438
|
+
if (res.entries.length > 0) onImportFiles(res.entries, targetFolderPath, res);
|
|
439
|
+
});
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
var isRowTarget = function(e) {
|
|
443
|
+
return !!(e.target && e.target.closest && e.target.closest('.tree-item'));
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// The root drop zone is the whole scrollable panel, not just .file-tree-root.
|
|
447
|
+
// That element is only as tall as its rows, so on a short tree everything
|
|
448
|
+
// below the last row looks like the explorer but belongs to the scroll
|
|
449
|
+
// parent — dropping there did nothing, which is the "can't drop into the
|
|
450
|
+
// root area" report.
|
|
451
|
+
//
|
|
452
|
+
// Attached natively rather than through React because the panel is rendered
|
|
453
|
+
// by MbeditorApp, not here. React 17 delegates from its own root, which is
|
|
454
|
+
// an ancestor of this element, so these fire before any row handler and the
|
|
455
|
+
// isRowTarget guard — not stopPropagation — is what keeps rows in charge.
|
|
456
|
+
var externalImportRef = useRef(startExternalImport);
|
|
457
|
+
externalImportRef.current = startExternalImport;
|
|
458
|
+
|
|
459
|
+
useEffect(function() {
|
|
460
|
+
if (!containerRef.current) return;
|
|
461
|
+
var panel = containerRef.current.closest('.ide-sidebar-scrollable');
|
|
462
|
+
if (!panel) return;
|
|
463
|
+
|
|
464
|
+
var HIGHLIGHT = 'drag-over-external-root';
|
|
465
|
+
var accepts = function(e) {
|
|
466
|
+
return FileImport.hasExternalFiles(e.dataTransfer) && !isRowTarget(e);
|
|
467
|
+
};
|
|
468
|
+
var over = function(e) {
|
|
469
|
+
if (!accepts(e)) return;
|
|
470
|
+
e.preventDefault();
|
|
471
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
472
|
+
panel.classList.add(HIGHLIGHT);
|
|
473
|
+
};
|
|
474
|
+
var leave = function(e) {
|
|
475
|
+
if (e.relatedTarget && panel.contains(e.relatedTarget)) return;
|
|
476
|
+
panel.classList.remove(HIGHLIGHT);
|
|
477
|
+
};
|
|
478
|
+
var drop = function(e) {
|
|
479
|
+
panel.classList.remove(HIGHLIGHT);
|
|
480
|
+
if (!accepts(e)) return;
|
|
481
|
+
e.preventDefault();
|
|
482
|
+
externalImportRef.current(e.dataTransfer, '');
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
panel.addEventListener('dragenter', over);
|
|
486
|
+
panel.addEventListener('dragover', over);
|
|
487
|
+
panel.addEventListener('dragleave', leave);
|
|
488
|
+
panel.addEventListener('drop', drop);
|
|
489
|
+
return function() {
|
|
490
|
+
panel.classList.remove(HIGHLIGHT);
|
|
491
|
+
panel.removeEventListener('dragenter', over);
|
|
492
|
+
panel.removeEventListener('dragover', over);
|
|
493
|
+
panel.removeEventListener('dragleave', leave);
|
|
494
|
+
panel.removeEventListener('drop', drop);
|
|
495
|
+
};
|
|
496
|
+
}, []);
|
|
497
|
+
|
|
424
498
|
var renderRow = function renderRow(item, idx) {
|
|
425
499
|
var indentPx = 8 + item.depth * 12;
|
|
426
500
|
|
|
@@ -439,6 +513,7 @@ var FileTree = function FileTree(_ref) {
|
|
|
439
513
|
var isOpenFile = activePath === node.path;
|
|
440
514
|
var isSelected = !!(selectedPaths && selectedPaths.has(node.path));
|
|
441
515
|
var isDragOver = isFolder && dragOverFolder === node.path;
|
|
516
|
+
var isDragOverExternal = isFolder && externalDragOver === node.path;
|
|
442
517
|
var status = getGitStatus(node.path);
|
|
443
518
|
var statusMeta = getTreeStatusMeta(status);
|
|
444
519
|
var isModified = statusMeta && (statusMeta.cssKey === 'M' || statusMeta.cssKey === 'A');
|
|
@@ -447,7 +522,8 @@ var FileTree = function FileTree(_ref) {
|
|
|
447
522
|
(isOpenFile ? ' active' : '') +
|
|
448
523
|
(isSelected ? ' selected' : '') +
|
|
449
524
|
(isModified ? ' modified' : '') +
|
|
450
|
-
(isDragOver ? ' drag-over' : '')
|
|
525
|
+
(isDragOver ? ' drag-over' : '') +
|
|
526
|
+
(isDragOverExternal ? ' drag-over-external' : '');
|
|
451
527
|
|
|
452
528
|
return React.createElement(
|
|
453
529
|
'div',
|
|
@@ -470,27 +546,60 @@ var FileTree = function FileTree(_ref) {
|
|
|
470
546
|
e.dataTransfer.setData('text/plain', JSON.stringify(srcPaths));
|
|
471
547
|
e.dataTransfer.effectAllowed = 'move';
|
|
472
548
|
},
|
|
473
|
-
|
|
549
|
+
// An element only counts as a drop target if it cancels dragenter
|
|
550
|
+
// as well as dragover. Chrome is happy with dragover alone, but
|
|
551
|
+
// Firefox and Safari are not — without this the highlight appears
|
|
552
|
+
// (dragover still runs) while the browser refuses the drop, so
|
|
553
|
+
// nothing happens on release.
|
|
554
|
+
onDragEnter: function(e) {
|
|
474
555
|
if (!isFolder) return;
|
|
475
556
|
e.preventDefault();
|
|
476
557
|
e.stopPropagation();
|
|
477
|
-
e.dataTransfer.dropEffect = 'move';
|
|
478
|
-
if (dragOverFolder !== node.path) setDragOverFolder(node.path);
|
|
479
558
|
},
|
|
480
|
-
|
|
559
|
+
onDragOver: function(e) {
|
|
560
|
+
var external = FileImport.hasExternalFiles(e.dataTransfer);
|
|
561
|
+
if (!isFolder) {
|
|
562
|
+
// Cancel the browser's default external-file action (usually
|
|
563
|
+
// navigating away to open the file) without accepting it.
|
|
564
|
+
if (external) {
|
|
565
|
+
e.preventDefault();
|
|
566
|
+
e.stopPropagation();
|
|
567
|
+
e.dataTransfer.dropEffect = 'none';
|
|
568
|
+
}
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
e.preventDefault();
|
|
572
|
+
e.stopPropagation();
|
|
573
|
+
e.dataTransfer.dropEffect = external ? 'copy' : 'move';
|
|
574
|
+
if (external) {
|
|
575
|
+
if (externalDragOver !== node.path) setExternalDragOver(node.path);
|
|
576
|
+
if (dragOverFolder !== null) setDragOverFolder(null);
|
|
577
|
+
} else if (dragOverFolder !== node.path) {
|
|
578
|
+
if (externalDragOver !== null) setExternalDragOver(null);
|
|
579
|
+
setDragOverFolder(node.path);
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
onDragLeave: function(e) {
|
|
583
|
+
if (e.currentTarget.contains(e.relatedTarget)) return;
|
|
481
584
|
if (dragOverFolder === node.path) setDragOverFolder(null);
|
|
585
|
+
if (externalDragOver === node.path) setExternalDragOver(null);
|
|
482
586
|
},
|
|
483
587
|
onDrop: function(e) {
|
|
484
588
|
e.preventDefault();
|
|
485
589
|
e.stopPropagation();
|
|
486
590
|
setDragOverFolder(null);
|
|
591
|
+
setExternalDragOver(null);
|
|
487
592
|
if (!isFolder) return;
|
|
593
|
+
if (FileImport.hasExternalFiles(e.dataTransfer)) {
|
|
594
|
+
startExternalImport(e.dataTransfer, node.path);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
488
597
|
try {
|
|
489
598
|
var srcPaths = JSON.parse(e.dataTransfer.getData('text/plain'));
|
|
490
599
|
if (onMove && srcPaths && srcPaths.length > 0) onMove(srcPaths, node.path);
|
|
491
600
|
} catch (err) {}
|
|
492
601
|
},
|
|
493
|
-
onDragEnd: function() { setDragOverFolder(null); },
|
|
602
|
+
onDragEnd: function() { setDragOverFolder(null); setExternalDragOver(null); },
|
|
494
603
|
onClick: function(e) {
|
|
495
604
|
if (e.ctrlKey || e.metaKey) {
|
|
496
605
|
if (onMultiSelect) {
|
|
@@ -568,7 +677,17 @@ var FileTree = function FileTree(_ref) {
|
|
|
568
677
|
|
|
569
678
|
return React.createElement(
|
|
570
679
|
'div',
|
|
571
|
-
{
|
|
680
|
+
{
|
|
681
|
+
className: 'file-tree file-tree-root',
|
|
682
|
+
ref: containerRef,
|
|
683
|
+
tabIndex: 0,
|
|
684
|
+
// No root drag handlers here. This element is only as tall as its rows,
|
|
685
|
+
// so it can't cover the empty space users actually aim at; the whole
|
|
686
|
+
// scrollable panel handles the root drop instead (see the effect above).
|
|
687
|
+
// A copy here too would import twice — the panel's native listener and
|
|
688
|
+
// this React one would both fire for a single drop.
|
|
689
|
+
style: { outline: 'none', padding: 0 }
|
|
690
|
+
},
|
|
572
691
|
React.createElement(
|
|
573
692
|
'div',
|
|
574
693
|
{ style: { height: totalHeight, position: 'relative' } },
|
|
@@ -595,4 +714,4 @@ var FileTreeMemo = React.memo(FileTree, function(prev, next) {
|
|
|
595
714
|
});
|
|
596
715
|
|
|
597
716
|
// Expose globally for sprockets require
|
|
598
|
-
window.FileTree = FileTreeMemo;
|
|
717
|
+
window.FileTree = FileTreeMemo;
|
|
@@ -47,6 +47,9 @@ var GitPanel = function GitPanel(_ref) {
|
|
|
47
47
|
unpushedCommits.forEach(function (c) { if (c && c.hash) localHashes[c.hash] = true; });
|
|
48
48
|
|
|
49
49
|
var branchBaseRef = gitInfo && gitInfo.branchBaseRef;
|
|
50
|
+
// The exact baseline unpushedFiles was computed against — a merge-base sha,
|
|
51
|
+
// or the upstream when this branch is itself a base branch.
|
|
52
|
+
var branchBaseSha = gitInfo && gitInfo.branchBaseSha;
|
|
50
53
|
var rawBranchCommits = gitInfo && gitInfo.branchCommits || [];
|
|
51
54
|
var branchCommits = rawBranchCommits.map(function (c) {
|
|
52
55
|
return Object.assign({}, c, { isLocal: !!localHashes[c.hash] });
|
|
@@ -469,13 +472,19 @@ var GitPanel = function GitPanel(_ref) {
|
|
|
469
472
|
? React.createElement('div', { className: 'git-hint' }, 'Compared to ', React.createElement('code', null, branchBaseRef))
|
|
470
473
|
: upstreamBranch
|
|
471
474
|
? React.createElement('div', { className: 'git-hint' }, 'All files changed vs ', React.createElement('code', null, upstreamBranch))
|
|
472
|
-
: React.createElement('div', { className: 'git-hint' },
|
|
475
|
+
: React.createElement('div', { className: 'git-hint git-hint-warn' },
|
|
476
|
+
'No base branch found to compare against — fetch develop, or set base_branch_candidates.'),
|
|
473
477
|
React.createElement(
|
|
474
478
|
'div',
|
|
475
479
|
{ className: 'git-list' },
|
|
476
480
|
unpushedFiles.length === 0
|
|
477
|
-
? React.createElement('div', { className: 'git-empty' },
|
|
478
|
-
|
|
481
|
+
? React.createElement('div', { className: 'git-empty' },
|
|
482
|
+
branchBaseRef ? 'No changes vs ' + branchBaseRef + '.' : 'No changes vs upstream.')
|
|
483
|
+
// branchBaseSha, not upstreamBranch: the row must diff against the
|
|
484
|
+
// same baseline the list was built from, or a file listed as
|
|
485
|
+
// changed vs develop opens a diff of the branch against itself and
|
|
486
|
+
// shows nothing.
|
|
487
|
+
: unpushedFiles.map(function (item) { return renderFileRow(item, branchBaseSha || upstreamBranch, 'HEAD', true); })
|
|
479
488
|
)
|
|
480
489
|
)
|
|
481
490
|
),
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shown when an external import lands on paths that already exist. Offers
|
|
4
|
+
// batch-level resolutions only — per-file toggles were left out deliberately
|
|
5
|
+
// so the three-way choice stays legible. Escape and click-outside both mean
|
|
6
|
+
// skip, which is the non-destructive option.
|
|
7
|
+
//
|
|
8
|
+
// Errors from the same pass render here too, so a drop that both conflicts
|
|
9
|
+
// and fails needs one dialog rather than two.
|
|
10
|
+
var ImportConflictModal = function ImportConflictModal(_ref) {
|
|
11
|
+
var conflicts = _ref.conflicts || [];
|
|
12
|
+
var errors = _ref.errors || [];
|
|
13
|
+
var onResolve = _ref.onResolve;
|
|
14
|
+
var modalRef = React.useRef(null);
|
|
15
|
+
|
|
16
|
+
React.useEffect(function () {
|
|
17
|
+
var previouslyFocused = document.activeElement;
|
|
18
|
+
var modal = modalRef.current;
|
|
19
|
+
var focusableSelector = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
20
|
+
var initialFocusable = modal && modal.querySelector(focusableSelector);
|
|
21
|
+
if (initialFocusable) initialFocusable.focus();
|
|
22
|
+
else if (modal) modal.focus();
|
|
23
|
+
|
|
24
|
+
var onKey = function (e) {
|
|
25
|
+
if (e.key === 'Escape') {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
e.stopPropagation();
|
|
28
|
+
onResolve('skip');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (e.key !== 'Tab' || !modal) return;
|
|
32
|
+
|
|
33
|
+
var focusable = modal.querySelectorAll(focusableSelector);
|
|
34
|
+
if (focusable.length === 0) {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
modal.focus();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var first = focusable[0];
|
|
41
|
+
var last = focusable[focusable.length - 1];
|
|
42
|
+
if (!modal.contains(document.activeElement)) {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
first.focus();
|
|
45
|
+
} else if (e.shiftKey && document.activeElement === first) {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
last.focus();
|
|
48
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
first.focus();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
document.addEventListener('keydown', onKey, true);
|
|
54
|
+
return function () {
|
|
55
|
+
document.removeEventListener('keydown', onKey, true);
|
|
56
|
+
if (previouslyFocused && previouslyFocused.focus && document.contains(previouslyFocused)) {
|
|
57
|
+
previouslyFocused.focus();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}, [onResolve]);
|
|
61
|
+
|
|
62
|
+
var button = function (label, mode, className) {
|
|
63
|
+
return React.createElement(
|
|
64
|
+
'button',
|
|
65
|
+
{ className: className, onClick: function () { onResolve(mode); } },
|
|
66
|
+
label
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return React.createElement(
|
|
71
|
+
'div',
|
|
72
|
+
{
|
|
73
|
+
className: 'schema-modal-overlay',
|
|
74
|
+
onClick: function () { onResolve('skip'); }
|
|
75
|
+
},
|
|
76
|
+
React.createElement(
|
|
77
|
+
'div',
|
|
78
|
+
{
|
|
79
|
+
ref: modalRef,
|
|
80
|
+
className: 'schema-modal import-conflict-modal',
|
|
81
|
+
role: 'dialog',
|
|
82
|
+
'aria-modal': 'true',
|
|
83
|
+
'aria-label': 'Resolve import conflicts',
|
|
84
|
+
tabIndex: -1,
|
|
85
|
+
onClick: function (e) { e.stopPropagation(); }
|
|
86
|
+
},
|
|
87
|
+
React.createElement(
|
|
88
|
+
'div', { className: 'schema-modal-header' },
|
|
89
|
+
React.createElement(
|
|
90
|
+
'div', { className: 'schema-modal-title' },
|
|
91
|
+
conflicts.length + ' file' + (conflicts.length === 1 ? '' : 's') + ' already exist' +
|
|
92
|
+
(conflicts.length === 1 ? 's' : '')
|
|
93
|
+
)
|
|
94
|
+
),
|
|
95
|
+
React.createElement(
|
|
96
|
+
'div', { className: 'schema-modal-body' },
|
|
97
|
+
React.createElement(
|
|
98
|
+
'ul', { className: 'import-conflict-list' },
|
|
99
|
+
conflicts.map(function (c) {
|
|
100
|
+
return React.createElement('li', { key: c.path, title: c.path }, c.path);
|
|
101
|
+
})
|
|
102
|
+
),
|
|
103
|
+
errors.length > 0 && React.createElement(
|
|
104
|
+
'div', { className: 'import-conflict-errors' },
|
|
105
|
+
React.createElement('div', { className: 'import-conflict-errors-title' },
|
|
106
|
+
errors.length + ' file' + (errors.length === 1 ? '' : 's') + ' could not be imported'),
|
|
107
|
+
React.createElement(
|
|
108
|
+
'ul', { className: 'import-conflict-list' },
|
|
109
|
+
errors.map(function (e) {
|
|
110
|
+
return React.createElement('li', { key: e.path, title: e.path },
|
|
111
|
+
e.path + ' — ' + e.error);
|
|
112
|
+
})
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
),
|
|
116
|
+
React.createElement(
|
|
117
|
+
'div', { className: 'import-conflict-actions' },
|
|
118
|
+
button('Skip', 'skip', 'import-conflict-btn'),
|
|
119
|
+
button('Keep both', 'rename', 'import-conflict-btn'),
|
|
120
|
+
button('Overwrite all', 'overwrite', 'import-conflict-btn import-conflict-btn-danger')
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Expose globally for sprockets require
|
|
127
|
+
window.ImportConflictModal = ImportConflictModal;
|