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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +193 -0
  3. data/README.md +190 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +916 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +934 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +52 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +365 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +492 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  37. data/app/services/mbeditor/model_graph_service.rb +232 -0
  38. data/app/services/mbeditor/presence_registry.rb +83 -0
  39. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  40. data/app/services/mbeditor/search_replace_service.rb +24 -4
  41. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  42. data/lib/mbeditor/configuration.rb +37 -3
  43. data/lib/mbeditor/engine.rb +34 -0
  44. data/lib/mbeditor/exception_log.rb +84 -0
  45. data/lib/mbeditor/route_map.rb +5 -0
  46. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  47. data/lib/mbeditor/version.rb +1 -1
  48. data/lib/mbeditor.rb +1 -0
  49. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  50. metadata +15 -2
@@ -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
- if (typeof HistoryService !== 'undefined') {
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
- var formatActionDisposable = editor.addAction({
687
- id: 'mbeditor.formatDocument',
688
- label: 'Format Document',
689
- contextMenuGroupId: '1_modification',
690
- contextMenuOrder: 1.5,
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 Monaco becomes ready
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
- var sev = m.severity === 'error'
1236
- ? window.monaco.MarkerSeverity.Error
1237
- : window.monaco.MarkerSeverity.Warning;
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: m.copName || '',
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
- onDragOver: function(e) {
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
- onDragLeave: function() {
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
- { className: 'file-tree file-tree-root', ref: containerRef, tabIndex: 0, style: { outline: 'none', padding: 0 } },
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' }, 'No upstream branch tracked.'),
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' }, 'No changes vs upstream.')
478
- : unpushedFiles.map(function (item) { return renderFileRow(item, upstreamBranch, 'HEAD', true); })
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;