mbeditor 0.12.7 → 0.12.9

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: 264dfef604baf70be3fcb9984f588a93a8388fe13371cb3c8707d5ee7149d441
4
+ data.tar.gz: d584056fbb108ea55ddb98a819af73e4eabb939d659fc7d54442446b34b2c203
5
5
  SHA512:
6
- metadata.gz: 99bbd0a1dd19d7191563ea1586f5c38f9b68d0b70319f412be5439fb1588148b135aed1b8a20b66023129065ea84d71880d80054d6500a8ff1f2b83879f941c0
7
- data.tar.gz: de0e4dfbb928584209ff4d5f1ace4a0bdb5c3b56a20c9a695c3c4770e2ccfd3a5063d9274f450706e13cabf4edd6ce2fdc3313b8477c6cecd28432eabc877246
6
+ metadata.gz: 20141c5dcd65e482c4d0437c5b1a4b1431507f424dc1a31b80af5490d30b4611c2c2e050f0716a4a382c9f2b3804001127e03292cfe371b50049c6d058f3c7b0
7
+ data.tar.gz: 551f8ad767679d4b63364d2c6e9bcb267663c35bf1750a355b29ff22f7149e445511a0fd9be8aa7a279754f6c0a3ccb79a1b3941d1bec3b11792911541081616
data/CHANGELOG.md CHANGED
@@ -7,6 +7,109 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.9] - 2026-08-07
11
+
12
+ ### Added
13
+ - **Problems panel severity filter.** Three toggle chips — errors, warnings,
14
+ info — each showing its live count, filtering the list independently of the
15
+ text filter. Multi-select rather than one-of-three, so "errors and warnings
16
+ but not the convention noise" is expressible. The selection persists, and the
17
+ last active severity stays latched on so the panel can't be filtered down to
18
+ an unexplained blank.
19
+ - **Search results carry match columns.** Clicking a result now puts the cursor
20
+ at the end of the matched text instead of the start of the line, ready to
21
+ type. Columns are measured against the raw source line on the server —
22
+ the row's display text is stripped, so a column derived from it would land
23
+ short by the indent on every indented hit.
24
+
25
+ ### Changed
26
+ - **The Problems header no longer restates its counts in prose.** The severity
27
+ chips carry the same three numbers.
28
+ - **Paste re-indents instead of reformatting.** Pasting ran the pasted range
29
+ through Prettier, which reprinted the whole enclosing statement — and for a
30
+ paste that filled the document, every line of it — so pasting a snippet
31
+ rewrote code you never touched. Paste now only re-indents to the file's
32
+ indentation setting; quotes, semicolons and spacing come through exactly as
33
+ copied. The setting is renamed "Format on paste" → "Indent on paste"
34
+ (`indentOnPaste`, still on by default). Monaco ships no indentation rules for
35
+ JavaScript, which would have made the re-indent a silent no-op there, so
36
+ VS Code's JS rules are now registered. Format-on-save is unchanged and still
37
+ off by default.
38
+
39
+ ### Fixed
40
+ - **Go-to-definition on a JS symbol defined in the same file opened a junk tab
41
+ named after a number.** Models are created without an explicit URI, so Monaco
42
+ identifies each as `inmemory://model/N`; the TS worker returns that URI for
43
+ an in-file definition, and the editor opener stripped it to a path and opened
44
+ a phantom tab called "57". Only `file://` resources are treated as workspace
45
+ files now — the rest go back to Monaco, which reveals the position in the
46
+ current editor.
47
+ - **Clicking a search result sometimes landed on the wrong line.** Opening a
48
+ hit in a file that wasn't already open ran the jump against a still-empty
49
+ model: it clamped to line 1 and then cleared the pending jump, so when the
50
+ content finally arrived nothing re-triggered it. Whether it misbehaved came
51
+ down to whether the fetch beat a 50 ms timer — hence "sometimes". The jump
52
+ now waits for the content, cancels a superseded timer, and clamps to the
53
+ file's real length when a result outlives the line it pointed at.
54
+ - **Search results ignored created and deleted files.** Only saves dropped the
55
+ client-side search cache, so a search re-run after adding or removing a file
56
+ was served the stale cached page. Every structural mutation now invalidates
57
+ it and re-runs the active query.
58
+ - **Search and the git status counts lagged behind file changes.** The
59
+ live-result refresh sat behind a 2 s debounce (now 250 ms), and every save
60
+ fired the full `/git_info` fan-out — the most expensive request the editor
61
+ makes — twice over, once directly and once from the broadcast handler, which
62
+ on a dev server with a few threads queued the tree and search requests behind
63
+ it. Saves and file mutations now use the cheap `/git_status` probe, which
64
+ patches the branch and file list immediately and escalates to the fan-out
65
+ itself only when the branch actually changed.
66
+
67
+ ## [0.12.8] - 2026-08-07
68
+
69
+ ### Added
70
+ - **Untitled scratch tabs.** The tab bar's "+" now opens an in-memory
71
+ `Untitled-N` buffer (VS Code-style) instead of prompting to create a file on
72
+ disk. Nothing is written anywhere until you save, at which point a save-as
73
+ prompt asks for a workspace-relative path and the tab converts to a real
74
+ file. Scratch tabs are skipped by Save All (each needs its own prompt) and
75
+ are not persisted across reloads.
76
+
77
+ ### Fixed
78
+ - **"was updated externally" fired after your own saves.** A successful save
79
+ never refreshed the external-change baseline (the save-time grace window
80
+ skipped the very fetch that would have), so once you edited the file again,
81
+ the next save of *any* file compared new disk content against the stale
82
+ pre-save baseline and raised the banner. Saves now update the baseline
83
+ directly, the check re-reads live tab state instead of a snapshot taken
84
+ before its fetch, and — since the files_changed push only ever announces
85
+ mbeditor's own writes — the push-triggered check is now scoped to the pushed
86
+ paths instead of re-fetching every open tab on every save. The manual
87
+ Refresh Workspace button still checks everything.
88
+ - **Virtual tabs no longer poll git.** Changelog/untitled/diff tabs were
89
+ fetching git line-diff every 10 s and git/file history on focus — guaranteed
90
+ no-ops, now skipped, along with persistent-undo tracking for paths that have
91
+ no file behind them.
92
+ - **git-tier search returned nothing, instantly, on hosts with older git.** The
93
+ exclusion pathspecs added in 0.12.6 produced a pathspec list of nothing but
94
+ `:(exclude)` entries. Newer git reads that as "everything except these";
95
+ older git refuses it ("fatal: There is nothing to exclude from"), exits 128,
96
+ and — with stderr discarded — search silently returned empty. The list is
97
+ now anchored with a `.` pathspec, which every git version accepts.
98
+
99
+ ### Added
100
+ - **Babel-based scope lint for JS/JSX, surfaced as warnings on save.** On top
101
+ of the existing babel syntax check (host mini_racer + babel-standalone), the
102
+ saved file is now traversed for identifier references that bind to nothing:
103
+ not to any scope in the file, not to a top-level declaration in any other
104
+ workspace JS file (Sprockets concatenates them into one scope), not to a
105
+ known `window.X` global, and not to the browser/React/hook names. Each one
106
+ gets a warning marker — `'name' is not defined in any reachable scope` — in
107
+ the editor and Problems panel. Also warns on bindings that are only assigned
108
+ inside a `useEffect`/`useLayoutEffect` callback but read during render
109
+ (undefined on first render). Report-only; `config.js_scope_lint = false`
110
+ disables it. Requires a babel-standalone new enough to expose
111
+ `Babel.packages` (7.9+); older bundles degrade to the syntax check alone.
112
+
10
113
  ## [0.12.7] - 2026-08-06
11
114
 
12
115
  ### 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) {
@@ -643,9 +647,9 @@ var EditorPanel = function EditorPanel(_ref) {
643
647
  autoClosingBrackets: editorPrefs.autoClosingBrackets || 'always',
644
648
  autoClosingQuotes: editorPrefs.autoClosingQuotes || 'always',
645
649
  autoIndent: editorPrefs.autoIndent || 'full',
646
- // Monaco's own format-on-paste is left off: it never ran the formatter
647
- // here. attachEditorFeatures drives it from onDidPaste instead, gated on
648
- // the same editorPrefs.formatOnPaste setting.
650
+ // Monaco own format-on-paste is left off: it never ran the formatter
651
+ // here. attachEditorFeatures drives re-indent-on-paste from onDidPaste
652
+ // instead, gated on editorPrefs.indentOnPaste.
649
653
  formatOnPaste: false,
650
654
  formatOnType: editorPrefs.formatOnType === true, // off by default: on-type formatting adds per-keystroke latency on slow machines
651
655
  quickSuggestions: editorPrefs.quickSuggestions !== false,
@@ -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 || '';
@@ -1209,9 +1213,9 @@ var EditorPanel = function EditorPanel(_ref) {
1209
1213
  autoClosingBrackets: editorPrefs.autoClosingBrackets || 'always',
1210
1214
  autoClosingQuotes: editorPrefs.autoClosingQuotes || 'always',
1211
1215
  autoIndent: editorPrefs.autoIndent || 'full',
1212
- // Monaco's own format-on-paste is left off: it never ran the formatter
1213
- // here. attachEditorFeatures drives it from onDidPaste instead, gated on
1214
- // the same editorPrefs.formatOnPaste setting.
1216
+ // Monaco own format-on-paste is left off: it never ran the formatter
1217
+ // here. attachEditorFeatures drives re-indent-on-paste from onDidPaste
1218
+ // instead, gated on editorPrefs.indentOnPaste.
1215
1219
  formatOnPaste: false,
1216
1220
  formatOnType: editorPrefs.formatOnType === true, // off by default: on-type formatting adds per-keystroke latency on slow machines
1217
1221
  quickSuggestions: editorPrefs.quickSuggestions !== false,
@@ -1297,22 +1301,40 @@ var EditorPanel = function EditorPanel(_ref) {
1297
1301
  return function() { window.removeEventListener('mbeditor:focusPane', onFocusPane); };
1298
1302
  }, [paneId]);
1299
1303
 
1300
- // Jump to line if specified
1304
+ // Jump to line if specified.
1305
+ //
1306
+ // The wait for content is load-bearing, not defensive. Opening a search hit
1307
+ // in a file that wasn't already open runs this effect once while the model
1308
+ // is still empty: the jump clamped to line 1 and then cleared gotoLine, so
1309
+ // when the content finally arrived there was nothing left to re-trigger it
1310
+ // and the cursor stayed on the wrong line. Whether it misbehaved came down
1311
+ // to whether the fetch beat a 50 ms timer — hence "sometimes".
1301
1312
  useEffect(function () {
1302
- if (tab.gotoLine && monacoRef.current) {
1303
- (function () {
1304
- var editor = monacoRef.current;
1305
- setTimeout(function () {
1306
- editor.revealLineInCenter(tab.gotoLine);
1307
- editor.setPosition({ lineNumber: tab.gotoLine, column: tab.gotoCol || 1 });
1308
- editor.focus();
1309
-
1310
- TabManager.saveTabViewState(tab.id, editor.saveViewState());
1311
- TabManager.clearGotoLine(paneId, tab.path);
1312
- }, 50);
1313
- })();
1314
- }
1315
- }, [tab.gotoLine, tab.content]); // need tab.content in dep array so if it loads asynchronously, the jump happens AFTER content loads
1313
+ if (!tab.gotoLine || !monacoRef.current) return;
1314
+ if (tab.loading) return;
1315
+
1316
+ var editor = monacoRef.current;
1317
+ var timer = setTimeout(function () {
1318
+ var model = editor.getModel();
1319
+ if (!model || model.isDisposed()) return;
1320
+
1321
+ // A result can outlive the line it pointed at (the file shrank since the
1322
+ // scan). Clamp instead of asking Monaco for a line that isn't there.
1323
+ var line = Math.max(1, Math.min(tab.gotoLine, model.getLineCount()));
1324
+ var column = tab.gotoCol || 1;
1325
+ var maxColumn = model.getLineMaxColumn(line);
1326
+ if (column > maxColumn) column = maxColumn;
1327
+
1328
+ editor.revealLineInCenter(line);
1329
+ editor.setPosition({ lineNumber: line, column: column });
1330
+ editor.focus();
1331
+
1332
+ TabManager.saveTabViewState(tab.id, editor.saveViewState());
1333
+ TabManager.clearGotoLine(paneId, tab.path);
1334
+ }, 50);
1335
+
1336
+ return function () { clearTimeout(timer); };
1337
+ }, [tab.gotoLine, tab.gotoCol, tab.content, tab.loading]);
1316
1338
 
1317
1339
  // Apply RuboCop markers
1318
1340
  useEffect(function () {
@@ -1410,7 +1432,10 @@ var EditorPanel = function EditorPanel(_ref) {
1410
1432
  var GIT_LINE_POLL_MS = 10000;
1411
1433
 
1412
1434
  useEffect(function () {
1435
+ // Virtual tabs (untitled://, mbeditor://, diff views) have no file behind
1436
+ // them — polling git for one is a guaranteed-404 request every 10s.
1413
1437
  if (!gitAvailable || !tab.path || tab.isDiff || tab.isCombinedDiff) return;
1438
+ if (tab.path.indexOf('://') >= 0) return;
1414
1439
 
1415
1440
  var cancelled = false;
1416
1441
 
@@ -109,7 +109,7 @@ var DEFAULT_EDITOR_PREFS = {
109
109
  autoClosingBrackets: 'always',
110
110
  autoClosingQuotes: 'always',
111
111
  autoIndent: 'full',
112
- formatOnPaste: true,
112
+ indentOnPaste: true,
113
113
  formatOnType: false,
114
114
  formatOnSave: false,
115
115
  quickSuggestions: true,
@@ -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);
@@ -841,10 +856,21 @@ var MbeditorApp = function MbeditorApp() {
841
856
  });
842
857
  };
843
858
 
859
+ // Every structural mutation (create, delete, rename, import) funnels through
860
+ // here, so this is where the project-search cache has to be dropped. Saves
861
+ // invalidated it at their own call sites, which is why editing a file
862
+ // updated the results but adding or deleting one never did — the stale
863
+ // cached page was served for the same query. The per-path delta refresh on
864
+ // the files_changed push can't cover it either: it re-scans named files,
865
+ // and a file that just appeared or vanished isn't in the previous result set.
844
866
  var refreshProjectTree = function refreshProjectTree() {
845
867
  return FileService.getTree().then(function (data) {
846
868
  setTreeData(data || []);
847
869
  SearchService.buildIndex(data || []);
870
+ SearchService.invalidate();
871
+ if (searchQueryRef.current && searchPanelVisibleRef.current) {
872
+ _debouncedSearch(searchQueryRef.current);
873
+ }
848
874
  return data || [];
849
875
  })["catch"](function (err) {
850
876
  EditorStore.setStatus("Failed to refresh files: " + (err && err.message || "Unknown error"), "error");
@@ -1319,7 +1345,7 @@ var MbeditorApp = function MbeditorApp() {
1319
1345
  return {
1320
1346
  id: p.id,
1321
1347
  activeTabId: p.activeTabId,
1322
- tabs: p.tabs.filter(function (t) { return !t.isCombinedDiff && !t.isModelGraph; }).map(function (t) {
1348
+ tabs: p.tabs.filter(function (t) { return !t.isCombinedDiff && !t.isModelGraph && !t.isUntitled; }).map(function (t) {
1323
1349
  return {
1324
1350
  id: t.id, path: t.path, name: t.name, dirty: t.dirty, viewState: t.viewState,
1325
1351
  isSettings: !!t.isSettings, isPreview: !!t.isPreview, previewFor: t.previewFor || null,
@@ -1648,17 +1674,28 @@ var MbeditorApp = function MbeditorApp() {
1648
1674
  });
1649
1675
  });
1650
1676
  }, Promise.resolve());
1651
- }, 2000)).current;
1677
+ // 250ms, not 2s. The window only exists to coalesce the paths from a
1678
+ // burst of saves; anything longer is dead time the user spends looking at
1679
+ // stale search rows, and the server-side result cache was already dropped
1680
+ // by the same broadcast, so waiting buys nothing.
1681
+ }, 250)).current;
1652
1682
 
1653
1683
  // WebSocket push — when the server broadcasts files_changed, refresh the tree
1654
1684
  // and git status immediately (same work as the 10s poll below does).
1655
1685
  useEffect(function () {
1656
1686
  function handleFilesChanged(payload) {
1657
1687
  if (document.hidden) return;
1658
- GitService.fetchStatus()["catch"](function () {});
1688
+ // The cheap /git_status probe, not the full /git_info fan-out. This
1689
+ // fires on every save, and the fan-out is the most expensive request
1690
+ // the editor makes — on a dev server with a handful of threads it
1691
+ // queues the tree and search requests behind itself, which is what made
1692
+ // search look like it was waiting for git. fetchStatusLite patches the
1693
+ // branch and file list immediately and escalates to the fan-out on its
1694
+ // own when the branch actually changed.
1695
+ GitService.fetchStatusLite({ background: true })["catch"](function () {});
1659
1696
  FileService.getTree().then(function (data) {
1660
1697
  setTreeData(_treeUpdater(data || []));
1661
- checkOpenTabsForExternalChanges();
1698
+ checkOpenTabsForExternalChanges(payload && payload.paths);
1662
1699
  })["catch"](function () {});
1663
1700
  if (payload && payload.paths && searchQueryRef.current && searchPanelVisibleRef.current) {
1664
1701
  payload.paths.forEach(function (p) { _pendingSearchRefreshPaths.current.add(p); });
@@ -1694,6 +1731,16 @@ var MbeditorApp = function MbeditorApp() {
1694
1731
  });
1695
1732
  if (changed) EditorStore.setState({ panes: newPanes });
1696
1733
 
1734
+ // The CRDT kept our buffer identical to what the peer just wrote, so the
1735
+ // buffer is the new on-disk truth — refresh the external-change baseline.
1736
+ var _savedTab = null;
1737
+ newPanes.forEach(function (p) {
1738
+ p.tabs.forEach(function (t) { if (t.path === path) _savedTab = t; });
1739
+ });
1740
+ if (_savedTab && typeof _savedTab.content === 'string') {
1741
+ lastDiskContentRef.current[path] = _savedTab.content.replace(/\r\n/g, '\n');
1742
+ }
1743
+
1697
1744
  // Reset the AVI clean baseline so undo past this peer's save shows dirty correctly.
1698
1745
  var _modelEntry = window.__mbeditorModels && window.__mbeditorModels[path];
1699
1746
  if (_modelEntry && _modelEntry.model && !_modelEntry.model.isDisposed()) {
@@ -1704,17 +1751,31 @@ var MbeditorApp = function MbeditorApp() {
1704
1751
  return function () { WebSocketService.offFileSaved(handleFileSaved); };
1705
1752
  }, []);
1706
1753
 
1707
- function checkOpenTabsForExternalChanges() {
1754
+ // onlyPaths: when the trigger names the files that changed (the
1755
+ // files_changed push always does — it only ever announces mbeditor's own
1756
+ // writes), restrict the check to open tabs on those paths. The old
1757
+ // behaviour re-fetched EVERY open tab on every save: N requests per save,
1758
+ // and each one another chance for a stale comparison to cry "changed
1759
+ // externally". A manual workspace refresh passes nothing and still checks
1760
+ // everything — that is the button's job.
1761
+ function checkOpenTabsForExternalChanges(onlyPaths) {
1762
+ var pathSet = null;
1763
+ if (onlyPaths && onlyPaths.length) {
1764
+ pathSet = {};
1765
+ onlyPaths.forEach(function (p) { pathSet[p] = true; });
1766
+ }
1708
1767
  var st = EditorStore.getState();
1709
1768
  var allTabs = st.panes.reduce(function (acc, p) {
1710
1769
  return acc.concat(p.tabs.map(function (t) { return { paneId: p.id, tab: t }; }));
1711
1770
  }, []);
1712
1771
  var fileTabs = allTabs.filter(function (pt) {
1713
1772
  var path = pt.tab.path || '';
1773
+ if (pathSet && !pathSet[path]) return false;
1714
1774
  return path &&
1715
1775
  !path.startsWith('mbeditor://') &&
1716
1776
  !path.startsWith('diff://') &&
1717
1777
  !path.startsWith('combined-diff://') &&
1778
+ !path.startsWith('untitled://') &&
1718
1779
  !pt.tab.isCombinedDiff &&
1719
1780
  !pt.tab.isSettings &&
1720
1781
  !pt.tab.isImage &&
@@ -1736,6 +1797,16 @@ var MbeditorApp = function MbeditorApp() {
1736
1797
  }
1737
1798
  FileService.getFile(pt.tab.path, { allowMissing: true }).then(function (data) {
1738
1799
  if (!data || typeof data.content !== 'string') return;
1800
+ // Re-read the tab: the snapshot above predates the fetch, and edits or
1801
+ // a save that landed meanwhile would make a stale comparison here
1802
+ // report phantom external changes.
1803
+ var liveState = EditorStore.getState();
1804
+ var livePane = liveState.panes.find(function (p) { return p.id === pt.paneId; });
1805
+ var liveTab = livePane && livePane.tabs.find(function (t) { return t.id === pt.tab.id; });
1806
+ if (!liveTab || liveTab.path !== pt.tab.path) return;
1807
+ var savedAgain = recentSavesRef.current[pt.tab.path];
1808
+ if (savedAgain && Date.now() - savedAgain < 3000) return;
1809
+ pt = { paneId: pt.paneId, tab: liveTab };
1739
1810
  var serverNorm = data.content.replace(/\r\n/g, '\n');
1740
1811
  var tabNorm = (pt.tab.content || '').replace(/\r\n/g, '\n');
1741
1812
 
@@ -1933,17 +2004,29 @@ var MbeditorApp = function MbeditorApp() {
1933
2004
  }
1934
2005
 
1935
2006
  if (save) {
2007
+ if (tab.isUntitled) {
2008
+ // Save-as converts the scratch tab to a real one (closing the scratch
2009
+ // tab in the process); a cancelled prompt keeps the tab open.
2010
+ saveUntitledTab(closingPaneId, tab, { close: true })["catch"](function (err) {
2011
+ if (!(err && err.cancelled)) {
2012
+ EditorStore.setStatus("Save failed: " + (err && err.message || err), "error");
2013
+ }
2014
+ })["finally"](function () {
2015
+ setClosingTabId(null);
2016
+ setClosingPaneId(null);
2017
+ });
2018
+ return;
2019
+ }
1936
2020
  setLoading(function (prev) {
1937
2021
  return _extends({}, prev, { save: true });
1938
2022
  });
1939
2023
  EditorStore.setStatus("Saving " + tab.name + "...", "info");
1940
2024
  isSavingRef.current = true;
1941
2025
  FileService.saveFile(tab.path, tab.content).then(function () {
1942
- recentSavesRef.current[tab.path] = Date.now();
1943
- setTimeout(function() { delete recentSavesRef.current[tab.path]; }, 3500);
2026
+ noteLocalSave(tab.path, tab.content);
1944
2027
  EditorStore.setStatus("Saved", "success");
1945
2028
  SearchService.invalidate();
1946
- GitService.fetchStatus();
2029
+ GitService.fetchStatusLite({ background: true });
1947
2030
  // Reset the AVI clean baseline so undo past this save point shows dirty correctly.
1948
2031
  var _closeEntry = window.__mbeditorModels && window.__mbeditorModels[tab.path];
1949
2032
  if (_closeEntry && _closeEntry.model && !_closeEntry.model.isDisposed()) {
@@ -2037,26 +2120,25 @@ var MbeditorApp = function MbeditorApp() {
2037
2120
  EditorStore.setStatus("Closed " + saved.length + " saved editor" + (saved.length === 1 ? "" : "s"), "info");
2038
2121
  };
2039
2122
 
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;
2123
+ // Save-as for an untitled scratch tab: ask for a workspace-relative path,
2124
+ // write it, then swap the scratch tab for a real one opened at that path.
2125
+ // Returns a promise that rejects with {cancelled: true} when the user backs
2126
+ // out, so close-flows can abort instead of discarding.
2127
+ var saveUntitledTab = function saveUntitledTab(paneId, tab, opts) {
2128
+ var input = window.prompt('Save as (path relative to workspace root):', tab.name + '.txt');
2129
+ if (!input || !input.trim()) return Promise.reject({ cancelled: true });
2130
+ var newPath = input.trim().replace(/^\/+/, '');
2131
+ return FileService.saveFile(newPath, tab.content).then(function () {
2132
+ noteLocalSave(newPath, tab.content);
2133
+ SearchService.invalidate();
2134
+ GitService.fetchStatusLite({ background: true });
2135
+ FileService.getTree().then(function (data) { setTreeData(_treeUpdater(data || [])); })["catch"](function () {});
2136
+ TabManager.closeTab(paneId, tab.id);
2137
+ if (!(opts && opts.close)) {
2138
+ TabManager.openTab(newPath, newPath.split('/').pop(), null, paneId);
2055
2139
  }
2056
- setExpandedDirs(function (prev) { return Object.assign({}, prev, ancestors); });
2057
- }
2058
- setPendingRename(null);
2059
- setPendingCreate({ type: 'file', parentPath: baseDir });
2140
+ EditorStore.setStatus('Saved ' + newPath, 'success');
2141
+ });
2060
2142
  };
2061
2143
 
2062
2144
  // Persist state when panes, focusedPaneId, or collapsedSections changes
@@ -2070,7 +2152,7 @@ var MbeditorApp = function MbeditorApp() {
2070
2152
  return {
2071
2153
  id: p.id,
2072
2154
  activeTabId: p.activeTabId,
2073
- tabs: p.tabs.filter(function(t) { return !t.isCombinedDiff && !t.isModelGraph; }).map(function (t) {
2155
+ tabs: p.tabs.filter(function(t) { return !t.isCombinedDiff && !t.isModelGraph && !t.isUntitled; }).map(function (t) {
2074
2156
  return {
2075
2157
  id: t.id,
2076
2158
  path: t.path,
@@ -2566,7 +2648,7 @@ var MbeditorApp = function MbeditorApp() {
2566
2648
  var setActiveEOL = _useState31e2[1];
2567
2649
 
2568
2650
  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) {
2651
+ if (!gitAvailable || !activeTab || activeTab.isDiff || activeTab.isCombinedDiff || activeTab.isCommitGraph || !activeTab.path || activeTab.path.indexOf('://') >= 0) {
2570
2652
  setActiveFileCommit(null);
2571
2653
  return;
2572
2654
  }
@@ -2711,14 +2793,20 @@ var MbeditorApp = function MbeditorApp() {
2711
2793
  };
2712
2794
 
2713
2795
  var _doSave = function _doSave(paneId, tab) {
2796
+ if (tab.isUntitled) {
2797
+ saveUntitledTab(paneId, tab)["catch"](function (err) {
2798
+ if (err && err.cancelled) return;
2799
+ EditorStore.setStatus("Save failed: " + (err && err.message || err), "error");
2800
+ });
2801
+ return;
2802
+ }
2714
2803
  setLoading(function (prev) {
2715
2804
  return _extends({}, prev, { save: true });
2716
2805
  });
2717
2806
  EditorStore.setStatus("Saving " + tab.name + "...", "info");
2718
2807
  isSavingRef.current = true;
2719
2808
  FileService.saveFile(tab.path, tab.content).then(function () {
2720
- recentSavesRef.current[tab.path] = Date.now();
2721
- setTimeout(function() { delete recentSavesRef.current[tab.path]; }, 3500);
2809
+ noteLocalSave(tab.path, tab.content);
2722
2810
  var newPanes = EditorStore.getState().panes.map(function (p) {
2723
2811
  if (p.id === paneId) {
2724
2812
  return _extends({}, p, { tabs: p.tabs.map(function (t) {
@@ -2770,7 +2858,7 @@ var MbeditorApp = function MbeditorApp() {
2770
2858
  })["catch"](function () {});
2771
2859
  }
2772
2860
 
2773
- GitService.fetchStatus();
2861
+ GitService.fetchStatusLite({ background: true });
2774
2862
  })["catch"](function (err) {
2775
2863
  EditorStore.setStatus("Save failed: " + err.message, "error");
2776
2864
  })["finally"](function () {
@@ -2796,8 +2884,7 @@ var MbeditorApp = function MbeditorApp() {
2796
2884
  if (!tab) { dismissPendingReload(reload); return; }
2797
2885
  isSavingRef.current = true;
2798
2886
  FileService.saveFile(tab.path, tab.content).then(function () {
2799
- recentSavesRef.current[tab.path] = Date.now();
2800
- setTimeout(function() { delete recentSavesRef.current[tab.path]; }, 3500);
2887
+ noteLocalSave(tab.path, tab.content);
2801
2888
  EditorStore.setState({
2802
2889
  panes: EditorStore.getState().panes.map(function (p) {
2803
2890
  if (p.id !== reload.paneId) return p;
@@ -2848,7 +2935,9 @@ var MbeditorApp = function MbeditorApp() {
2848
2935
  var dirtyTabs = state.panes.flatMap(function (p) {
2849
2936
  return p.tabs;
2850
2937
  }).filter(function (t) {
2851
- return t.dirty;
2938
+ // Untitled scratch tabs need a save-as prompt each — Ctrl+S them
2939
+ // individually; bulk-save skips them rather than stacking prompts.
2940
+ return t.dirty && !t.isUntitled;
2852
2941
  });
2853
2942
  if (dirtyTabs.length === 0) return;
2854
2943
 
@@ -2861,10 +2950,8 @@ var MbeditorApp = function MbeditorApp() {
2861
2950
  return FileService.saveFile(tab.path, tab.content);
2862
2951
  });
2863
2952
  Promise.all(promises).then(function () {
2864
- var now = Date.now();
2865
2953
  dirtyTabs.forEach(function(tab) {
2866
- recentSavesRef.current[tab.path] = now;
2867
- setTimeout(function() { delete recentSavesRef.current[tab.path]; }, 3500);
2954
+ noteLocalSave(tab.path, tab.content);
2868
2955
  });
2869
2956
  var newPanes = EditorStore.getState().panes.map(function (p) {
2870
2957
  return _extends({}, p, { tabs: p.tabs.map(function (t) {
@@ -2882,7 +2969,7 @@ var MbeditorApp = function MbeditorApp() {
2882
2969
  });
2883
2970
  EditorStore.setStatus("All files saved", "success");
2884
2971
  SearchService.invalidate();
2885
- GitService.fetchStatus();
2972
+ GitService.fetchStatusLite({ background: true });
2886
2973
  })["catch"](function (err) {
2887
2974
  EditorStore.setStatus("Failed to save some files", "error");
2888
2975
  })["finally"](function () {
@@ -3807,7 +3894,7 @@ var MbeditorApp = function MbeditorApp() {
3807
3894
  EditorStore.setStatus('Created file: ' + createdName, 'success');
3808
3895
  return refreshProjectTree().then(function () {
3809
3896
  handleSelectFile(createdPath, createdName);
3810
- GitService.fetchStatus();
3897
+ GitService.fetchStatusLite({ background: true });
3811
3898
  });
3812
3899
  })["catch"](function (err) {
3813
3900
  var message = err && err.response && err.response.data && err.response.data.error || err.message;
@@ -3828,7 +3915,7 @@ var MbeditorApp = function MbeditorApp() {
3828
3915
  handleNodeSelect({ path: createdPath, name: createdPath.split('/').pop(), type: 'folder' });
3829
3916
  EditorStore.setStatus('Created folder: ' + createdPath, 'success');
3830
3917
  return refreshProjectTree().then(function () {
3831
- return GitService.fetchStatus();
3918
+ return GitService.fetchStatusLite({ background: true });
3832
3919
  });
3833
3920
  })["catch"](function (err) {
3834
3921
  var message = err && err.response && err.response.data && err.response.data.error || err.message;
@@ -3903,7 +3990,7 @@ var MbeditorApp = function MbeditorApp() {
3903
3990
  setSelectedPaths(new Set([renamedPath]));
3904
3991
  EditorStore.setStatus('Renamed to: ' + renamedPath, 'success');
3905
3992
  return refreshProjectTree().then(function () {
3906
- GitService.fetchStatus();
3993
+ GitService.fetchStatusLite({ background: true });
3907
3994
  });
3908
3995
  })["catch"](function (err) {
3909
3996
  var message = err && err.response && err.response.data && err.response.data.error || err.message;
@@ -3964,7 +4051,7 @@ var MbeditorApp = function MbeditorApp() {
3964
4051
  EditorStore.setStatus('Delete failed: ' + message, 'error');
3965
4052
  }
3966
4053
  return refreshProjectTree().then(function () {
3967
- GitService.fetchStatus();
4054
+ GitService.fetchStatusLite({ background: true });
3968
4055
  });
3969
4056
  })["finally"](function () {
3970
4057
  setLoading(function (prev) {
@@ -4024,7 +4111,7 @@ var MbeditorApp = function MbeditorApp() {
4024
4111
  onCloseOthers: function (id) { handleCloseOtherTabs(paneId, id); },
4025
4112
  onCloseSaved: function () { handleCloseSavedTabs(paneId); },
4026
4113
  onCloseAll: function () { handleCloseEditorsInGroup(paneId); },
4027
- onNewFile: function () { handleNewFileInTabDir(paneId); }
4114
+ onNewFile: function () { TabManager.openUntitledTab(paneId); }
4028
4115
  });
4029
4116
  };
4030
4117
 
@@ -4877,7 +4964,9 @@ var MbeditorApp = function MbeditorApp() {
4877
4964
  {
4878
4965
  key: i,
4879
4966
  className: "search-result-item",
4880
- onClick: (function(r) { return function() { handleSelectFile(r.file, r.file.split('/').pop(), r.line, r.col); }; })(res)
4967
+ // end_col puts the cursor just past the match, which is
4968
+ // where you want to start typing after jumping to a hit.
4969
+ onClick: (function(r) { return function() { handleSelectFile(r.file, r.file.split('/').pop(), r.line, r.end_col || r.col); }; })(res)
4881
4970
  },
4882
4971
  React.createElement("i", { className: (window.getFileIcon ? window.getFileIcon(fileName) : 'far fa-file-code') + " search-result-icon" }),
4883
4972
  React.createElement(
@@ -5485,13 +5574,13 @@ var MbeditorApp = function MbeditorApp() {
5485
5574
  )
5486
5575
  ),
5487
5576
  React.createElement(
5488
- 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Auto-format pasted code using the language formatter' },
5489
- React.createElement('span', { className: 'ide-settings-label' }, 'Format on paste'),
5577
+ 'label', { className: 'ide-settings-row ide-settings-row-check', title: 'Re-indent pasted code to match where it lands. Only leading whitespace changes — the formatter is not run.' },
5578
+ React.createElement('span', { className: 'ide-settings-label' }, 'Indent on paste'),
5490
5579
  React.createElement('input', {
5491
5580
  type: 'checkbox',
5492
5581
  className: 'ide-settings-checkbox',
5493
- checked: editorPrefs.formatOnPaste !== false,
5494
- onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { formatOnPaste: v }); }); }
5582
+ checked: editorPrefs.indentOnPaste !== false,
5583
+ onChange: function(e) { var v = e.target.checked; setEditorPrefs(function(p) { return Object.assign({}, p, { indentOnPaste: v }); }); }
5495
5584
  })
5496
5585
  ),
5497
5586
  React.createElement(