mbeditor 0.12.7 → 0.12.8

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: 7e19be096529fa5f410c792379f88395494fa7783964604b2ff07bb5a93d39d8
4
- data.tar.gz: d491ca7214661784bbb9d6bc78c13da045a2da9529649d088dae1b0171d17868
3
+ metadata.gz: 4f4e31cf2770935dab538488ed3b946b26c7bacfb8519a7d1069cd84d8064838
4
+ data.tar.gz: db294fee8ac1938dc03f58d0332e13c2da4c8f2a322c62a22fbcd859b8d3b17c
5
5
  SHA512:
6
- metadata.gz: 99bbd0a1dd19d7191563ea1586f5c38f9b68d0b70319f412be5439fb1588148b135aed1b8a20b66023129065ea84d71880d80054d6500a8ff1f2b83879f941c0
7
- data.tar.gz: de0e4dfbb928584209ff4d5f1ace4a0bdb5c3b56a20c9a695c3c4770e2ccfd3a5063d9274f450706e13cabf4edd6ce2fdc3313b8477c6cecd28432eabc877246
6
+ metadata.gz: d3d8769779acc2c71a535c2d324385f182937a117218471f42dccac8ebe10c0e0741b46da229d3f388c70d8f10c27e510ae25c0eb6e8507dc7967c6e694c46d6
7
+ data.tar.gz: 130c85228b63ab45775d542086a7bb3104515cf07ecf15e88c8c23e8fe79f42207c9c1ab1f2f3ee16bf611a712c599335d5074e2060d1da90ac37dc52b833b09
data/CHANGELOG.md CHANGED
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.8] - 2026-08-07
11
+
12
+ ### Added
13
+ - **Untitled scratch tabs.** The tab bar's "+" now opens an in-memory
14
+ `Untitled-N` buffer (VS Code-style) instead of prompting to create a file on
15
+ disk. Nothing is written anywhere until you save, at which point a save-as
16
+ prompt asks for a workspace-relative path and the tab converts to a real
17
+ file. Scratch tabs are skipped by Save All (each needs its own prompt) and
18
+ are not persisted across reloads.
19
+
20
+ ### Fixed
21
+ - **"was updated externally" fired after your own saves.** A successful save
22
+ never refreshed the external-change baseline (the save-time grace window
23
+ skipped the very fetch that would have), so once you edited the file again,
24
+ the next save of *any* file compared new disk content against the stale
25
+ pre-save baseline and raised the banner. Saves now update the baseline
26
+ directly, the check re-reads live tab state instead of a snapshot taken
27
+ before its fetch, and — since the files_changed push only ever announces
28
+ mbeditor's own writes — the push-triggered check is now scoped to the pushed
29
+ paths instead of re-fetching every open tab on every save. The manual
30
+ Refresh Workspace button still checks everything.
31
+ - **Virtual tabs no longer poll git.** Changelog/untitled/diff tabs were
32
+ fetching git line-diff every 10 s and git/file history on focus — guaranteed
33
+ no-ops, now skipped, along with persistent-undo tracking for paths that have
34
+ no file behind them.
35
+ - **git-tier search returned nothing, instantly, on hosts with older git.** The
36
+ exclusion pathspecs added in 0.12.6 produced a pathspec list of nothing but
37
+ `:(exclude)` entries. Newer git reads that as "everything except these";
38
+ older git refuses it ("fatal: There is nothing to exclude from"), exits 128,
39
+ and — with stderr discarded — search silently returned empty. The list is
40
+ now anchored with a `.` pathspec, which every git version accepts.
41
+
42
+ ### Added
43
+ - **Babel-based scope lint for JS/JSX, surfaced as warnings on save.** On top
44
+ of the existing babel syntax check (host mini_racer + babel-standalone), the
45
+ saved file is now traversed for identifier references that bind to nothing:
46
+ not to any scope in the file, not to a top-level declaration in any other
47
+ workspace JS file (Sprockets concatenates them into one scope), not to a
48
+ known `window.X` global, and not to the browser/React/hook names. Each one
49
+ gets a warning marker — `'name' is not defined in any reachable scope` — in
50
+ the editor and Problems panel. Also warns on bindings that are only assigned
51
+ inside a `useEffect`/`useLayoutEffect` callback but read during render
52
+ (undefined on first render). Report-only; `config.js_scope_lint = false`
53
+ disables it. Requires a babel-standalone new enough to expose
54
+ `Babel.packages` (7.9+); older bundles degrade to the syntax check alone.
55
+
10
56
  ## [0.12.7] - 2026-08-06
11
57
 
12
58
  ### Fixed
@@ -603,7 +603,11 @@ var EditorPanel = function EditorPanel(_ref) {
603
603
  CollaborationService.ensureRoom(tab.path);
604
604
  collabActiveRef.current = _collabActive;
605
605
 
606
- if (!_collabActive && typeof HistoryService !== 'undefined') {
606
+ // untitled://, mbeditor:// and friends have no file to persist history
607
+ // against — tracking them just produces doomed /file_history requests.
608
+ var _virtualPath = (tab.path || '').indexOf('://') >= 0;
609
+
610
+ if (!_collabActive && !_virtualPath && typeof HistoryService !== 'undefined') {
607
611
  var _histBranch = EditorStore.getState().gitBranch || '';
608
612
  if (_histBranch) {
609
613
  if (_reusingModel) {
@@ -894,7 +898,7 @@ var EditorPanel = function EditorPanel(_ref) {
894
898
  // Phase 2: background undo-history replay.
895
899
  // Only run for newly-created models (reused models already have their undo stack).
896
900
  var _phase2CleanupFn = null;
897
- if (!_collabActive && !_reusingModel && typeof HistoryService !== 'undefined') {
901
+ if (!_collabActive && !_virtualPath && !_reusingModel && typeof HistoryService !== 'undefined') {
898
902
  var _phase2Branch = EditorStore.getState().gitBranch || '';
899
903
  var _phase2Path = tab.path;
900
904
  var _phase2Content = tab.content || '';
@@ -1410,7 +1414,10 @@ var EditorPanel = function EditorPanel(_ref) {
1410
1414
  var GIT_LINE_POLL_MS = 10000;
1411
1415
 
1412
1416
  useEffect(function () {
1417
+ // Virtual tabs (untitled://, mbeditor://, diff views) have no file behind
1418
+ // them — polling git for one is a guaranteed-404 request every 10s.
1413
1419
  if (!gitAvailable || !tab.path || tab.isDiff || tab.isCombinedDiff) return;
1420
+ if (tab.path.indexOf('://') >= 0) return;
1414
1421
 
1415
1422
  var cancelled = false;
1416
1423
 
@@ -744,6 +744,21 @@ var MbeditorApp = function MbeditorApp() {
744
744
  // buffer flags every dirty tab, which is just the definition of "dirty".
745
745
  var lastDiskContentRef = useRef({});
746
746
 
747
+ // Every successful save must go through this. Besides the 3.5s grace window
748
+ // that stops the external-change check racing our own write, a save defines
749
+ // the new on-disk truth — so it must also refresh the external-change
750
+ // baseline. The check's own fetch is skipped inside the grace window, so
751
+ // without this the baseline stayed at the *pre-save* disk content and the
752
+ // next files_changed push reported our own save as an external edit on any
753
+ // tab the user had started editing again.
754
+ function noteLocalSave(path, content) {
755
+ recentSavesRef.current[path] = Date.now();
756
+ setTimeout(function () { delete recentSavesRef.current[path]; }, 3500);
757
+ if (typeof content === 'string') {
758
+ lastDiskContentRef.current[path] = content.replace(/\r\n/g, '\n');
759
+ }
760
+ }
761
+
747
762
  // ── Draft backup helpers ─────────────────────────────────────────────────
748
763
  var draftWriteTimerRef = useRef({});
749
764
  var serverOnlineRef = useRef(true);
@@ -1319,7 +1334,7 @@ var MbeditorApp = function MbeditorApp() {
1319
1334
  return {
1320
1335
  id: p.id,
1321
1336
  activeTabId: p.activeTabId,
1322
- tabs: p.tabs.filter(function (t) { return !t.isCombinedDiff && !t.isModelGraph; }).map(function (t) {
1337
+ tabs: p.tabs.filter(function (t) { return !t.isCombinedDiff && !t.isModelGraph && !t.isUntitled; }).map(function (t) {
1323
1338
  return {
1324
1339
  id: t.id, path: t.path, name: t.name, dirty: t.dirty, viewState: t.viewState,
1325
1340
  isSettings: !!t.isSettings, isPreview: !!t.isPreview, previewFor: t.previewFor || null,
@@ -1658,7 +1673,7 @@ var MbeditorApp = function MbeditorApp() {
1658
1673
  GitService.fetchStatus()["catch"](function () {});
1659
1674
  FileService.getTree().then(function (data) {
1660
1675
  setTreeData(_treeUpdater(data || []));
1661
- checkOpenTabsForExternalChanges();
1676
+ checkOpenTabsForExternalChanges(payload && payload.paths);
1662
1677
  })["catch"](function () {});
1663
1678
  if (payload && payload.paths && searchQueryRef.current && searchPanelVisibleRef.current) {
1664
1679
  payload.paths.forEach(function (p) { _pendingSearchRefreshPaths.current.add(p); });
@@ -1694,6 +1709,16 @@ var MbeditorApp = function MbeditorApp() {
1694
1709
  });
1695
1710
  if (changed) EditorStore.setState({ panes: newPanes });
1696
1711
 
1712
+ // The CRDT kept our buffer identical to what the peer just wrote, so the
1713
+ // buffer is the new on-disk truth — refresh the external-change baseline.
1714
+ var _savedTab = null;
1715
+ newPanes.forEach(function (p) {
1716
+ p.tabs.forEach(function (t) { if (t.path === path) _savedTab = t; });
1717
+ });
1718
+ if (_savedTab && typeof _savedTab.content === 'string') {
1719
+ lastDiskContentRef.current[path] = _savedTab.content.replace(/\r\n/g, '\n');
1720
+ }
1721
+
1697
1722
  // Reset the AVI clean baseline so undo past this peer's save shows dirty correctly.
1698
1723
  var _modelEntry = window.__mbeditorModels && window.__mbeditorModels[path];
1699
1724
  if (_modelEntry && _modelEntry.model && !_modelEntry.model.isDisposed()) {
@@ -1704,17 +1729,31 @@ var MbeditorApp = function MbeditorApp() {
1704
1729
  return function () { WebSocketService.offFileSaved(handleFileSaved); };
1705
1730
  }, []);
1706
1731
 
1707
- function checkOpenTabsForExternalChanges() {
1732
+ // onlyPaths: when the trigger names the files that changed (the
1733
+ // files_changed push always does — it only ever announces mbeditor's own
1734
+ // writes), restrict the check to open tabs on those paths. The old
1735
+ // behaviour re-fetched EVERY open tab on every save: N requests per save,
1736
+ // and each one another chance for a stale comparison to cry "changed
1737
+ // externally". A manual workspace refresh passes nothing and still checks
1738
+ // everything — that is the button's job.
1739
+ function checkOpenTabsForExternalChanges(onlyPaths) {
1740
+ var pathSet = null;
1741
+ if (onlyPaths && onlyPaths.length) {
1742
+ pathSet = {};
1743
+ onlyPaths.forEach(function (p) { pathSet[p] = true; });
1744
+ }
1708
1745
  var st = EditorStore.getState();
1709
1746
  var allTabs = st.panes.reduce(function (acc, p) {
1710
1747
  return acc.concat(p.tabs.map(function (t) { return { paneId: p.id, tab: t }; }));
1711
1748
  }, []);
1712
1749
  var fileTabs = allTabs.filter(function (pt) {
1713
1750
  var path = pt.tab.path || '';
1751
+ if (pathSet && !pathSet[path]) return false;
1714
1752
  return path &&
1715
1753
  !path.startsWith('mbeditor://') &&
1716
1754
  !path.startsWith('diff://') &&
1717
1755
  !path.startsWith('combined-diff://') &&
1756
+ !path.startsWith('untitled://') &&
1718
1757
  !pt.tab.isCombinedDiff &&
1719
1758
  !pt.tab.isSettings &&
1720
1759
  !pt.tab.isImage &&
@@ -1736,6 +1775,16 @@ var MbeditorApp = function MbeditorApp() {
1736
1775
  }
1737
1776
  FileService.getFile(pt.tab.path, { allowMissing: true }).then(function (data) {
1738
1777
  if (!data || typeof data.content !== 'string') return;
1778
+ // Re-read the tab: the snapshot above predates the fetch, and edits or
1779
+ // a save that landed meanwhile would make a stale comparison here
1780
+ // report phantom external changes.
1781
+ var liveState = EditorStore.getState();
1782
+ var livePane = liveState.panes.find(function (p) { return p.id === pt.paneId; });
1783
+ var liveTab = livePane && livePane.tabs.find(function (t) { return t.id === pt.tab.id; });
1784
+ if (!liveTab || liveTab.path !== pt.tab.path) return;
1785
+ var savedAgain = recentSavesRef.current[pt.tab.path];
1786
+ if (savedAgain && Date.now() - savedAgain < 3000) return;
1787
+ pt = { paneId: pt.paneId, tab: liveTab };
1739
1788
  var serverNorm = data.content.replace(/\r\n/g, '\n');
1740
1789
  var tabNorm = (pt.tab.content || '').replace(/\r\n/g, '\n');
1741
1790
 
@@ -1933,14 +1982,26 @@ var MbeditorApp = function MbeditorApp() {
1933
1982
  }
1934
1983
 
1935
1984
  if (save) {
1985
+ if (tab.isUntitled) {
1986
+ // Save-as converts the scratch tab to a real one (closing the scratch
1987
+ // tab in the process); a cancelled prompt keeps the tab open.
1988
+ saveUntitledTab(closingPaneId, tab, { close: true })["catch"](function (err) {
1989
+ if (!(err && err.cancelled)) {
1990
+ EditorStore.setStatus("Save failed: " + (err && err.message || err), "error");
1991
+ }
1992
+ })["finally"](function () {
1993
+ setClosingTabId(null);
1994
+ setClosingPaneId(null);
1995
+ });
1996
+ return;
1997
+ }
1936
1998
  setLoading(function (prev) {
1937
1999
  return _extends({}, prev, { save: true });
1938
2000
  });
1939
2001
  EditorStore.setStatus("Saving " + tab.name + "...", "info");
1940
2002
  isSavingRef.current = true;
1941
2003
  FileService.saveFile(tab.path, tab.content).then(function () {
1942
- recentSavesRef.current[tab.path] = Date.now();
1943
- setTimeout(function() { delete recentSavesRef.current[tab.path]; }, 3500);
2004
+ noteLocalSave(tab.path, tab.content);
1944
2005
  EditorStore.setStatus("Saved", "success");
1945
2006
  SearchService.invalidate();
1946
2007
  GitService.fetchStatus();
@@ -2037,26 +2098,25 @@ var MbeditorApp = function MbeditorApp() {
2037
2098
  EditorStore.setStatus("Closed " + saved.length + " saved editor" + (saved.length === 1 ? "" : "s"), "info");
2038
2099
  };
2039
2100
 
2040
- var handleNewFileInTabDir = function handleNewFileInTabDir(paneId) {
2041
- var pane = state.panes.find(function (p) { return p.id === paneId; });
2042
- var activeForPane = pane && pane.tabs.find(function (t) { return t.id === pane.activeTabId; });
2043
- var activePath = activeForPane && activeForPane.path;
2044
- var isReal = activePath && activePath.indexOf('://') < 0 && activePath !== '__settings__';
2045
- var baseDir = isReal ? parentDir(activePath) : '';
2046
- // Make sure the explorer is visible so the inline-create row shows.
2047
- setActiveSidebarTab('explorer');
2048
- setSidebarCollapsed(false);
2049
- // Expand ancestors of the target dir.
2050
- if (baseDir) {
2051
- var parts = baseDir.split('/');
2052
- var ancestors = {};
2053
- for (var i = 1; i <= parts.length; i++) {
2054
- ancestors[parts.slice(0, i).join('/')] = true;
2101
+ // Save-as for an untitled scratch tab: ask for a workspace-relative path,
2102
+ // write it, then swap the scratch tab for a real one opened at that path.
2103
+ // Returns a promise that rejects with {cancelled: true} when the user backs
2104
+ // out, so close-flows can abort instead of discarding.
2105
+ var saveUntitledTab = function saveUntitledTab(paneId, tab, opts) {
2106
+ var input = window.prompt('Save as (path relative to workspace root):', tab.name + '.txt');
2107
+ if (!input || !input.trim()) return Promise.reject({ cancelled: true });
2108
+ var newPath = input.trim().replace(/^\/+/, '');
2109
+ return FileService.saveFile(newPath, tab.content).then(function () {
2110
+ noteLocalSave(newPath, tab.content);
2111
+ SearchService.invalidate();
2112
+ GitService.fetchStatus();
2113
+ FileService.getTree().then(function (data) { setTreeData(_treeUpdater(data || [])); })["catch"](function () {});
2114
+ TabManager.closeTab(paneId, tab.id);
2115
+ if (!(opts && opts.close)) {
2116
+ TabManager.openTab(newPath, newPath.split('/').pop(), null, paneId);
2055
2117
  }
2056
- setExpandedDirs(function (prev) { return Object.assign({}, prev, ancestors); });
2057
- }
2058
- setPendingRename(null);
2059
- setPendingCreate({ type: 'file', parentPath: baseDir });
2118
+ EditorStore.setStatus('Saved ' + newPath, 'success');
2119
+ });
2060
2120
  };
2061
2121
 
2062
2122
  // Persist state when panes, focusedPaneId, or collapsedSections changes
@@ -2070,7 +2130,7 @@ var MbeditorApp = function MbeditorApp() {
2070
2130
  return {
2071
2131
  id: p.id,
2072
2132
  activeTabId: p.activeTabId,
2073
- tabs: p.tabs.filter(function(t) { return !t.isCombinedDiff && !t.isModelGraph; }).map(function (t) {
2133
+ tabs: p.tabs.filter(function(t) { return !t.isCombinedDiff && !t.isModelGraph && !t.isUntitled; }).map(function (t) {
2074
2134
  return {
2075
2135
  id: t.id,
2076
2136
  path: t.path,
@@ -2566,7 +2626,7 @@ var MbeditorApp = function MbeditorApp() {
2566
2626
  var setActiveEOL = _useState31e2[1];
2567
2627
 
2568
2628
  useEffect(function () {
2569
- if (!gitAvailable || !activeTab || activeTab.isDiff || activeTab.isCombinedDiff || activeTab.isCommitGraph || !activeTab.path || activeTab.path.indexOf('diff://') === 0 || activeTab.path.indexOf('combined-diff://') === 0) {
2629
+ if (!gitAvailable || !activeTab || activeTab.isDiff || activeTab.isCombinedDiff || activeTab.isCommitGraph || !activeTab.path || activeTab.path.indexOf('://') >= 0) {
2570
2630
  setActiveFileCommit(null);
2571
2631
  return;
2572
2632
  }
@@ -2711,14 +2771,20 @@ var MbeditorApp = function MbeditorApp() {
2711
2771
  };
2712
2772
 
2713
2773
  var _doSave = function _doSave(paneId, tab) {
2774
+ if (tab.isUntitled) {
2775
+ saveUntitledTab(paneId, tab)["catch"](function (err) {
2776
+ if (err && err.cancelled) return;
2777
+ EditorStore.setStatus("Save failed: " + (err && err.message || err), "error");
2778
+ });
2779
+ return;
2780
+ }
2714
2781
  setLoading(function (prev) {
2715
2782
  return _extends({}, prev, { save: true });
2716
2783
  });
2717
2784
  EditorStore.setStatus("Saving " + tab.name + "...", "info");
2718
2785
  isSavingRef.current = true;
2719
2786
  FileService.saveFile(tab.path, tab.content).then(function () {
2720
- recentSavesRef.current[tab.path] = Date.now();
2721
- setTimeout(function() { delete recentSavesRef.current[tab.path]; }, 3500);
2787
+ noteLocalSave(tab.path, tab.content);
2722
2788
  var newPanes = EditorStore.getState().panes.map(function (p) {
2723
2789
  if (p.id === paneId) {
2724
2790
  return _extends({}, p, { tabs: p.tabs.map(function (t) {
@@ -2796,8 +2862,7 @@ var MbeditorApp = function MbeditorApp() {
2796
2862
  if (!tab) { dismissPendingReload(reload); return; }
2797
2863
  isSavingRef.current = true;
2798
2864
  FileService.saveFile(tab.path, tab.content).then(function () {
2799
- recentSavesRef.current[tab.path] = Date.now();
2800
- setTimeout(function() { delete recentSavesRef.current[tab.path]; }, 3500);
2865
+ noteLocalSave(tab.path, tab.content);
2801
2866
  EditorStore.setState({
2802
2867
  panes: EditorStore.getState().panes.map(function (p) {
2803
2868
  if (p.id !== reload.paneId) return p;
@@ -2848,7 +2913,9 @@ var MbeditorApp = function MbeditorApp() {
2848
2913
  var dirtyTabs = state.panes.flatMap(function (p) {
2849
2914
  return p.tabs;
2850
2915
  }).filter(function (t) {
2851
- return t.dirty;
2916
+ // Untitled scratch tabs need a save-as prompt each — Ctrl+S them
2917
+ // individually; bulk-save skips them rather than stacking prompts.
2918
+ return t.dirty && !t.isUntitled;
2852
2919
  });
2853
2920
  if (dirtyTabs.length === 0) return;
2854
2921
 
@@ -2861,10 +2928,8 @@ var MbeditorApp = function MbeditorApp() {
2861
2928
  return FileService.saveFile(tab.path, tab.content);
2862
2929
  });
2863
2930
  Promise.all(promises).then(function () {
2864
- var now = Date.now();
2865
2931
  dirtyTabs.forEach(function(tab) {
2866
- recentSavesRef.current[tab.path] = now;
2867
- setTimeout(function() { delete recentSavesRef.current[tab.path]; }, 3500);
2932
+ noteLocalSave(tab.path, tab.content);
2868
2933
  });
2869
2934
  var newPanes = EditorStore.getState().panes.map(function (p) {
2870
2935
  return _extends({}, p, { tabs: p.tabs.map(function (t) {
@@ -4024,7 +4089,7 @@ var MbeditorApp = function MbeditorApp() {
4024
4089
  onCloseOthers: function (id) { handleCloseOtherTabs(paneId, id); },
4025
4090
  onCloseSaved: function () { handleCloseSavedTabs(paneId); },
4026
4091
  onCloseAll: function () { handleCloseEditorsInGroup(paneId); },
4027
- onNewFile: function () { handleNewFileInTabDir(paneId); }
4092
+ onNewFile: function () { TabManager.openUntitledTab(paneId); }
4028
4093
  });
4029
4094
  };
4030
4095
 
@@ -1220,13 +1220,24 @@
1220
1220
  // Single-line comments
1221
1221
  [/#.*$/, 'comment'],
1222
1222
 
1223
- // Heredoc start — capture the terminator word; route to specialized state by delimiter name
1223
+ // Heredoc start — capture the terminator word. A tag naming a
1224
+ // language hands the body to Monaco's own tokenizer for that
1225
+ // language via nextEmbedded, so <<~JS is highlighted as real
1226
+ // JavaScript, not an imitation.
1224
1227
  [/<<[-~]?(['"]?)(\w+)\1/, {
1225
1228
  cases: {
1226
- '$2~(?i:SQL)': { token: 'string.heredoc.delimiter', next: '@heredocSQL.$2' },
1227
- '$2~(?i:HTML?)': { token: 'string.heredoc.delimiter', next: '@heredocHTML.$2' },
1228
- '$2~(?i:JS|JAVASCRIPT)': { token: 'string.heredoc.delimiter', next: '@heredocJS.$2' },
1229
- '@default': { token: 'string.heredoc.delimiter', next: '@heredoc.$2' }
1229
+ '$2~(?i:SQL)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'sql' },
1230
+ '$2~(?i:HTML?|ERB)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'html' },
1231
+ '$2~(?i:JS|JAVASCRIPT)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'javascript' },
1232
+ '$2~(?i:CSS)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'css' },
1233
+ '$2~(?i:SCSS)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'scss' },
1234
+ '$2~(?i:JSON)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'json' },
1235
+ '$2~(?i:XML|SVG)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'xml' },
1236
+ '$2~(?i:YAML|YML)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'yaml' },
1237
+ '$2~(?i:SH|BASH|SHELL)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'shell' },
1238
+ '$2~(?i:GRAPHQL|GQL)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'graphql' },
1239
+ '$2~(?i:RUBY)': { token: 'string.heredoc.delimiter', next: '@heredocEmbedded.$2', nextEmbedded: 'ruby' },
1240
+ '@default': { token: 'string.heredoc.delimiter', next: '@heredoc.$2' }
1230
1241
  }
1231
1242
  }],
1232
1243
 
@@ -1360,9 +1371,12 @@
1360
1371
  [/\s+/, '']
1361
1372
  ],
1362
1373
 
1363
- // Generic heredoc — all content is string.heredoc
1374
+ // Generic heredoc — all content is string.heredoc. The terminator may
1375
+ // be indented: that is the whole point of <<~ (and <<-), and the old
1376
+ // /^(\w+)$/ rule missed it, leaving the rest of the file painted as a
1377
+ // string.
1364
1378
  heredoc: [
1365
- [/^(\w+)\s*$/, {
1379
+ [/^\s*(\w+)\s*$/, {
1366
1380
  cases: {
1367
1381
  '$1==$S2': { token: 'string.heredoc.delimiter', next: '@pop' },
1368
1382
  '@default': 'string.heredoc'
@@ -1371,67 +1385,23 @@
1371
1385
  [/.+/, 'string.heredoc']
1372
1386
  ],
1373
1387
 
1374
- // SQL heredoc keyword/string/number/comment tokenization
1375
- heredocSQL: [
1376
- [/^(\w+)\s*$/, {
1388
+ // Language-tagged heredoc body. While embedded, Monarch only consults
1389
+ // this state to find the leaving rule; everything before it is
1390
+ // tokenized by the embedded language. @rematch + switchTo hands the
1391
+ // terminator line to @heredocEnd so it gets the delimiter colour
1392
+ // rather than being re-lexed as Ruby.
1393
+ heredocEmbedded: [
1394
+ [/^\s*(\w+)\s*$/, {
1377
1395
  cases: {
1378
- '$1==$S2': { token: 'string.heredoc.delimiter', next: '@pop' },
1379
- '@default': { token: '@rematch', next: '@heredocSQLLine' }
1380
- }
1381
- }],
1382
- [/.+/, { token: '@rematch', next: '@heredocSQLLine' }]
1383
- ],
1384
-
1385
- heredocSQLLine: [
1386
- [/--.*$/, { token: 'comment.sql', next: '@pop' }],
1387
- [/'[^']*'/, 'string.sql'],
1388
- [/\b\d+(?:\.\d+)?\b/, 'number.sql'],
1389
- [/\b(?:SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|GROUP|ORDER|BY|HAVING|LIMIT|OFFSET|CREATE|DROP|ALTER|TABLE|INDEX|INTO|VALUES|SET|AS|AND|OR|NOT|NULL|IS|IN|LIKE|BETWEEN|DISTINCT|COUNT|SUM|AVG|MIN|MAX)\b/i, 'keyword.sql'],
1390
- [/[^\s\w'"-]+/, 'string.heredoc'],
1391
- [/\w+/, 'string.heredoc'],
1392
- [/$/, { token: '', next: '@pop' }]
1393
- ],
1394
-
1395
- // HTML heredoc — tag/attribute tokenization
1396
- heredocHTML: [
1397
- [/^(\w+)\s*$/, {
1398
- cases: {
1399
- '$1==$S2': { token: 'string.heredoc.delimiter', next: '@pop' },
1400
- '@default': { token: '@rematch', next: '@heredocHTMLLine' }
1401
- }
1402
- }],
1403
- [/.+/, { token: '@rematch', next: '@heredocHTMLLine' }]
1404
- ],
1405
-
1406
- heredocHTMLLine: [
1407
- [/<\/?[a-zA-Z][a-zA-Z0-9]*/, 'tag.html'],
1408
- [/[a-zA-Z_:][a-zA-Z0-9_:\-\.]*(?=\s*=)/, 'attribute.name.html'],
1409
- [/\/?>/, 'tag.html'],
1410
- [/[^<>]+/, 'string.heredoc'],
1411
- [/$/, { token: '', next: '@pop' }]
1412
- ],
1413
-
1414
- // JS heredoc — keyword/string/number/comment tokenization
1415
- heredocJS: [
1416
- [/^(\w+)\s*$/, {
1417
- cases: {
1418
- '$1==$S2': { token: 'string.heredoc.delimiter', next: '@pop' },
1419
- '@default': { token: '@rematch', next: '@heredocJSLine' }
1396
+ '$1==$S2': { token: '@rematch', switchTo: '@heredocEnd.$S2', nextEmbedded: '@pop' },
1397
+ '@default': { token: '' }
1420
1398
  }
1421
1399
  }],
1422
- [/.+/, { token: '@rematch', next: '@heredocJSLine' }]
1400
+ [/.*/, { token: '' }]
1423
1401
  ],
1424
1402
 
1425
- heredocJSLine: [
1426
- [/\/\/.*$/, { token: 'comment', next: '@pop' }],
1427
- [/"(?:[^"\\]|\\.)*"/, 'string'],
1428
- [/'(?:[^'\\]|\\.)*'/, 'string'],
1429
- [/`(?:[^`\\]|\\.)*`/, 'string'],
1430
- [/\b\d+(?:\.\d+)?\b/, 'number'],
1431
- [/\b(?:var|let|const|function|return|if|else|for|while|do|switch|case|break|continue|new|delete|typeof|instanceof|in|of|class|extends|import|export|default|null|undefined|true|false|this|super|async|await|try|catch|finally|throw|void|yield)\b/, 'keyword'],
1432
- [/[^\s\w'"`;\/]+/, 'string.heredoc'],
1433
- [/\w+/, 'string.heredoc'],
1434
- [/$/, { token: '', next: '@pop' }]
1403
+ heredocEnd: [
1404
+ [/^\s*\w+\s*$/, { token: 'string.heredoc.delimiter', next: '@pop' }]
1435
1405
  ],
1436
1406
 
1437
1407
  // %w[] %W[] word arrays
@@ -650,8 +650,42 @@ var TabManager = (function () {
650
650
  _updateTab(paneId, path, { gotoLine: null, gotoCol: null });
651
651
  }
652
652
 
653
+ // VS Code-style scratch buffer: a tab with no file behind it. Nothing is
654
+ // written anywhere until the user saves, at which point the save flow asks
655
+ // for a real path and converts the tab.
656
+ function openUntitledTab(forcePaneId) {
657
+ var state = EditorStore.getState();
658
+ var paneId = forcePaneId || state.focusedPaneId;
659
+ var pane = state.panes.find(function (p) { return p.id === paneId; });
660
+ if (!pane) return;
661
+
662
+ var used = {};
663
+ state.panes.forEach(function (p) {
664
+ p.tabs.forEach(function (t) { if (t.isUntitled) used[t.name] = true; });
665
+ });
666
+ var n = 1;
667
+ while (used['Untitled-' + n]) n++;
668
+ var name = 'Untitled-' + n;
669
+ var path = 'untitled://' + name;
670
+
671
+ var newTab = {
672
+ id: path, path: path, name: name,
673
+ dirty: false, content: '', cleanContent: '',
674
+ viewState: null, isUntitled: true, loading: false,
675
+ externalContentVersion: 1
676
+ };
677
+ var newPanes = state.panes.map(function (p) {
678
+ if (p.id === paneId) {
679
+ return Object.assign({}, p, { tabs: p.tabs.concat(newTab), activeTabId: path });
680
+ }
681
+ return p;
682
+ });
683
+ EditorStore.setState({ panes: newPanes, focusedPaneId: paneId, activeTabId: path });
684
+ }
685
+
653
686
  return {
654
687
  openTab: openTab,
688
+ openUntitledTab: openUntitledTab,
655
689
  getRecentFiles: getRecentFiles,
656
690
  openDiffTab: openDiffTab,
657
691
  openCombinedDiffTab: openCombinedDiffTab,
@@ -931,7 +931,20 @@ module Mbeditor
931
931
  startLine: line, startCol: col, endLine: line, endCol: col + 1
932
932
  }]
933
933
  else
934
- []
934
+ # Only when the file parses: the scope lint would re-hit the same
935
+ # parse error and report nothing useful on top of the marker above.
936
+ JsSyntaxCheckService.scope_lint(workspace_root, code).map do |w|
937
+ line = w["line"] || 1
938
+ col = (w["column"] || 0) + 1
939
+ {
940
+ severity: "warning",
941
+ copName: "BabelScope",
942
+ correctable: false,
943
+ message: "[babel] #{w['message']}",
944
+ startLine: line, startCol: col,
945
+ endLine: line, endCol: col + [w["name"].to_s.length, 1].max
946
+ }
947
+ end
935
948
  end
936
949
  return render json: { markers: markers }
937
950
  end
@@ -14,6 +14,153 @@ module Mbeditor
14
14
  # V8 heap grows across transforms; recreate the context periodically.
15
15
  MAX_CHECKS_PER_CONTEXT = 50
16
16
  BABEL_ASSET_CANDIDATES = %w[babel.min.js babel.js babel-standalone.js babel-standalone.min.js].freeze
17
+ MAX_SCOPE_FINDINGS = 50
18
+
19
+ # Names defined by the browser rather than by any workspace file. ES
20
+ # builtins (Array, Promise, ...) are NOT listed: babel's own
21
+ # Scope#hasBinding already knows them, so this only needs the DOM layer.
22
+ BROWSER_GLOBALS = %w[
23
+ window document navigator location history screen console
24
+ alert confirm prompt getComputedStyle matchMedia scrollTo scrollBy
25
+ innerWidth innerHeight devicePixelRatio
26
+ fetch Headers Request Response XMLHttpRequest WebSocket EventSource
27
+ FormData URL URLSearchParams Blob File FileReader FileList DataTransfer
28
+ AbortController AbortSignal DOMParser XMLSerializer
29
+ setTimeout setInterval clearTimeout clearInterval
30
+ requestAnimationFrame cancelAnimationFrame requestIdleCallback cancelIdleCallback
31
+ queueMicrotask structuredClone atob btoa
32
+ localStorage sessionStorage indexedDB crypto performance
33
+ Event CustomEvent KeyboardEvent MouseEvent TouchEvent ErrorEvent
34
+ MessageEvent PopStateEvent StorageEvent ProgressEvent ClipboardEvent
35
+ MutationObserver ResizeObserver IntersectionObserver
36
+ Node NodeList Element HTMLElement SVGElement Image Audio Option
37
+ CSS customElements
38
+ ].freeze
39
+
40
+ # The React UMD globals plus the bare hook aliases host apps conventionally
41
+ # pull out of React at the top of a Sprockets bundle.
42
+ REACT_GLOBALS = %w[
43
+ React ReactDOM PropTypes
44
+ useState useEffect useLayoutEffect useRef useMemo useCallback useContext
45
+ useReducer useId useTransition useDeferredValue useSyncExternalStore
46
+ useImperativeHandle useDebugValue
47
+ ].freeze
48
+
49
+ # Installed into the V8 context alongside babel-standalone. collect()
50
+ # returns a file's top-level declaration names (Sprockets concatenates
51
+ # every file into one scope, so these are the cross-file globals). lint()
52
+ # reports references babel can bind to no scope and no whitelist entry,
53
+ # plus bindings that are only ever assigned inside a
54
+ # useEffect/useLayoutEffect callback but read during render.
55
+ LINT_HELPERS_JS = <<~'JS'
56
+ (function () {
57
+ if (globalThis.__mbLint) return;
58
+ if (typeof Babel === "undefined" || !Babel.packages || !Babel.packages.parser || !Babel.packages.traverse) return;
59
+ var parser = Babel.packages.parser;
60
+ var traverse = Babel.packages.traverse["default"] || Babel.packages.traverse;
61
+
62
+ function parse(source) {
63
+ return parser.parse(source, { sourceType: "script", plugins: ["jsx"], errorRecovery: true });
64
+ }
65
+
66
+ function bindNames(node, out) {
67
+ if (!node) return;
68
+ switch (node.type) {
69
+ case "Identifier": out.push(node.name); break;
70
+ case "ObjectPattern": node.properties.forEach(function (p) { bindNames(p.value || p.argument, out); }); break;
71
+ case "ArrayPattern": node.elements.forEach(function (el) { bindNames(el, out); }); break;
72
+ case "AssignmentPattern": bindNames(node.left, out); break;
73
+ case "RestElement": bindNames(node.argument, out); break;
74
+ }
75
+ }
76
+
77
+ // Is this path inside the callback argument of useEffect/useLayoutEffect?
78
+ function insideEffectCallback(path) {
79
+ var fn = path.getFunctionParent();
80
+ while (fn) {
81
+ var parent = fn.parentPath;
82
+ if (parent && parent.isCallExpression() && parent.node.arguments[0] === fn.node) {
83
+ var callee = parent.node.callee;
84
+ var name = callee.type === "Identifier" ? callee.name
85
+ : (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier"
86
+ ? callee.property.name : null);
87
+ if (name === "useEffect" || name === "useLayoutEffect") return true;
88
+ }
89
+ fn = fn.getFunctionParent();
90
+ }
91
+ return false;
92
+ }
93
+
94
+ globalThis.__mbLint = {
95
+ collect: function (source) {
96
+ var names = [];
97
+ var ast;
98
+ try { ast = parse(source); } catch (e) { return names; }
99
+ ast.program.body.forEach(function (node) {
100
+ if (node.type === "VariableDeclaration") {
101
+ node.declarations.forEach(function (d) { bindNames(d.id, names); });
102
+ } else if (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") {
103
+ if (node.id) names.push(node.id.name);
104
+ }
105
+ });
106
+ return names;
107
+ },
108
+
109
+ lint: function (source, whitelist, max) {
110
+ var findings = [];
111
+ var wl = {};
112
+ whitelist.forEach(function (n) { wl[n] = true; });
113
+ var ast;
114
+ try { ast = parse(source); } catch (e) { return findings; }
115
+ var seen = {};
116
+
117
+ try {
118
+ traverse(ast, {
119
+ ReferencedIdentifier: function (p) {
120
+ if (findings.length >= max) { p.stop(); return; }
121
+ var name = p.node.name;
122
+ if (p.isJSXIdentifier() && !/^[A-Z]/.test(name)) return; // <div>, <span>
123
+ if (wl[name]) return;
124
+ if (p.scope.hasBinding(name)) return; // includes ES builtins
125
+ if (p.parentPath && p.parentPath.isUnaryExpression({ operator: "typeof" })) return;
126
+ var loc = p.node.loc && p.node.loc.start;
127
+ var key = name + ":" + (loc ? loc.line : 0);
128
+ if (seen[key]) return;
129
+ seen[key] = true;
130
+ findings.push({
131
+ kind: "undeclared", name: name,
132
+ line: loc ? loc.line : 1, column: loc ? loc.column : 0,
133
+ message: "'" + name + "' is not defined in any reachable scope"
134
+ });
135
+ },
136
+
137
+ Function: function (p) {
138
+ if (findings.length >= max) { p.stop(); return; }
139
+ var bindings = p.scope.bindings;
140
+ Object.keys(bindings).forEach(function (name) {
141
+ var b = bindings[name];
142
+ if (b.kind !== "let" && b.kind !== "var") return;
143
+ if (!b.path.isVariableDeclarator() || b.path.node.init) return;
144
+ var writes = b.constantViolations || [];
145
+ if (!writes.length) return;
146
+ if (!writes.every(insideEffectCallback)) return;
147
+ var renderReads = (b.referencePaths || []).filter(function (r) { return !insideEffectCallback(r); });
148
+ if (!renderReads.length) return;
149
+ var loc = renderReads[0].node.loc && renderReads[0].node.loc.start;
150
+ findings.push({
151
+ kind: "effect", name: name,
152
+ line: loc ? loc.line : 1, column: loc ? loc.column : 0,
153
+ message: "'" + name + "' is only assigned inside an effect but read during render — undefined on first render"
154
+ });
155
+ });
156
+ }
157
+ });
158
+ } catch (e) { /* traversal blew up on odd input — report what we have */ }
159
+ return findings;
160
+ }
161
+ };
162
+ })();
163
+ JS
17
164
 
18
165
  MUTEX = Mutex.new
19
166
  private_constant :MUTEX
@@ -62,11 +209,43 @@ module Mbeditor
62
209
  end
63
210
  end
64
211
 
212
+ # Babel-based scope lint: warnings for identifier references that bind to
213
+ # no scope, no top-level declaration anywhere in the workspace's own JS
214
+ # (Sprockets: one shared scope), no known window.X global, and no
215
+ # browser/React name — the typos Monaco's TS worker misses once ambient
216
+ # globals are declared. Plus the effect-write/render-read hazard.
217
+ # Report-only; returns [] whenever anything is unavailable or fails.
218
+ def scope_lint(workspace_root, source)
219
+ return [] unless available? && Mbeditor.configuration.js_scope_lint != false
220
+
221
+ MUTEX.synchronize do
222
+ begin
223
+ ctx = context
224
+ return [] unless ctx
225
+
226
+ ctx.eval(LINT_HELPERS_JS) unless @lint_helpers_loaded
227
+ @lint_helpers_loaded = true
228
+ return [] unless ctx.eval("typeof __mbLint !== 'undefined'")
229
+
230
+ names = whitelist(ctx, workspace_root)
231
+ findings = ctx.eval("__mbLint.lint(#{source.to_json}, #{names.to_json}, #{MAX_SCOPE_FINDINGS})")
232
+
233
+ @checks_run = (@checks_run || 0) + 1
234
+ reset_context! if @checks_run >= MAX_CHECKS_PER_CONTEXT
235
+ Array(findings).select { |f| f.is_a?(Hash) }
236
+ rescue StandardError
237
+ reset_context!
238
+ []
239
+ end
240
+ end
241
+ end
242
+
65
243
  # Exposed for tests.
66
244
  def reset!
67
245
  MUTEX.synchronize do
68
246
  reset_context!
69
247
  @babel_path = :unresolved
248
+ @decl_cache = nil
70
249
  end
71
250
  end
72
251
 
@@ -91,6 +270,39 @@ module Mbeditor
91
270
  def reset_context!
92
271
  @context = nil
93
272
  @checks_run = 0
273
+ @lint_helpers_loaded = false
274
+ end
275
+
276
+ # Cross-file whitelist: every top-level declaration in the workspace's
277
+ # own JS program, every window.X-style global JsGlobalsService knows,
278
+ # plus the browser and React layers. Per-file declaration names are
279
+ # cached by content digest, so a steady-state save re-parses only the
280
+ # files that changed since the last lint.
281
+ def whitelist(ctx, workspace_root)
282
+ names = []
283
+ @decl_cache ||= {}
284
+ live = {}
285
+
286
+ program = JsProgramService.call(workspace_root.to_s)
287
+ Array(program[:files]).each do |f|
288
+ digest = f[:content].hash
289
+ entry = @decl_cache[f[:path]]
290
+ entry = { digest: digest, names: collect_names(ctx, f[:content]) } unless entry && entry[:digest] == digest
291
+ live[f[:path]] = entry
292
+ names.concat(entry[:names])
293
+ end
294
+ @decl_cache = live
295
+
296
+ globals = JsGlobalsService.call(workspace_root.to_s)
297
+ names.concat(Array(globals[:symbols]).map { |s| s[:name].to_s })
298
+
299
+ (names + BROWSER_GLOBALS + REACT_GLOBALS).uniq
300
+ end
301
+
302
+ def collect_names(ctx, source)
303
+ Array(ctx.eval("__mbLint.collect(#{source.to_json})")).grep(String)
304
+ rescue StandardError
305
+ []
94
306
  end
95
307
 
96
308
  def babel_source_path
@@ -297,9 +297,11 @@ module Mbeditor
297
297
  # Exclusions have to reach git as pathspecs, not just be dropped from
298
298
  # the results by the matcher below: otherwise git walks node_modules
299
299
  # and every other excluded tree in full before we discard the matches.
300
- # A pathspec list of nothing but :(exclude) entries means
301
- # "everything except these", which is exactly what the unscoped
302
- # search wants.
300
+ # The "." anchor is required: newer git reads a pathspec list of
301
+ # nothing but :(exclude) entries as "everything except these", but
302
+ # older git refuses it outright ("fatal: There is nothing to exclude
303
+ # from"), exits 128, and search silently returns empty.
304
+ args << "." if paths.nil? && exclusions.any?
303
305
  args += exclusions.map { |p| ":(exclude)#{p}" }
304
306
  # No LC_ALL=C: measured neutral for the -F -i default and 2.2x slower
305
307
  # for -E, and the UTF-8 locale case-folds non-ASCII correctly.
@@ -10,7 +10,7 @@ module Mbeditor
10
10
  :ruby_def_include_dirs, :related_files_custom_paths,
11
11
  :mount_path, :resilient_routing, :js_global_identifiers,
12
12
  :js_program, :js_program_exclude,
13
- :js_syntax_check, :babel_standalone_path,
13
+ :js_syntax_check, :babel_standalone_path, :js_scope_lint,
14
14
  :ruby_lsp, :ruby_lsp_command, :ruby_lsp_timeout,
15
15
  :exception_capture, :model_graph_max_models,
16
16
  :search_respect_gitignore, :ripgrep_command
@@ -84,6 +84,7 @@ module Mbeditor
84
84
  # third-party or generated JS here, e.g. "app/assets/javascripts/react".
85
85
  @js_program_exclude = %w[vendor]
86
86
  @js_syntax_check = :auto # save-time babel parse check via host mini_racer + babel-standalone; false disables
87
+ @js_scope_lint = true # save-time undeclared-identifier warnings (needs js_syntax_check active); false disables
87
88
  @babel_standalone_path = nil # explicit path to babel-standalone JS; nil auto-detects via the asset pipeline
88
89
  @ruby_lsp = :auto # use the host's ruby-lsp for Ruby definitions/hover/completion when available; false disables
89
90
  @ruby_lsp_command = nil # override the ruby-lsp launch command (String or Array); nil auto-resolves bin/ruby-lsp > gem > bundle exec
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
- VERSION = "0.12.7"
4
+ VERSION = "0.12.8"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mbeditor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.7
4
+ version: 0.12.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oliver Noonan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-06 00:00:00.000000000 Z
11
+ date: 2026-08-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails