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 +4 -4
- data/CHANGELOG.md +46 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +9 -2
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +100 -35
- data/app/assets/javascripts/mbeditor/editor_plugins.js +33 -63
- data/app/assets/javascripts/mbeditor/tab_manager.js +34 -0
- data/app/controllers/mbeditor/editors_controller.rb +14 -1
- data/app/services/mbeditor/js_syntax_check_service.rb +212 -0
- data/app/services/mbeditor/search_replace_service.rb +5 -3
- data/lib/mbeditor/configuration.rb +2 -1
- data/lib/mbeditor/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4f4e31cf2770935dab538488ed3b946b26c7bacfb8519a7d1069cd84d8064838
|
|
4
|
+
data.tar.gz: db294fee8ac1938dc03f58d0332e13c2da4c8f2a322c62a22fbcd859b8d3b17c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
var
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 () {
|
|
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
|
|
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)':
|
|
1227
|
-
'$2~(?i:HTML
|
|
1228
|
-
'$2~(?i:JS|JAVASCRIPT)':
|
|
1229
|
-
'
|
|
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
|
-
[
|
|
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
|
-
//
|
|
1375
|
-
|
|
1376
|
-
|
|
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: '
|
|
1379
|
-
'@default': { token: '
|
|
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
|
-
[
|
|
1400
|
+
[/.*/, { token: '' }]
|
|
1423
1401
|
],
|
|
1424
1402
|
|
|
1425
|
-
|
|
1426
|
-
[
|
|
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
|
-
#
|
|
301
|
-
# "everything except these",
|
|
302
|
-
#
|
|
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
|
data/lib/mbeditor/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-08-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|