mbeditor 0.12.5 → 0.12.6

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: 021b4117178c3c82c72bd21709de8665d578557f5833b3b1b525e25ef672ce10
4
- data.tar.gz: efc3c48d714d8973b1df5614c05647e21a11856ada4591b3af4b58261e248332
3
+ metadata.gz: dffea86be286ee0bdcb216589b12264dcac613a33e59aa2828490706785e1825
4
+ data.tar.gz: f786fe449deccc7ff1971c8df15015b7ee0507b45b14a332f83e162b44facc01
5
5
  SHA512:
6
- metadata.gz: 2655b76c9b8f77dfd7c2127124eae19d9536de389e532f0da51a954adf0e5079042e72175c76a14d047788b13fa7a85193d23353f1112e75d602cc62dde3bc4b
7
- data.tar.gz: a4332fc2c00d5caf96c8f26d774892e7adf23f42b09782555cb359c60d601dc419e6b7abab44ae589f12e8804b5d43e9ce1303d9858227eb55ca3e5cb5b32f8f
6
+ metadata.gz: b1cf4fcf24d9868b1b5a284c1de2f8eb3b5032a10d8cb3a9ebb1677eed3a6ce9660e658a43f80652ec1a3edb5195aadcf01fc428fc381ed5f62618d59dde4286
7
+ data.tar.gz: 3c0c8dc6d5d2aee702d0ed1af2814e884cd204702383dafc21871ffcf2a0b8fa8e871a971d69824ea44f7831f335d3f7e8d0999c832142e5b510d43033ad10cd
data/CHANGELOG.md CHANGED
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.6] - 2026-08-05
11
+
12
+ ### Added
13
+ - **Format All.** A second toolbar button formats every open document. Each tab
14
+ is formatted independently and failures are collected rather than thrown, so
15
+ one unparseable file cannot stop the rest; the status line reports how many
16
+ were formatted, skipped and failed.
17
+ - **Format on paste actually formats.** `formatOnPaste` has been on by default
18
+ for a long time and did nothing outside Ruby: Monaco acts on a paste only
19
+ through a *range* formatting provider, and none was registered. Prettier-backed
20
+ range and document providers now cover JS/JSX, JSON, CSS/SCSS/LESS, HTML and
21
+ Markdown. A paste that fills the document is formatted as a document — so
22
+ pasting into a blank file formats the whole thing — while a paste in the middle
23
+ is formatted as a range, reprinting the smallest enclosing statement and
24
+ leaving the rest of the file byte-identical. Code copied in from a spaces
25
+ project therefore lands as tabs (or the reverse), which was previously a manual
26
+ chore that never converted cleanly.
27
+ - **Files symlinked in from outside the workspace now open.** An app that links a
28
+ shared config directory or a sibling engine into its tree could see those files
29
+ in the explorer but not open them — the tab opened and closed itself a moment
30
+ later. Symlinks are now followed the way a file manager does. Containment is
31
+ still enforced: `File.expand_path` collapses `..` before a lexical check
32
+ against the workspace root, so no request can name anything outside it, and
33
+ git operations stay strictly inside the repo.
34
+
35
+ ### Fixed
36
+ - **Search returned nothing, instantly, on some workspaces without ripgrep.** The
37
+ git-grep tier was chosen on the mere presence of a `.git` entry, with no check
38
+ that git could actually open the repo. When it cannot — dubious ownership under
39
+ Docker, a `.git` file pointing at a gitdir that moved, no git binary on the
40
+ server's `PATH` — `git grep` fails, its stderr goes to `/dev/null`, and the
41
+ result is an empty set indistinguishable from "no matches". The tier is now
42
+ gated on `git rev-parse --is-inside-work-tree`, which fails in exactly those
43
+ cases, so a bad repo falls through to plain grep. Definition lookups had the
44
+ same check and were silently broken the same way.
45
+ - **Formatting ignored the tab/space setting.** There were four copies of the
46
+ Prettier options and they had drifted: two read a `prettierTabWidth` /
47
+ `prettierUseTabs` pair that no settings screen ever wrote, frozen at two
48
+ spaces, overriding the `tabSize` / `insertSpaces` actually configured. A JSX
49
+ file in a tabs workspace came back space-indented. Indentation now comes from
50
+ the same preferences Monaco itself is given.
51
+ - **One JS file had two formatters that disagreed.** Monaco's TypeScript worker
52
+ registers its own range formatter for `javascript` and Monaco uses whichever it
53
+ finds first, so the toolbar button went through Prettier while `Shift+Alt+F`
54
+ and format-on-paste got the worker's two-space, no-semicolon output. The
55
+ worker's formatting is switched off; its completions, hovers, diagnostics and
56
+ rename are untouched.
57
+ - **"Server offline" flashed during ordinary actions.** The reachability check
58
+ counted any error without a response as a network failure, and a request the
59
+ editor aborts itself has none either. The editor cancels constantly — each
60
+ keystroke supersedes the previous search, hover and completion providers abort
61
+ when the cursor moves on, opening a file aborts its own prefetch — so two in a
62
+ row declared the server offline until the `/ping` probe a second later put it
63
+ back. Creating a file reliably triggered it. Cancellations are now ignored; a
64
+ genuinely unreachable host still marks offline and still recovers.
65
+ - **Warnings logged by the host app from mbeditor's own hooks were swallowed.**
66
+ Editor requests were silenced at `ERROR`, which hides Rails' own INFO-level
67
+ request lines but also everything a host app logs from inside
68
+ `authenticate_with` or `user_name_callback` — those procs run within the
69
+ request, so a developer debugging their own hook saw nothing and had no way to
70
+ know why. Silenced at `WARN` now, which still hides the request lines.
71
+
72
+ ### Changed
73
+ - RuboCop's output is taken as-is. Ruby indentation belongs to the project's
74
+ `.rubocop.yml`, so rewriting it in the editor fought the linter about to run
75
+ over the same file. The previous code converted the source to tabs *before*
76
+ handing it to RuboCop, which discarded it. To convert an already-open file,
77
+ use Monaco's built-in "Convert Indentation to Tabs / to Spaces" (F1).
78
+
10
79
  ## [0.12.5] - 2026-08-03
11
80
 
12
81
  ### Fixed
@@ -643,7 +643,10 @@ var EditorPanel = function EditorPanel(_ref) {
643
643
  autoClosingBrackets: editorPrefs.autoClosingBrackets || 'always',
644
644
  autoClosingQuotes: editorPrefs.autoClosingQuotes || 'always',
645
645
  autoIndent: editorPrefs.autoIndent || 'full',
646
- formatOnPaste: editorPrefs.formatOnPaste !== false,
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.
649
+ formatOnPaste: false,
647
650
  formatOnType: editorPrefs.formatOnType === true, // off by default: on-type formatting adds per-keystroke latency on slow machines
648
651
  quickSuggestions: editorPrefs.quickSuggestions !== false,
649
652
  wordBasedSuggestions: editorPrefs.wordBasedSuggestions || 'currentDocument',
@@ -1206,7 +1209,10 @@ var EditorPanel = function EditorPanel(_ref) {
1206
1209
  autoClosingBrackets: editorPrefs.autoClosingBrackets || 'always',
1207
1210
  autoClosingQuotes: editorPrefs.autoClosingQuotes || 'always',
1208
1211
  autoIndent: editorPrefs.autoIndent || 'full',
1209
- formatOnPaste: editorPrefs.formatOnPaste !== false,
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.
1215
+ formatOnPaste: false,
1210
1216
  formatOnType: editorPrefs.formatOnType === true, // off by default: on-type formatting adds per-keystroke latency on slow machines
1211
1217
  quickSuggestions: editorPrefs.quickSuggestions !== false,
1212
1218
  wordBasedSuggestions: editorPrefs.wordBasedSuggestions || 'currentDocument',
@@ -39,7 +39,52 @@ var GIT_PANEL_MIN_WIDTH = 280;
39
39
  var PANE_MIN_WIDTH_PERCENT = 20;
40
40
  var PANE_MAX_WIDTH_PERCENT = 80;
41
41
  var SIDEBAR_COLLAPSED_WIDTH = 48;
42
- var SUPPORTED_PRETTIER_EXTS = ['js', 'jsx', 'json', 'css', 'scss', 'html', 'md'];
42
+ // Extension -> Prettier parser. Limited to what the vendored plugins actually
43
+ // parse (babel, estree, html, postcss, markdown); anything outside this map
44
+ // falls through to Monaco's re-indent.
45
+ //
46
+ // One map, one options builder, one runner — there were four copies of each,
47
+ // and they had drifted: two read a `prettierTabWidth`/`prettierUseTabs` pair
48
+ // that no settings screen ever wrote, so Prettier reprinted every JS/JSX file
49
+ // at two spaces however the editor was configured.
50
+ var PRETTIER_PARSERS = {
51
+ js: 'babel', jsx: 'babel', mjs: 'babel', cjs: 'babel',
52
+ json: 'json', jsonc: 'json', json5: 'json5',
53
+ css: 'css', scss: 'scss', less: 'less',
54
+ html: 'html', vue: 'vue',
55
+ md: 'markdown', markdown: 'markdown'
56
+ };
57
+ var SUPPORTED_PRETTIER_EXTS = Object.keys(PRETTIER_PARSERS);
58
+
59
+ function prettierParserFor(path) {
60
+ return PRETTIER_PARSERS[String(path || '').split('.').pop().toLowerCase()] || null;
61
+ }
62
+
63
+ // Indentation comes from the editor's own tabSize/insertSpaces, so formatting
64
+ // produces what the user set up. `insertSpaces !== true` mirrors how
65
+ // EditorPanel configures Monaco: anything but an explicit true means tabs.
66
+ function prettierOptions(prefs, parserName, extra) {
67
+ return Object.assign({
68
+ parser: parserName,
69
+ plugins: Object.values(window.prettierPlugins || {}),
70
+ printWidth: prefs.prettierPrintWidth != null ? prefs.prettierPrintWidth : 80,
71
+ tabWidth: prefs.tabSize != null ? prefs.tabSize : 4,
72
+ useTabs: prefs.insertSpaces !== true,
73
+ semi: prefs.prettierSemi !== false,
74
+ singleQuote: !!prefs.prettierSingleQuote,
75
+ trailingComma: prefs.prettierTrailingComma || 'all',
76
+ bracketSpacing: prefs.prettierBracketSpacing !== false
77
+ }, extra || {});
78
+ }
79
+
80
+ // Format with Prettier, loading it on first use. Rejects if it cannot be
81
+ // loaded, so callers can report that rather than appear to do nothing.
82
+ function runPrettier(source, prefs, parserName, extra) {
83
+ var go = function () { return window.prettier.format(source, prettierOptions(prefs, parserName, extra)); };
84
+ if (window.prettier && window.prettierPlugins) return go();
85
+ if (window.loadPrettierPlugins) return window.loadPrettierPlugins().then(go);
86
+ return Promise.reject(new Error('Prettier is not available'));
87
+ }
43
88
 
44
89
  var DEFAULT_EDITOR_PREFS = {
45
90
  theme: 'vs-dark',
@@ -74,8 +119,6 @@ var DEFAULT_EDITOR_PREFS = {
74
119
  toolbarIconOnly: false,
75
120
  rubocopLintEnabled: true,
76
121
  prettierPrintWidth: 80,
77
- prettierTabWidth: 2,
78
- prettierUseTabs: false,
79
122
  prettierSemi: true,
80
123
  prettierSingleQuote: false,
81
124
  prettierTrailingComma: 'all',
@@ -89,28 +132,11 @@ var DEFAULT_EDITOR_PREFS = {
89
132
  branchStateRestore: true
90
133
  };
91
134
 
92
- // Detect the minimum number of leading spaces used for indentation across all
93
- // indented lines in the code. Returns 0 if no space-indented lines are found
94
- // (e.g. file already uses tabs or has no indented lines).
95
- function detectIndentWidth(code) {
96
- var min = Infinity;
97
- code.split('\n').forEach(function(line) {
98
- if (!line.trim()) return;
99
- var m = line.match(/^( +)/);
100
- if (m) min = Math.min(min, m[1].length);
101
- });
102
- return min === Infinity ? 0 : min;
103
- }
104
-
105
- // Convert leading space-based indentation to tabs using the detected unit size.
106
- function spacesToTabs(code, indentSize) {
107
- var unit = ' '.repeat(indentSize);
108
- return code.split('\n').map(function(line) {
109
- var tabs = '';
110
- while (line.startsWith(unit)) { tabs += '\t'; line = line.slice(unit.length); }
111
- return tabs + line;
112
- }).join('\n');
113
- }
135
+ // Indentation of formatted output is the formatter's job, not a post-pass:
136
+ // Prettier is given useTabs/tabWidth from the editor's own settings, and Ruby
137
+ // indentation comes from the project's .rubocop.yml. Monaco's built-in
138
+ // "Convert Indentation to Tabs / to Spaces" commands (F1) cover converting a
139
+ // file that is already open, using its own indentation guesser.
114
140
 
115
141
  function diffLines(oldLines, newLines) {
116
142
  var n = oldLines.length, m = newLines.length;
@@ -1018,28 +1044,11 @@ var MbeditorApp = function MbeditorApp() {
1018
1044
  return;
1019
1045
  }
1020
1046
 
1021
- var ext = tab.path.split('.').pop().toLowerCase();
1022
- var formatMap = {
1023
- 'js': 'babel', 'jsx': 'babel',
1024
- 'json': 'json',
1025
- 'css': 'css', 'scss': 'scss',
1026
- 'html': 'html', 'md': 'markdown'
1027
- };
1028
- var parserName = formatMap[ext];
1047
+ var parserName = prettierParserFor(tab.path);
1029
1048
 
1030
1049
  if (parserName && window.prettier && window.prettierPlugins) {
1031
1050
  var prefs = EditorStore.getState().editorPrefs || DEFAULT_EDITOR_PREFS;
1032
- window.prettier.format(tab.content, {
1033
- parser: parserName,
1034
- plugins: Object.values(window.prettierPlugins),
1035
- printWidth: prefs.prettierPrintWidth != null ? prefs.prettierPrintWidth : 80,
1036
- tabWidth: prefs.tabSize != null ? prefs.tabSize : 2,
1037
- useTabs: !(prefs.insertSpaces),
1038
- semi: prefs.prettierSemi !== false,
1039
- singleQuote: !!prefs.prettierSingleQuote,
1040
- trailingComma: prefs.prettierTrailingComma || 'all',
1041
- bracketSpacing: prefs.prettierBracketSpacing !== false
1042
- }).then(function () {
1051
+ window.prettier.format(tab.content, prettierOptions(prefs, parserName)).then(function () {
1043
1052
  var currentPane = EditorStore.getState().panes.find(function (p) {
1044
1053
  return p.id === paneId;
1045
1054
  });
@@ -2671,26 +2680,9 @@ var MbeditorApp = function MbeditorApp() {
2671
2680
  .then(function (res) { return (res && res.content) || null; })
2672
2681
  ["catch"](function () { return null; });
2673
2682
  }
2674
- var ext = tab.path.split('.').pop().toLowerCase();
2675
- var parserMap = { 'js': 'babel', 'jsx': 'babel', 'json': 'json', 'css': 'css', 'scss': 'scss', 'html': 'html', 'md': 'markdown' };
2676
- var parserName = parserMap[ext];
2683
+ var parserName = prettierParserFor(tab.path);
2677
2684
  if (!parserName) return Promise.resolve(null);
2678
- var run = function () {
2679
- return window.prettier.format(tab.content, {
2680
- parser: parserName,
2681
- plugins: Object.values(window.prettierPlugins),
2682
- printWidth: editorPrefs.prettierPrintWidth != null ? editorPrefs.prettierPrintWidth : 80,
2683
- tabWidth: editorPrefs.prettierTabWidth != null ? editorPrefs.prettierTabWidth : 2,
2684
- useTabs: !!editorPrefs.prettierUseTabs,
2685
- semi: editorPrefs.prettierSemi !== false,
2686
- singleQuote: !!editorPrefs.prettierSingleQuote,
2687
- trailingComma: editorPrefs.prettierTrailingComma || 'all',
2688
- bracketSpacing: editorPrefs.prettierBracketSpacing !== false
2689
- })["catch"](function () { return null; });
2690
- };
2691
- if (window.prettier && window.prettierPlugins) return run();
2692
- if (window.loadPrettierPlugins) return window.loadPrettierPlugins().then(run)["catch"](function () { return null; });
2693
- return Promise.resolve(null);
2685
+ return runPrettier(tab.content, editorPrefs, parserName)["catch"](function () { return null; });
2694
2686
  };
2695
2687
 
2696
2688
  var handleSave = function handleSave(paneId, tab) {
@@ -2991,132 +2983,141 @@ var MbeditorApp = function MbeditorApp() {
2991
2983
  });
2992
2984
  };
2993
2985
 
2994
- var handleFormat = function handleFormat() {
2995
- if (!activeTab) return;
2986
+ // Format one tab's content.
2987
+ //
2988
+ // Resolves to the formatted string, or to null when no formatter covers this
2989
+ // file type (the caller then decides whether to fall back to a re-indent).
2990
+ // Rejects when a formatter ran and failed, so the reason reaches the user
2991
+ // rather than the button appearing to do nothing.
2992
+ var formatTabContent = function formatTabContent(tab, prefs) {
2993
+ prefs = prefs || editorPrefs;
2994
+ if (isRubyPath(tab.path) || tab.path.endsWith('.rake')) {
2995
+ if (!rubocopAvailable) return Promise.reject(new Error("RuboCop is not available for this workspace."));
2996
+ // RuboCop's output is taken as-is. Ruby indentation belongs to the
2997
+ // project's .rubocop.yml (Layout/IndentationStyle and friends), so
2998
+ // rewriting it here would fight the linter that is about to be run over
2999
+ // the same file. The old code tried the opposite — converting the source
3000
+ // to tabs *before* handing it over — which RuboCop simply discarded.
3001
+ return formatRubySource(tab.path, tab.content).then(function (res) {
3002
+ return (res && res.content) || null;
3003
+ });
3004
+ }
2996
3005
 
2997
- var isRubyLang = activeTab.path.endsWith('.rb') || activeTab.path.endsWith('.rake') || activeTab.path.endsWith('.gemspec') || activeTab.path.endsWith("Rakefile") || activeTab.path.endsWith("Gemfile");
3006
+ var parserName = prettierParserFor(tab.path);
3007
+ if (!parserName) return Promise.resolve(null);
3008
+ return runPrettier(tab.content, prefs, parserName);
3009
+ };
2998
3010
 
2999
- if (isRubyLang && !rubocopAvailable) {
3000
- EditorStore.setStatus("RuboCop is not available for this workspace.", "warning");
3001
- return;
3002
- }
3011
+ // Write formatted content back to a tab, dirty and unsaved — the user decides
3012
+ // when to save. EditorPanel applies it through executeEdits, so undo works.
3013
+ var applyFormattedContent = function applyFormattedContent(paneId, tabId, formatted) {
3014
+ EditorStore.setState({
3015
+ panes: EditorStore.getState().panes.map(function (p) {
3016
+ if (p.id !== paneId) return p;
3017
+ return _extends({}, p, { tabs: p.tabs.map(function (t) {
3018
+ return t.id === tabId
3019
+ ? _extends({}, t, { content: formatted, dirty: true, externalContentVersion: (t.externalContentVersion || 0) + 1 })
3020
+ : t;
3021
+ }) });
3022
+ })
3023
+ });
3024
+ };
3003
3025
 
3004
- if (isRubyLang) {
3005
- setLoading(function (prev) {
3006
- return _extends({}, prev, { format: true });
3007
- });
3008
- EditorStore.setStatus("Formatting...", "info");
3009
- var originalContent = activeTab.content;
3010
- var codeToFormat = originalContent;
3011
- if (editorPrefs.insertSpaces === false) {
3012
- var detectedWidth = detectIndentWidth(originalContent);
3013
- if (detectedWidth > 0) codeToFormat = spacesToTabs(originalContent, detectedWidth);
3014
- }
3015
- formatRubySource(activeTab.path, codeToFormat).then(function (res) {
3016
- if (res.content) {
3017
- // Update content and mark dirty — user decides when to save.
3018
- // The executeEdits path in EditorPanel preserves the undo stack.
3019
- var newPanes = EditorStore.getState().panes.map(function (p) {
3020
- if (p.id === focusedPane.id) return _extends({}, p, { tabs: p.tabs.map(function (t) {
3021
- return t.id === activeTab.id ? _extends({}, t, { content: res.content, dirty: true, externalContentVersion: (t.externalContentVersion || 0) + 1 }) : t;
3022
- }) });
3023
- return p;
3024
- });
3025
- EditorStore.setState({ panes: newPanes });
3026
-
3027
- // Highlight changed lines briefly
3028
- var monacoEditor = window.__mbeditorActiveEditor;
3029
- if (monacoEditor && res.content !== originalContent) {
3030
- var changedLineNums = diffLines(originalContent.split('\n'), res.content.split('\n'));
3031
- if (changedLineNums.length > 0) {
3032
- var decorations = changedLineNums.map(function(ln) {
3033
- return { range: new monaco.Range(ln, 1, ln, 1), options: { isWholeLine: true, className: 'mbeditor-format-changed' } };
3034
- });
3035
- var ids = monacoEditor.deltaDecorations([], decorations);
3036
- setTimeout(function() { monacoEditor.deltaDecorations(ids, []); }, 3000);
3037
- }
3038
- }
3026
+ var highlightFormatChanges = function highlightFormatChanges(before, after) {
3027
+ var monacoEditor = window.__mbeditorActiveEditor;
3028
+ if (!monacoEditor || before === after) return;
3029
+ var changedLineNums = diffLines(before.split('\n'), after.split('\n'));
3030
+ if (!changedLineNums.length) return;
3031
+ var ids = monacoEditor.deltaDecorations([], changedLineNums.map(function (ln) {
3032
+ return { range: new monaco.Range(ln, 1, ln, 1), options: { isWholeLine: true, className: 'mbeditor-format-changed' } };
3033
+ }));
3034
+ setTimeout(function () { monacoEditor.deltaDecorations(ids, []); }, 3000);
3035
+ };
3036
+
3037
+ var handleFormat = function handleFormat() {
3038
+ if (!activeTab) return;
3039
+
3040
+ var tab = activeTab;
3041
+ var paneId = focusedPane.id;
3042
+ var originalContent = tab.content;
3043
+
3044
+ setLoading(function (prev) { return _extends({}, prev, { format: true }); });
3045
+ EditorStore.setStatus("Formatting " + tab.name + "...", "info");
3046
+
3047
+ formatTabContent(tab).then(function (formatted) {
3048
+ if (formatted == null) {
3049
+ // No formatter for this file type — re-indent with Monaco instead.
3050
+ var monacoEditor = window.__mbeditorActiveEditor;
3051
+ var reindentAction = monacoEditor && monacoEditor.getAction('editor.action.reindentLines');
3052
+ if (!reindentAction) {
3053
+ EditorStore.setStatus("No formatter for " + tab.name + ".", "warning");
3054
+ return;
3039
3055
  }
3040
- EditorStore.setStatus("Formatted (Unsaved)", "success");
3041
- GitService.fetchStatus();
3042
- })["catch"](function (err) {
3043
- return EditorStore.setStatus("Format failed: " + err.message, "error");
3044
- })["finally"](function () {
3045
- return setLoading(function (prev) {
3046
- return _extends({}, prev, { format: false });
3056
+ return reindentAction.run().then(function () {
3057
+ EditorStore.setStatus("Re-indented (Unsaved)", "success");
3047
3058
  });
3059
+ }
3060
+ if (formatted !== originalContent) {
3061
+ applyFormattedContent(paneId, tab.id, formatted);
3062
+ highlightFormatChanges(originalContent, formatted);
3063
+ }
3064
+ EditorStore.setStatus(formatted === originalContent ? "Already formatted" : "Formatted (Unsaved)", "success");
3065
+ GitService.fetchStatus();
3066
+ })["catch"](function (err) {
3067
+ EditorStore.setStatus("Format failed: " + (err && err.message ? err.message : err), "error");
3068
+ })["finally"](function () {
3069
+ setLoading(function (prev) { return _extends({}, prev, { format: false }); });
3070
+ });
3071
+ };
3072
+
3073
+ // Format every open document across all panes.
3074
+ //
3075
+ // Each tab is formatted independently and a failure is collected rather than
3076
+ // thrown, so one unparseable file cannot stop the rest. Virtual tabs (diffs,
3077
+ // settings, the changelog) have no path on disk and are skipped.
3078
+ var handleFormatAll = function handleFormatAll() {
3079
+ var targets = [];
3080
+ EditorStore.getState().panes.forEach(function (p) {
3081
+ p.tabs.forEach(function (t) {
3082
+ if (t.path && !t.path.startsWith('mbeditor://') && t.path !== '__settings__' && typeof t.content === 'string') {
3083
+ targets.push({ paneId: p.id, tab: t });
3084
+ }
3048
3085
  });
3086
+ });
3087
+
3088
+ if (!targets.length) {
3089
+ EditorStore.setStatus("No open documents to format.", "warning");
3049
3090
  return;
3050
3091
  }
3051
3092
 
3052
- // Attempt Prettier Formatting
3053
- var ext = activeTab.path.split('.').pop().toLowerCase();
3054
- var formatMap = {
3055
- 'js': 'babel', 'jsx': 'babel',
3056
- 'json': 'json',
3057
- 'css': 'css', 'scss': 'scss',
3058
- 'html': 'html', 'md': 'markdown'
3059
- };
3060
- var parserName = formatMap[ext];
3093
+ setLoading(function (prev) { return _extends({}, prev, { format: true }); });
3094
+ EditorStore.setStatus("Formatting " + targets.length + " open document" + (targets.length === 1 ? "" : "s") + "...", "info");
3061
3095
 
3062
- if (parserName) {
3063
- setLoading(function (prev) {
3064
- return _extends({}, prev, { format: true });
3065
- });
3066
- EditorStore.setStatus("Formatting with Prettier...", "info");
3067
- var doFormat = function doFormat() {
3068
- return window.prettier.format(activeTab.content, {
3069
- parser: parserName,
3070
- plugins: Object.values(window.prettierPlugins),
3071
- printWidth: editorPrefs.prettierPrintWidth != null ? editorPrefs.prettierPrintWidth : 80,
3072
- tabWidth: editorPrefs.prettierTabWidth != null ? editorPrefs.prettierTabWidth : 2,
3073
- useTabs: !!editorPrefs.prettierUseTabs,
3074
- semi: editorPrefs.prettierSemi !== false,
3075
- singleQuote: !!editorPrefs.prettierSingleQuote,
3076
- trailingComma: editorPrefs.prettierTrailingComma || 'all',
3077
- bracketSpacing: editorPrefs.prettierBracketSpacing !== false
3078
- }).then(function (formatted) {
3079
- var newPanes = EditorStore.getState().panes.map(function (p) {
3080
- if (p.id === focusedPane.id) return _extends({}, p, { tabs: p.tabs.map(function (t) {
3081
- return t.id === activeTab.id ? _extends({}, t, { content: formatted, dirty: true, externalContentVersion: (t.externalContentVersion || 0) + 1 }) : t;
3082
- }) });
3083
- return p;
3084
- });
3085
- EditorStore.setState({ panes: newPanes });
3086
- EditorStore.setStatus("Formatted (Unsaved)", "success");
3087
- GitService.fetchStatus();
3088
- })["catch"](function (err) {
3089
- EditorStore.setStatus("Prettier Formatter failed: " + err.message, "error");
3090
- })["finally"](function () {
3091
- setLoading(function (prev) {
3092
- return _extends({}, prev, { format: false });
3093
- });
3094
- });
3095
- };
3096
- if (window.prettier && window.prettierPlugins) {
3097
- doFormat();
3098
- } else if (window.loadPrettierPlugins) {
3099
- window.loadPrettierPlugins().then(doFormat)["catch"](function (err) {
3100
- EditorStore.setStatus("Failed to load Prettier: " + err.message, "error");
3101
- setLoading(function (prev) { return _extends({}, prev, { format: false }); });
3102
- });
3103
- } else {
3104
- EditorStore.setStatus("Prettier is not available.", "warning");
3105
- setLoading(function (prev) { return _extends({}, prev, { format: false }); });
3106
- }
3107
- return;
3108
- }
3096
+ var changed = 0;
3097
+ var skipped = 0;
3098
+ var failures = [];
3109
3099
 
3110
- // Fallback: Monaco re-indent using the editor's configured tabSize/insertSpaces
3111
- var monacoEditor = window.__mbeditorActiveEditor;
3112
- if (monacoEditor) {
3113
- var reindentAction = monacoEditor.getAction('editor.action.reindentLines');
3114
- if (reindentAction) {
3115
- reindentAction.run().then(function () {
3116
- EditorStore.setStatus("Formatted (Unsaved)", "success");
3117
- });
3118
- }
3119
- }
3100
+ Promise.all(targets.map(function (target) {
3101
+ return formatTabContent(target.tab).then(function (formatted) {
3102
+ if (formatted == null) { skipped += 1; return; }
3103
+ if (formatted === target.tab.content) return;
3104
+ applyFormattedContent(target.paneId, target.tab.id, formatted);
3105
+ changed += 1;
3106
+ })["catch"](function (err) {
3107
+ failures.push(target.tab.name + ": " + (err && err.message ? err.message.split('\n')[0] : err));
3108
+ });
3109
+ })).then(function () {
3110
+ var parts = [changed + " formatted"];
3111
+ if (skipped) parts.push(skipped + " skipped");
3112
+ if (failures.length) parts.push(failures.length + " failed");
3113
+ EditorStore.setStatus(
3114
+ parts.join(", ") + (changed ? " (Unsaved)" : "") + (failures.length ? " — " + failures[0] : ""),
3115
+ failures.length ? "error" : "success"
3116
+ );
3117
+ if (changed) GitService.fetchStatus();
3118
+ })["finally"](function () {
3119
+ setLoading(function (prev) { return _extends({}, prev, { format: false }); });
3120
+ });
3120
3121
  };
3121
3122
 
3122
3123
  var TEST_CACHE_PREFIX = 'mbeditor_test_result_';
@@ -4226,10 +4227,16 @@ var MbeditorApp = function MbeditorApp() {
4226
4227
  React.createElement("div", { className: "statusbar-sep" }),
4227
4228
  React.createElement(
4228
4229
  "button",
4229
- { className: "statusbar-btn", onClick: handleFormat, disabled: loading.format || !canLintAndFormat, 'aria-busy': !!loading.format },
4230
+ { className: "statusbar-btn", onClick: handleFormat, disabled: loading.format || !canLintAndFormat, 'aria-busy': !!loading.format, title: "Format this document" },
4230
4231
  !loading.format && React.createElement("i", { className: "fas fa-magic" }),
4231
4232
  !toolbarIconOnly && !loading.format && " Format"
4232
4233
  ),
4234
+ React.createElement(
4235
+ "button",
4236
+ { className: "statusbar-btn", onClick: handleFormatAll, disabled: loading.format || !canLintAndFormat, 'aria-busy': !!loading.format, title: "Format all open documents" },
4237
+ React.createElement("i", { className: "fas fa-wand-magic-sparkles" }),
4238
+ !toolbarIconOnly && !loading.format && " Format All"
4239
+ ),
4233
4240
  hasGitBranch && React.createElement(
4234
4241
  React.Fragment,
4235
4242
  null,
@@ -816,12 +816,58 @@
816
816
  return handled;
817
817
  });
818
818
 
819
+ // ── Format on paste ──────────────────────────────────────────────────────
820
+ //
821
+ // Driven from onDidPaste rather than Monaco's own `formatOnPaste` option.
822
+ // That option has been on by default all along and never did anything here:
823
+ // its contribution is present and the event fires, but it declines to run
824
+ // the formatter, so pasted code kept whatever indentation it was copied
825
+ // with. onDidPaste is public API, fires reliably, and hands over the exact
826
+ // pasted range — which is also what makes the two cases distinguishable:
827
+ //
828
+ // * paste that fills the whole document (a blank file, or replacing all
829
+ // of it) is formatted as a document
830
+ // * a paste in the middle is formatted as a range, so Prettier reprints
831
+ // the smallest enclosing statement and the rest of the file is left
832
+ // byte-identical
833
+ //
834
+ // Either way the result comes back at the editor's own tab/space setting,
835
+ // which is the point: code copied in from a spaces project lands as tabs.
836
+ var pasteDisposable = editor.onDidPaste(function (e) {
837
+ var prefs = (typeof EditorStore !== 'undefined' && EditorStore.getState().editorPrefs) || {};
838
+ if (prefs.formatOnPaste === false) return;
839
+
840
+ var pasteModel = editor.getModel();
841
+ if (!pasteModel || pasteModel.isDisposed()) return;
842
+
843
+ var full = pasteModel.getFullModelRange();
844
+ var wholeDocument = e.range.startLineNumber <= full.startLineNumber &&
845
+ e.range.endLineNumber >= full.endLineNumber;
846
+
847
+ var action = editor.getAction(wholeDocument ? 'editor.action.formatDocument' : 'editor.action.formatSelection');
848
+ if (!action) return;
849
+
850
+ if (wholeDocument) {
851
+ action.run()["catch"](function () { /* no formatter, or unparseable — leave it */ });
852
+ return;
853
+ }
854
+
855
+ // formatSelection works on the selection, so point it at the pasted range
856
+ // and put the cursor back where the paste left it.
857
+ var restore = editor.getSelections();
858
+ editor.setSelection(e.range);
859
+ action.run()["catch"](function () {})["finally"](function () {
860
+ if (restore && restore.length && !editor.getModel().isDisposed()) editor.setSelections(restore);
861
+ });
862
+ });
863
+
819
864
  return {
820
865
  dispose: function dispose() {
821
866
  if (keydownDisposable) keydownDisposable.dispose();
822
867
  if (emmetTabDisposable) emmetTabDisposable.dispose();
823
868
  if (jsGotoMouseDisposable) jsGotoMouseDisposable.dispose();
824
869
  if (jsGotoActionDisposable) jsGotoActionDisposable.dispose();
870
+ if (pasteDisposable) pasteDisposable.dispose();
825
871
  contentDisposable.dispose();
826
872
  }
827
873
  };
@@ -853,6 +899,22 @@
853
899
  jsx: monaco.languages.typescript.JsxEmit.React,
854
900
  noUnusedLocals: true
855
901
  });
902
+ // Hand JS formatting to Prettier alone.
903
+ //
904
+ // The TypeScript worker registers its own range formatter for
905
+ // 'javascript', and Monaco consults whichever provider it finds first.
906
+ // That gave one file two formatters that disagreed: the toolbar button
907
+ // went through Prettier and honoured the editor's tab settings, while
908
+ // Shift+Alt+F and format-on-paste got the TS worker's, which reprints at
909
+ // two spaces and never adds semicolons, ignoring every preference. Only
910
+ // formatting is switched off — completions, hovers, diagnostics, rename
911
+ // and the rest of the JS intelligence still come from the worker.
912
+ monaco.languages.typescript.javascriptDefaults.setModeConfiguration(
913
+ Object.assign({}, monaco.languages.typescript.javascriptDefaults.modeConfiguration, {
914
+ documentRangeFormattingEdits: false,
915
+ onTypeFormattingEdits: false
916
+ })
917
+ );
856
918
  }
857
919
 
858
920
  // ── React mini-UMD type declarations ────────────────────────────────────
@@ -1767,6 +1829,82 @@
1767
1829
  }).catch(function () { return []; });
1768
1830
  }
1769
1831
 
1832
+ // ── Prettier formatting providers ────────────────────────────────────────
1833
+ //
1834
+ // `formatOnPaste` has been on by default for a long time and did nothing
1835
+ // outside Ruby: Monaco acts on a paste only through a *range* formatting
1836
+ // provider, and none was registered. Registering one here is what makes
1837
+ // pasted blocks land correctly indented, and what formats the whole thing
1838
+ // when the paste fills an empty file (the pasted range is then the file).
1839
+ //
1840
+ // Prettier's own rangeStart/rangeEnd does the narrowing — it reprints the
1841
+ // smallest enclosing statement and leaves the rest byte-identical, so a
1842
+ // paste in the middle of a file does not reformat the file.
1843
+ var PRETTIER_LANGUAGE_PARSERS = {
1844
+ javascript: 'babel', json: 'json', css: 'css',
1845
+ scss: 'scss', less: 'less', html: 'html', markdown: 'markdown'
1846
+ };
1847
+
1848
+ // One edit spanning only what actually changed. A whole-document
1849
+ // replacement would work but throws away the cursor position and collapses
1850
+ // undo, which on every paste is very noticeable.
1851
+ function minimalEdit(model, oldText, newText) {
1852
+ if (oldText === newText) return [];
1853
+ var start = 0;
1854
+ var maxStart = Math.min(oldText.length, newText.length);
1855
+ while (start < maxStart && oldText.charCodeAt(start) === newText.charCodeAt(start)) start++;
1856
+ var oldEnd = oldText.length;
1857
+ var newEnd = newText.length;
1858
+ while (oldEnd > start && newEnd > start && oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
1859
+ oldEnd--;
1860
+ newEnd--;
1861
+ }
1862
+ return [{
1863
+ range: monaco.Range.fromPositions(model.getPositionAt(start), model.getPositionAt(oldEnd)),
1864
+ text: newText.slice(start, newEnd)
1865
+ }];
1866
+ }
1867
+
1868
+ function prettierEdits(model, range) {
1869
+ var parser = PRETTIER_LANGUAGE_PARSERS[model.getLanguageId()];
1870
+ if (!parser || typeof runPrettier !== 'function') return Promise.resolve([]);
1871
+
1872
+ var text = model.getValue();
1873
+ var prefs = (typeof EditorStore !== 'undefined' && EditorStore.getState().editorPrefs) || {};
1874
+ var extra = null;
1875
+ if (range) {
1876
+ extra = {
1877
+ rangeStart: model.getOffsetAt({ lineNumber: range.startLineNumber, column: range.startColumn }),
1878
+ rangeEnd: model.getOffsetAt({ lineNumber: range.endLineNumber, column: range.endColumn })
1879
+ };
1880
+ }
1881
+
1882
+ return runPrettier(text, prefs, parser, extra)
1883
+ .then(function (formatted) {
1884
+ // The model can have moved on while Prettier was working.
1885
+ if (model.isDisposed() || model.getValue() !== text) return [];
1886
+ return minimalEdit(model, text, formatted);
1887
+ })
1888
+ // A paste of half an expression will not parse. Leaving it alone is the
1889
+ // right answer — the diagnostics path reports the syntax error.
1890
+ ["catch"](function () { return []; });
1891
+ }
1892
+
1893
+ Object.keys(PRETTIER_LANGUAGE_PARSERS).forEach(function (languageId) {
1894
+ monaco.languages.registerDocumentRangeFormattingEditProvider(languageId, {
1895
+ displayName: 'Prettier',
1896
+ provideDocumentRangeFormattingEdits: function (model, range) {
1897
+ return prettierEdits(model, range);
1898
+ }
1899
+ });
1900
+ monaco.languages.registerDocumentFormattingEditProvider(languageId, {
1901
+ displayName: 'Prettier',
1902
+ provideDocumentFormattingEdits: function (model) {
1903
+ return prettierEdits(model, null);
1904
+ }
1905
+ });
1906
+ });
1907
+
1770
1908
  // Parameter hints while typing a call's arguments.
1771
1909
  monaco.languages.registerSignatureHelpProvider('ruby', {
1772
1910
  signatureHelpTriggerCharacters: ['(', ','],
@@ -93,11 +93,25 @@ axios.interceptors.request.use(function (config) {
93
93
  return config;
94
94
  });
95
95
 
96
+ // A request we aborted ourselves also arrives here with no response, and the
97
+ // editor cancels constantly — every keystroke supersedes the previous search,
98
+ // every hover and completion provider aborts when the cursor moves on, and
99
+ // opening a file aborts its own prefetch. Counting those as unreachable took
100
+ // two cancellations to declare the server offline, so routine actions (creating
101
+ // a file, typing in search) flashed "Server offline" until the /ping probe
102
+ // one second later put it back. A cancellation says nothing about the server.
103
+ function _isCanceled(error) {
104
+ if (!error) return false;
105
+ if (axios.isCancel && axios.isCancel(error)) return true;
106
+ return error.code === 'ERR_CANCELED' || error.name === 'CanceledError' || error.name === 'AbortError';
107
+ }
108
+
96
109
  axios.interceptors.response.use(function (response) {
97
110
  ServerReachability.noteSuccess();
98
111
  return response;
99
112
  }, function (error) {
100
113
  if (error && error.mbeditorSkipped) return Promise.reject(error);
114
+ if (_isCanceled(error)) return Promise.reject(error);
101
115
  // No response object at all means the request never reached the server.
102
116
  if (error && !error.response) ServerReachability.noteNetworkFailure();
103
117
  else ServerReachability.noteSuccess();
@@ -32,17 +32,22 @@ module Mbeditor
32
32
  end
33
33
 
34
34
  # Expand path and confirm it's inside workspace_root.
35
- # SafePath follows every symlink on the way — including dangling ones, which
36
- # an existence walk would skip past so a symlink inside the workspace
37
- # cannot escape the sandbox regardless of whether the target exists yet
38
- # (create / rename paths).
35
+ #
36
+ # Containment is judged lexically, on the path as spelled. File.expand_path
37
+ # collapses "..", so no request can name anything outside the workspace, and
38
+ # an absolute path is rejected outright.
39
+ #
40
+ # Symlinks are followed, the way a file manager does: an app that links a
41
+ # shared config directory or a sibling engine into its tree gets those files
42
+ # opening like any other. The link itself has to already exist inside the
43
+ # workspace, which means a developer put it there — the sandbox is about
44
+ # what a request may *name*, not about second-guessing the checkout.
39
45
  def resolve_path(raw)
40
46
  return nil if raw.blank?
41
47
 
42
48
  root = workspace_root.to_s
43
49
  full = File.expand_path(raw.to_s, root)
44
50
  return nil unless full.start_with?("#{root}/") || full == root
45
- return nil unless SafePath.within?(root, full)
46
51
 
47
52
  full
48
53
  rescue Errno::EACCES, ArgumentError
@@ -22,7 +22,7 @@ module Mbeditor
22
22
  root = workspace_root.to_s
23
23
  if SearchReplaceService.rg_available?
24
24
  run_rg(pattern, root, globs)
25
- elsif File.exist?(File.join(root, ".git"))
25
+ elsif SearchReplaceService.git_tier?(root)
26
26
  run_git_grep(pattern, root, globs)
27
27
  else
28
28
  run_grep(pattern, root, globs)
@@ -155,9 +155,12 @@ module Mbeditor
155
155
  # Resolve a file path safely within repo_path. Returns full path string or
156
156
  # nil if the path escapes the root.
157
157
  #
158
- # Follows symlinks (via SafePath, including dangling ones) so that a symlink
159
- # inside the repo cannot escape it, mirroring
160
- # ApplicationController#resolve_path. When repo_path is not a real directory
158
+ # Resolves symlinks (via SafePath, including dangling ones) so that a
159
+ # symlink inside the repo cannot escape it. Deliberately stricter than
160
+ # ApplicationController#resolve_path, which follows links out of the
161
+ # workspace so linked-in files open: git has nothing to say about a file
162
+ # outside its own repo, so there is no feature to lose here.
163
+ # When repo_path is not a real directory
161
164
  # (e.g. unit tests with synthetic roots) the symlink check is skipped, since
162
165
  # there is nothing on disk to resolve and repo_path is server-controlled.
163
166
  def resolve_path(repo_path, relative)
@@ -45,6 +45,19 @@ module Mbeditor
45
45
  AvailabilityProbe.rg_command || "rg"
46
46
  end
47
47
 
48
+ # Whether the git tier is usable on this workspace. The presence of a
49
+ # `.git` entry is not enough: a repo git refuses to open (dubious
50
+ # ownership under Docker, a `.git` file pointing at a gitdir that moved,
51
+ # a git binary missing from the server's PATH) still has one. `git grep`
52
+ # then fails, its stderr goes to /dev/null, and search returns instantly
53
+ # with nothing — silently, and with no way to tell it apart from "no
54
+ # matches". AvailabilityProbe.git runs `rev-parse --is-inside-work-tree`,
55
+ # which fails in exactly those cases, so a bad repo falls through to
56
+ # plain grep instead of to an empty result set.
57
+ def git_tier?(root)
58
+ File.exist?(File.join(root, ".git")) && AvailabilityProbe.git(root)
59
+ end
60
+
48
61
  # Which backend a search on this workspace will actually use. Reported by
49
62
  # GET /workspace: the difference between the tiers is 10-30x, so "why is
50
63
  # search slow" needs to be answerable without reading the source.
@@ -245,7 +258,7 @@ module Mbeditor
245
258
 
246
259
  def pick_tier(root)
247
260
  return :rg if rg_available?
248
- return :git if File.exist?(File.join(root, ".git"))
261
+ return :git if git_tier?(root)
249
262
 
250
263
  :grep
251
264
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_support/logger_silence"
4
+ require "logger"
4
5
  require "uri"
5
6
 
6
7
  module Mbeditor
@@ -18,7 +19,14 @@ module Mbeditor
18
19
  if root_request?(env, path)
19
20
  @app.call(env)
20
21
  elsif mbeditor_request?(path) || cable_request?(path) || editor_asset_request?(env, path)
21
- Rails.logger.silence { @app.call(env) }
22
+ # Silence to WARN, not the default ERROR. The point is to hide Rails'
23
+ # own request lines ("Started GET ...", "Completed 200 ..."), which are
24
+ # INFO — but the default swallows everything below ERROR, including
25
+ # anything a host app logs from inside authenticate_with or
26
+ # user_name_callback. Those procs run within this request, so a
27
+ # developer debugging their own hook with Rails.logger.warn saw
28
+ # nothing at all and had no way to know why.
29
+ Rails.logger.silence(::Logger::WARN) { @app.call(env) }
22
30
  else
23
31
  @app.call(env)
24
32
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
- VERSION = "0.12.5"
4
+ VERSION = "0.12.6"
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.5
4
+ version: 0.12.6
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-04 00:00:00.000000000 Z
11
+ date: 2026-08-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails