mbeditor 0.4.5 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75e7d28ba5664e0aeae526a954b2032c040d6ac2f20ed327bef9507a29bc3e21
4
- data.tar.gz: 34b2ba82856f0fef274049050a55e95a465b9d9bdb7e86e3a2299c30ce0e00c4
3
+ metadata.gz: a6e34c84c4c7e3a0ef9d4382dd49c29ac3bd13fdc42f8b38d50666cdd30439b6
4
+ data.tar.gz: 07a9658a8d2faa837969d7c8b05556eaba59afeb48af63b9c8482d7693805b64
5
5
  SHA512:
6
- metadata.gz: ba333da50a99c900136a52832821b7a2d939f7836b5aa05d2bdaedb259167c3c8a32f7ec3e13e854501c51a7686dd354973b3ff9a299c87af2b0e41cfaa8a649
7
- data.tar.gz: 9fd083956de7ac0c3443715ba15fe63af069822043ab9e4113b8ab9af4096e5ac582e29d8377cd63f1c681b9fbcca5097dbc7f1d20592f937df6bb208fe91988
6
+ metadata.gz: 4506a5f5c0e7d7c5b111f9e6cb665cf5d95b7a2e9be5c140948c49f3fbb3a7ee9c13e78d1924d891522131cb85d512242074202298172a10915ea57e6e48f71a
7
+ data.tar.gz: c7b06da62965639c18f0531a9459da9ddcfeb14ba29ecceadeb600aeec8eb28197657c3dcca4dec5311978fef1397dc610cc2d9f27ead2513ec2e768daceda62
data/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.1] - 2026-04-30
9
+
10
+ ### Added
11
+ - **Large file pagination** — files over 5 MB now open in read-only paginated mode (500 lines per page) instead of showing an error. A bar below the toolbar shows the current line range, total line count, and file size, with Prev/Next navigation. The backend streams only the requested line slice via `File.foreach` so arbitrarily large files never load fully into memory.
12
+ - **JSON auto pretty-print** — `.json` files are automatically formatted with 2-space indentation when opened. Invalid JSON falls back to raw display with Monaco's built-in error markers. The formatted content is set as the editor baseline so the file does not appear dirty after opening.
13
+
14
+ ## [0.5.0] - 2026-04-30
15
+
16
+ ### Added
17
+ - **Zen / focus mode** — `Cmd+Shift+Z` hides the sidebar and git panel for a distraction-free editing experience.
18
+ - **Bulk find-and-replace** — search and replace across all workspace files in one operation.
19
+ - **File content prefetch on hover** — opening a file from the tree is now instant; content is fetched while hovering the row.
20
+ - **Client-side search cache** — search results are cached client-side for 30 s and invalidated automatically on save.
21
+ - **Monaco model cache with LRU eviction** — in-memory Monaco models are capped at 15 and evicted in LRU order to keep memory use bounded.
22
+
23
+ ### Changed
24
+ - **Faster initial render** — Monaco startup is now decoupled from the React mount lifecycle, cutting time-to-first-edit.
25
+ - **Robust dirty-state tracking** — dirty state is now driven by Monaco's `alternativeVersionId`; `cleanVersionId` is reset correctly on save-on-close so re-opened tabs start clean.
26
+
27
+ ### Fixed
28
+ - ActionCable reconnect logic hardened; regression tests added to confirm websocket lifecycle log filtering still works after reconnect.
29
+ - Bulk replace: fixed a security issue and a correctness bug in the replacement pipeline; added covering tests.
30
+
8
31
  ## [0.4.5] - 2026-04-23
9
32
 
10
33
  ### Fixed
@@ -1,3 +1,4 @@
1
1
  window.SearchService = SearchService;
2
2
  window.GitService = GitService;
3
+ window.FileService = FileService;
3
4
  })(window.MbeditorRuntime.React, window.MbeditorRuntime.ReactDOM);
@@ -30,6 +30,7 @@ var EditorPanel = function EditorPanel(_ref) {
30
30
  var testLoading = _ref.testLoading;
31
31
  var testInlineVisible = _ref.testInlineVisible;
32
32
  var editorPrefs = _ref.editorPrefs || {};
33
+ var monacoReady = _ref.monacoReady !== false; // undefined means Monaco already loaded (legacy callers)
33
34
 
34
35
  var editorRef = useRef(null);
35
36
  var monacoRef = useRef(null);
@@ -85,6 +86,27 @@ var EditorPanel = function EditorPanel(_ref) {
85
86
 
86
87
  var methodsBtnRef = useRef(null);
87
88
 
89
+ // Local pagination state — initialized from tab props; updated on page navigation
90
+ var _useState17 = useState(tab.startLine || 0);
91
+ var _useState18 = _slicedToArray(_useState17, 2);
92
+ var pageStartLine = _useState18[0];
93
+ var setPageStartLine = _useState18[1];
94
+
95
+ var _useState19 = useState(tab.lineCount || 0);
96
+ var _useState20 = _slicedToArray(_useState19, 2);
97
+ var pageLineCount = _useState20[0];
98
+ var setPageLineCount = _useState20[1];
99
+
100
+ var _useState21 = useState(tab.totalLines || 0);
101
+ var _useState22 = _slicedToArray(_useState21, 2);
102
+ var pageTotalLines = _useState22[0];
103
+ var setPageTotalLines = _useState22[1];
104
+
105
+ var _useState23 = useState(tab.totalBytes || 0);
106
+ var _useState24 = _slicedToArray(_useState23, 2);
107
+ var pageTotalBytes = _useState24[0];
108
+ var setPageTotalBytes = _useState24[1];
109
+
88
110
  var onFormatRef = useRef(onFormat);
89
111
  onFormatRef.current = onFormat;
90
112
 
@@ -94,6 +116,12 @@ var EditorPanel = function EditorPanel(_ref) {
94
116
  var vimStatusRef = useRef(null);
95
117
  var vimModeObjRef = useRef(null);
96
118
 
119
+ function humanSize(bytes) {
120
+ if (bytes < 1024) return bytes + ' B';
121
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
122
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
123
+ }
124
+
97
125
  var clearTestZones = function clearTestZones(editor) {
98
126
  if (!editor) return;
99
127
  if (testZoneIdsRef.current.length === 0) return;
@@ -132,7 +160,7 @@ var EditorPanel = function EditorPanel(_ref) {
132
160
 
133
161
  useEffect(function () {
134
162
  if (tab.isPreview) return;
135
- if (!editorRef.current || !window.monaco) return;
163
+ if (!monacoReady || !editorRef.current || !window.monaco) return;
136
164
 
137
165
  if (window.MbeditorEditorPlugins && window.MbeditorEditorPlugins.registerGlobalExtensions) {
138
166
  window.MbeditorEditorPlugins.registerGlobalExtensions(window.monaco);
@@ -478,13 +506,28 @@ var EditorPanel = function EditorPanel(_ref) {
478
506
  if (_modelEntry && _modelEntry.model && !_modelEntry.model.isDisposed()) {
479
507
  modelObj = _modelEntry.model;
480
508
  _reusingModel = true;
509
+ // Update access timestamp so LRU eviction knows this model was recently used.
510
+ _modelEntry.lastAccessed = Date.now();
481
511
  // Re-apply language in case it changed (e.g. file renamed)
482
512
  if (modelObj.getLanguageId() !== language) {
483
513
  window.monaco.editor.setModelLanguage(modelObj, language);
484
514
  }
485
515
  } else {
486
- modelObj = window.monaco.editor.createModel(tab.content, language);
487
- window.__mbeditorModels[tab.path] = { model: modelObj, aviBase: null, aviMax: null };
516
+ // Evict the LRU model if the cache is at capacity before creating a new one.
517
+ TabManager.evictLruModel();
518
+
519
+ // Pretty-print JSON content before initial load
520
+ var contentForModel = tab.content;
521
+ if (language === 'json' && contentForModel) {
522
+ try {
523
+ contentForModel = JSON.stringify(JSON.parse(contentForModel), null, 2);
524
+ } catch (_) {
525
+ // invalid JSON — use raw content; Monaco will show error markers
526
+ }
527
+ }
528
+
529
+ modelObj = window.monaco.editor.createModel(contentForModel, language);
530
+ window.__mbeditorModels[tab.path] = { model: modelObj, aviBase: null, aviMax: null, lastAccessed: Date.now(), cleanVersionId: null };
488
531
  _modelEntry = window.__mbeditorModels[tab.path];
489
532
  }
490
533
 
@@ -573,6 +616,10 @@ var EditorPanel = function EditorPanel(_ref) {
573
616
 
574
617
  monacoRef.current = editor;
575
618
  window.__mbeditorActiveEditor = editor;
619
+ // Apply read-only for paginated (truncated) files
620
+ if (tab.truncated) {
621
+ editor.updateOptions({ readOnly: true });
622
+ }
576
623
  setEditorReady(true);
577
624
 
578
625
  // Stash the workspace-relative path on the model so code-action providers
@@ -645,6 +692,8 @@ var EditorPanel = function EditorPanel(_ref) {
645
692
  } else {
646
693
  aviBaseRef.current = avi;
647
694
  aviMaxRef.current = avi;
695
+ // Record the clean baseline for dirty-state tracking on initial model creation.
696
+ _modelEntry.cleanVersionId = avi;
648
697
  }
649
698
  EditorStore.setState({ canUndo: avi > aviBaseRef.current, canRedo: avi < aviMaxRef.current });
650
699
 
@@ -660,19 +709,16 @@ var EditorPanel = function EditorPanel(_ref) {
660
709
 
661
710
  var val = editor.getValue();
662
711
 
663
- // Belt-and-suspenders: when undoing, directly check if content matches the
664
- // saved clean state. This guarantees the dirty flag clears on full undo even
665
- // if the latestContentRef comparison path misses an event.
666
- if (e.isUndoing) {
667
- var _st = EditorStore.getState();
668
- var _pane = _st.panes.find(function(p) { return p.id === paneId; });
669
- var _tab = _pane && _pane.tabs.find(function(t) { return t.id === tab.id; });
670
- if (_tab && _tab.dirty) {
671
- var _cleanNorm = ((_tab.cleanContent) || '').replace(/\r\n/g, '\n');
672
- var _valNorm = val.replace(/\r\n/g, '\n');
673
- if (_valNorm === _cleanNorm) {
674
- TabManager.markClean(paneId, tab.id, val);
675
- }
712
+ // Dirty-state tracking via alternativeVersionId O(1), no string comparison.
713
+ // AVI decrements on undo so it returns to cleanVersionId after a full undo.
714
+ // Skip entirely when cleanVersionId is null file is mid-load, not yet settled.
715
+ var _entry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
716
+ var _cleanAvi = _entry && _entry.cleanVersionId;
717
+ if (_cleanAvi !== null && _cleanAvi !== undefined) {
718
+ if (currentAvi !== _cleanAvi) {
719
+ TabManager.markDirty(paneId, tab.id, val);
720
+ } else {
721
+ TabManager.markClean(paneId, tab.id, val);
676
722
  }
677
723
  }
678
724
 
@@ -734,7 +780,7 @@ var EditorPanel = function EditorPanel(_ref) {
734
780
  editor.setModel(null);
735
781
  editor.dispose();
736
782
  };
737
- }, [tab.id, tab.isPreview]); // re-run ONLY on tab switch, not on content change (Monaco handles its own content state)
783
+ }, [tab.id, tab.isPreview, monacoReady]); // re-run on tab switch or when Monaco becomes ready
738
784
 
739
785
  // Listen for external content changes (e.g. after Format/Load)
740
786
  // Only applies when externalContentVersion advances — prevents stale typing-originated
@@ -760,12 +806,29 @@ var EditorPanel = function EditorPanel(_ref) {
760
806
  if (!vNorm) {
761
807
  // If the editor is currently completely empty, treat it as an initial load.
762
808
  // setValue clears the undo stack which is correct for initial load.
763
- editor.setValue(tab.content);
809
+ // Null cleanVersionId before setValue so the synchronous onDidChangeContent
810
+ // fires during setValue and skips the dirty check (cleanVersionId is null).
811
+ var _initEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
812
+ if (_initEntry) _initEntry.cleanVersionId = null;
813
+
814
+ // Pretty-print JSON content before initial load
815
+ var contentToSet = tab.content;
816
+ var modelLang = model.getLanguageId();
817
+ if (modelLang === 'json' && contentToSet) {
818
+ try {
819
+ contentToSet = JSON.stringify(JSON.parse(contentToSet), null, 2);
820
+ } catch (_) {
821
+ // invalid JSON — use raw content; Monaco will show error markers
822
+ }
823
+ }
824
+
825
+ editor.setValue(contentToSet);
764
826
  // Reset the AVI baseline: setValue clears the undo stack so anything before
765
827
  // this point is no longer reachable. Also clear the canUndo/canRedo display.
766
828
  var newBase = model.getAlternativeVersionId();
767
829
  aviBaseRef.current = newBase;
768
830
  aviMaxRef.current = newBase;
831
+ if (_initEntry) _initEntry.cleanVersionId = newBase;
769
832
  EditorStore.setState({ canUndo: false, canRedo: false });
770
833
  } else {
771
834
  // Keep undo stack for formats or replaces by using executeEdits
@@ -908,6 +971,21 @@ var EditorPanel = function EditorPanel(_ref) {
908
971
  }
909
972
  }, [markers, tab.id]);
910
973
 
974
+ // Sync pagination state when tab changes (different file or fresh load)
975
+ useEffect(function () {
976
+ setPageStartLine(tab.startLine || 0);
977
+ setPageLineCount(tab.lineCount || 0);
978
+ setPageTotalLines(tab.totalLines || 0);
979
+ setPageTotalBytes(tab.totalBytes || 0);
980
+ }, [tab.id]);
981
+
982
+ // Apply read-only mode based on tab.truncated whenever the editor or truncated flag changes
983
+ useEffect(function () {
984
+ if (monacoRef.current) {
985
+ monacoRef.current.updateOptions({ readOnly: !!tab.truncated });
986
+ }
987
+ }, [tab.truncated, editorReady]);
988
+
911
989
  // Reset blame + test decorations when file path changes
912
990
  useEffect(function () {
913
991
  setBlameData(null);
@@ -1363,6 +1441,16 @@ var EditorPanel = function EditorPanel(_ref) {
1363
1441
  return '\u2026/' + parts.slice(-2).join('/');
1364
1442
  }
1365
1443
 
1444
+ // While Monaco is still loading, show a lightweight skeleton so the UI is
1445
+ // visible immediately without calling monaco.editor.create() too early.
1446
+ if (!monacoReady) {
1447
+ return React.createElement(
1448
+ 'div',
1449
+ { className: 'monaco-container monaco-loading-skeleton', style: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: '#888', fontSize: '13px' } },
1450
+ 'Loading editor…'
1451
+ );
1452
+ }
1453
+
1366
1454
  // Always render the same wrapper structure so the editorRef div is never
1367
1455
  // unmounted when gitAvailable changes (e.g. loaded async after workspace
1368
1456
  // call returns). The toolbar is conditionally included inside the wrapper.
@@ -1437,6 +1525,105 @@ var EditorPanel = function EditorPanel(_ref) {
1437
1525
  !editorPrefs.toolbarIconOnly && !testLoading && React.createElement('span', { className: 'ide-toolbar-label' }, 'Test')
1438
1526
  )
1439
1527
  ),
1528
+ tab.truncated && React.createElement(
1529
+ 'div',
1530
+ {
1531
+ className: 'ide-pagination-bar',
1532
+ style: {
1533
+ display: 'flex',
1534
+ alignItems: 'center',
1535
+ gap: '8px',
1536
+ padding: '4px 10px',
1537
+ background: 'var(--ide-toolbar-bg, #252526)',
1538
+ borderBottom: '1px solid var(--ide-border, #3e3e3e)',
1539
+ fontSize: '12px',
1540
+ color: 'var(--ide-toolbar-fg, #ccc)',
1541
+ flexShrink: 0,
1542
+ userSelect: 'none'
1543
+ }
1544
+ },
1545
+ React.createElement(
1546
+ 'button',
1547
+ {
1548
+ className: 'ide-icon-btn',
1549
+ style: { padding: '2px 8px', fontSize: '12px' },
1550
+ disabled: pageStartLine === 0,
1551
+ onClick: function() {
1552
+ var newStart = Math.max(0, pageStartLine - 500);
1553
+ FileService.getFileChunk(tab.path, newStart, 500).then(function(data) {
1554
+ var sl = data.start_line || 0;
1555
+ var lc = data.line_count || 0;
1556
+ var tl = data.total_lines || pageTotalLines;
1557
+ var tb = data.total_bytes || pageTotalBytes;
1558
+ setPageStartLine(sl);
1559
+ setPageLineCount(lc);
1560
+ setPageTotalLines(tl);
1561
+ setPageTotalBytes(tb);
1562
+ if (monacoRef.current) {
1563
+ monacoRef.current.setValue(data.content || '');
1564
+ var _paginatedEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
1565
+ if (_paginatedEntry) {
1566
+ var _newBase = monacoRef.current.getModel().getAlternativeVersionId();
1567
+ aviBaseRef.current = _newBase;
1568
+ aviMaxRef.current = _newBase;
1569
+ _paginatedEntry.cleanVersionId = _newBase;
1570
+ _paginatedEntry.aviBase = _newBase;
1571
+ _paginatedEntry.aviMax = _newBase;
1572
+ }
1573
+ EditorStore.setState({ canUndo: false, canRedo: false });
1574
+ monacoRef.current.updateOptions({ readOnly: true });
1575
+ }
1576
+ }).catch(function(err) {
1577
+ EditorStore.setStatus('Failed to load page: ' + (err && err.message || 'Unknown error'), 'error');
1578
+ });
1579
+ }
1580
+ },
1581
+ '← Prev'
1582
+ ),
1583
+ React.createElement(
1584
+ 'span',
1585
+ { style: { flex: 1, textAlign: 'center' } },
1586
+ 'Lines ' + (pageStartLine + 1) + '–' + (pageStartLine + pageLineCount) + ' of ' + pageTotalLines + ' (' + humanSize(pageTotalBytes) + ')'
1587
+ ),
1588
+ React.createElement(
1589
+ 'button',
1590
+ {
1591
+ className: 'ide-icon-btn',
1592
+ style: { padding: '2px 8px', fontSize: '12px' },
1593
+ disabled: pageStartLine + pageLineCount >= pageTotalLines,
1594
+ onClick: function() {
1595
+ var newStart = pageStartLine + pageLineCount;
1596
+ FileService.getFileChunk(tab.path, newStart, 500).then(function(data) {
1597
+ var sl = data.start_line || 0;
1598
+ var lc = data.line_count || 0;
1599
+ var tl = data.total_lines || pageTotalLines;
1600
+ var tb = data.total_bytes || pageTotalBytes;
1601
+ setPageStartLine(sl);
1602
+ setPageLineCount(lc);
1603
+ setPageTotalLines(tl);
1604
+ setPageTotalBytes(tb);
1605
+ if (monacoRef.current) {
1606
+ monacoRef.current.setValue(data.content || '');
1607
+ var _paginatedEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
1608
+ if (_paginatedEntry) {
1609
+ var _newBase = monacoRef.current.getModel().getAlternativeVersionId();
1610
+ aviBaseRef.current = _newBase;
1611
+ aviMaxRef.current = _newBase;
1612
+ _paginatedEntry.cleanVersionId = _newBase;
1613
+ _paginatedEntry.aviBase = _newBase;
1614
+ _paginatedEntry.aviMax = _newBase;
1615
+ }
1616
+ EditorStore.setState({ canUndo: false, canRedo: false });
1617
+ monacoRef.current.updateOptions({ readOnly: true });
1618
+ }
1619
+ }).catch(function(err) {
1620
+ EditorStore.setStatus('Failed to load page: ' + (err && err.message || 'Unknown error'), 'error');
1621
+ });
1622
+ }
1623
+ },
1624
+ 'Next →'
1625
+ )
1626
+ ),
1440
1627
  React.createElement('div', { ref: editorRef, className: 'monaco-container', style: { flex: 1, minHeight: 0 } }),
1441
1628
  methodsOpen && methodsDropdownPos && React.createElement(
1442
1629
  'div',
@@ -51,6 +51,8 @@ var FileTree = function FileTree(_ref) {
51
51
  var containerRef = useRef(null);
52
52
  var typeaheadBufferRef = useRef('');
53
53
  var typeaheadTimerRef = useRef(null);
54
+ var hoverTimerRef = useRef(null);
55
+ var hoverPathRef = useRef(null);
54
56
  // Ref that always points to the latest onNodeSelect prop, avoiding stale closures in the effect.
55
57
  var onNodeSelectRef = useRef(onNodeSelect);
56
58
  onNodeSelectRef.current = onNodeSelect;
@@ -131,6 +133,12 @@ var FileTree = function FileTree(_ref) {
131
133
  }
132
134
  }, [pendingCreate, pendingRename]);
133
135
 
136
+ // Clear any pending hover timer when the component unmounts to prevent
137
+ // a prefetch from firing against an unmounted component.
138
+ useEffect(function() {
139
+ return function() { clearTimeout(hoverTimerRef.current); };
140
+ }, []);
141
+
134
142
  var toggleFolder = function toggleFolder(path, e) {
135
143
  e.stopPropagation();
136
144
  var next = !(expandedDirs && expandedDirs[path]);
@@ -441,6 +449,20 @@ var FileTree = function FileTree(_ref) {
441
449
  onFileDoubleClick(node.path, node.name);
442
450
  }
443
451
  },
452
+ onMouseEnter: function () {
453
+ if (isFolder) return;
454
+ clearTimeout(hoverTimerRef.current);
455
+ hoverTimerRef.current = setTimeout(function () {
456
+ hoverPathRef.current = node.path;
457
+ FileService.prefetch(node.path);
458
+ }, 200);
459
+ },
460
+ onMouseLeave: function () {
461
+ if (isFolder) return;
462
+ clearTimeout(hoverTimerRef.current);
463
+ FileService.cancelPrefetch(hoverPathRef.current);
464
+ hoverPathRef.current = null;
465
+ },
444
466
  onContextMenu: function (e) {
445
467
  e.preventDefault();
446
468
  e.stopPropagation();
@@ -498,7 +520,7 @@ var FileTreeMemo = React.memo(FileTree, function(prev, next) {
498
520
  prev.activePath === next.activePath &&
499
521
  prev.selectedPaths === next.selectedPaths &&
500
522
  prev.anchorPath === next.anchorPath &&
501
- prev.gitFiles === next.gitFiles &&
523
+ JSON.stringify(prev.gitFiles) === JSON.stringify(next.gitFiles) &&
502
524
  prev.expandedDirs === next.expandedDirs &&
503
525
  prev.pendingCreate === next.pendingCreate &&
504
526
  prev.pendingRename === next.pendingRename;