mbeditor 0.12.4 → 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 +4 -4
- data/CHANGELOG.md +98 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +8 -2
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +188 -181
- data/app/assets/javascripts/mbeditor/editor_plugins.js +138 -0
- data/app/assets/javascripts/mbeditor/file_service.js +119 -2
- data/app/assets/javascripts/mbeditor/git_service.js +5 -2
- data/app/assets/javascripts/mbeditor/websocket_service.js +34 -3
- data/app/controllers/mbeditor/application_controller.rb +10 -5
- data/app/services/mbeditor/code_search_service.rb +1 -1
- data/app/services/mbeditor/git_service.rb +6 -3
- data/app/services/mbeditor/search_replace_service.rb +14 -1
- data/lib/mbeditor/cable_log_filter.rb +6 -1
- data/lib/mbeditor/rack/silence_ping_request.rb +9 -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: dffea86be286ee0bdcb216589b12264dcac613a33e59aa2828490706785e1825
|
|
4
|
+
data.tar.gz: f786fe449deccc7ff1971c8df15015b7ee0507b45b14a332f83e162b44facc01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1cf4fcf24d9868b1b5a284c1de2f8eb3b5032a10d8cb3a9ebb1677eed3a6ce9660e658a43f80652ec1a3edb5195aadcf01fc428fc381ed5f62618d59dde4286
|
|
7
|
+
data.tar.gz: 3c0c8dc6d5d2aee702d0ed1af2814e884cd204702383dafc21871ffcf2a0b8fa8e871a971d69824ea44f7831f335d3f7e8d0999c832142e5b510d43033ad10cd
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,104 @@ 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
|
+
|
|
79
|
+
## [0.12.5] - 2026-08-03
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
- **The cable took up to 30 seconds to reconnect.** The retry was a flat 30s
|
|
83
|
+
interval, and because a disconnect tears the consumer down, Action Cable's own
|
|
84
|
+
much faster reconnection monitor was discarded with it. Any transient blip or
|
|
85
|
+
dev-server restart therefore cost half a minute with no cable at all: no
|
|
86
|
+
presence, no collaboration, no file-change push. Replaced with a jittered
|
|
87
|
+
backoff from 1s to a 8s ceiling, reset on a successful connect. Measured
|
|
88
|
+
against a real server restart: **2.8 seconds** from the server answering again
|
|
89
|
+
to the cable being back, where the flat interval could take the full 30.
|
|
90
|
+
- **Collaboration broadcasts flooded the development log.** The Action Cable log
|
|
91
|
+
filter matched `Mbeditor::` and `mbeditor_editor`, but a broadcast line names
|
|
92
|
+
only the stream — `Broadcasting to mbeditor_collab:…` — so every keystroke and
|
|
93
|
+
cursor move was logged with the base64 CRDT payload inlined.
|
|
94
|
+
- **The browser console filled with failed requests when the server became
|
|
95
|
+
unreachable.** A dropped VPN, a closed lid or a stopped server left the file
|
|
96
|
+
tree polling every 10s, git status every 5s and the git line tint every 10s,
|
|
97
|
+
all failing forever — and the browser logs every failed request itself, which
|
|
98
|
+
no amount of JavaScript can suppress. The only fix is to stop making them.
|
|
99
|
+
|
|
100
|
+
Background polls are now skipped before the request is issued once two
|
|
101
|
+
consecutive *network-level* failures have been seen (an HTTP error does not
|
|
102
|
+
count — a 500 proves the server is there), and a single probe on a 1s→30s
|
|
103
|
+
backoff decides when it is back. Requests you initiate are never blocked; they
|
|
104
|
+
fail fast with a real error rather than hanging until the 30s timeout.
|
|
105
|
+
Measured with the server down: two failed requests, then silence, instead of
|
|
106
|
+
an unbounded stream.
|
|
107
|
+
|
|
10
108
|
## [0.12.4] - 2026-08-03
|
|
11
109
|
|
|
12
110
|
### Fixed
|
|
@@ -17,7 +115,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
115
|
collaboration room authenticate. One message per distinct reason per five
|
|
18
116
|
minutes now, which says the same thing without drowning the log.
|
|
19
117
|
|
|
20
|
-
|
|
21
118
|
## [0.12.3] - 2026-08-03
|
|
22
119
|
|
|
23
120
|
### Added
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
});
|
|
@@ -1798,7 +1807,7 @@ var MbeditorApp = function MbeditorApp() {
|
|
|
1798
1807
|
useEffect(function () {
|
|
1799
1808
|
var intervalId = setInterval(function () {
|
|
1800
1809
|
if (document.hidden) return;
|
|
1801
|
-
FileService.getTree().then(function (data) {
|
|
1810
|
+
FileService.getTree({ background: true }).then(function (data) {
|
|
1802
1811
|
setTreeData(_treeUpdater(data || []));
|
|
1803
1812
|
}).catch(function () {}); // silently ignore auto-refresh errors
|
|
1804
1813
|
}, 10000);
|
|
@@ -1815,7 +1824,7 @@ var MbeditorApp = function MbeditorApp() {
|
|
|
1815
1824
|
// /git_info fan-out on its own when the branch or working tree changed.
|
|
1816
1825
|
var refresh = function () {
|
|
1817
1826
|
if (document.hidden) return;
|
|
1818
|
-
GitService.fetchStatusLite()["catch"](function () {});
|
|
1827
|
+
GitService.fetchStatusLite({ background: true })["catch"](function () {});
|
|
1819
1828
|
};
|
|
1820
1829
|
// Regaining focus is a strong signal something may have happened in a
|
|
1821
1830
|
// terminal meanwhile — do a full refresh (server-side cache bounds cost).
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
2995
|
-
|
|
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
|
+
}
|
|
3005
|
+
|
|
3006
|
+
var parserName = prettierParserFor(tab.path);
|
|
3007
|
+
if (!parserName) return Promise.resolve(null);
|
|
3008
|
+
return runPrettier(tab.content, prefs, parserName);
|
|
3009
|
+
};
|
|
2996
3010
|
|
|
2997
|
-
|
|
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
|
+
};
|
|
2998
3025
|
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
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
|
+
};
|
|
3003
3036
|
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
3041
|
-
|
|
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
|
-
|
|
3053
|
-
|
|
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
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
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
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
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: ['(', ','],
|
|
@@ -6,6 +6,118 @@ axios.defaults.headers.common['X-Mbeditor-Client'] = '1';
|
|
|
6
6
|
// The ping endpoint overrides this with a tighter 4 s timeout per-request.
|
|
7
7
|
axios.defaults.timeout = 30000;
|
|
8
8
|
|
|
9
|
+
// ── Server reachability ─────────────────────────────────────────────────────
|
|
10
|
+
//
|
|
11
|
+
// The editor polls hard and unconditionally: the file tree every 10s, git status
|
|
12
|
+
// every 5s, the git line tint every 10s. When the host stops resolving — a
|
|
13
|
+
// dropped VPN, a closed laptop lid, a stopped server — every one of those keeps
|
|
14
|
+
// firing forever, and the browser logs each failure itself. That is where the
|
|
15
|
+
// wall of ERR_NAME_NOT_RESOLVED in the console comes from, and JavaScript cannot
|
|
16
|
+
// suppress those entries: the only fix is to stop making the requests.
|
|
17
|
+
//
|
|
18
|
+
// So background polling is short-circuited while the server looks unreachable,
|
|
19
|
+
// and a single probe on a backoff decides when it is back. User-initiated
|
|
20
|
+
// requests are never blocked — they fail fast with a real error instead, which
|
|
21
|
+
// beats hanging until the 30s timeout.
|
|
22
|
+
var ServerReachability = (function () {
|
|
23
|
+
// A network-level failure has no response; an HTTP error does. Only the former
|
|
24
|
+
// means "cannot reach the server" — a 500 proves it is very much there.
|
|
25
|
+
var CONSECUTIVE_FAILURES_BEFORE_OFFLINE = 2;
|
|
26
|
+
var PROBE_BASE_MS = 1000;
|
|
27
|
+
var PROBE_MAX_MS = 30000;
|
|
28
|
+
|
|
29
|
+
var _failures = 0;
|
|
30
|
+
var _online = true;
|
|
31
|
+
var _probeTimer = null;
|
|
32
|
+
var _probeAttempts = 0;
|
|
33
|
+
var _listeners = [];
|
|
34
|
+
|
|
35
|
+
function _emit() {
|
|
36
|
+
_listeners.slice().forEach(function (fn) {
|
|
37
|
+
try { fn(_online); } catch (e) { /* a bad listener must not stop the rest */ }
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _scheduleProbe() {
|
|
42
|
+
if (_probeTimer || _online) return;
|
|
43
|
+
var delay = Math.min(PROBE_MAX_MS, PROBE_BASE_MS * Math.pow(2, _probeAttempts));
|
|
44
|
+
_probeAttempts += 1;
|
|
45
|
+
_probeTimer = setTimeout(function () {
|
|
46
|
+
_probeTimer = null;
|
|
47
|
+
// Bypasses the short-circuit below: this is the one request allowed
|
|
48
|
+
// through while offline, and it is what lets us notice recovery.
|
|
49
|
+
axios.get(window.mbeditorBasePath() + '/ping', { timeout: 4000, mbeditorProbe: true })
|
|
50
|
+
.then(function () { noteSuccess(); })
|
|
51
|
+
.catch(function () { _scheduleProbe(); });
|
|
52
|
+
}, delay);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function noteSuccess() {
|
|
56
|
+
_failures = 0;
|
|
57
|
+
_probeAttempts = 0;
|
|
58
|
+
if (_probeTimer) { clearTimeout(_probeTimer); _probeTimer = null; }
|
|
59
|
+
if (!_online) { _online = true; _emit(); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function noteNetworkFailure() {
|
|
63
|
+
_failures += 1;
|
|
64
|
+
if (_online && _failures >= CONSECUTIVE_FAILURES_BEFORE_OFFLINE) {
|
|
65
|
+
_online = false;
|
|
66
|
+
_emit();
|
|
67
|
+
_scheduleProbe();
|
|
68
|
+
} else if (!_online) {
|
|
69
|
+
_scheduleProbe();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
isOnline: function () { return _online; },
|
|
75
|
+
noteSuccess: noteSuccess,
|
|
76
|
+
noteNetworkFailure: noteNetworkFailure,
|
|
77
|
+
onChange: function (fn) {
|
|
78
|
+
_listeners.push(fn);
|
|
79
|
+
return function () { _listeners = _listeners.filter(function (f) { return f !== fn; }); };
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
})();
|
|
83
|
+
|
|
84
|
+
// Drop background polls while the server is unreachable, so they stop producing
|
|
85
|
+
// console noise and pointless traffic. Marked requests only — anything the user
|
|
86
|
+
// asked for still goes out.
|
|
87
|
+
axios.interceptors.request.use(function (config) {
|
|
88
|
+
if (config.mbeditorBackground && !ServerReachability.isOnline()) {
|
|
89
|
+
var err = new Error('mbeditor: skipped background request while server unreachable');
|
|
90
|
+
err.mbeditorSkipped = true;
|
|
91
|
+
return Promise.reject(err);
|
|
92
|
+
}
|
|
93
|
+
return config;
|
|
94
|
+
});
|
|
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
|
+
|
|
109
|
+
axios.interceptors.response.use(function (response) {
|
|
110
|
+
ServerReachability.noteSuccess();
|
|
111
|
+
return response;
|
|
112
|
+
}, function (error) {
|
|
113
|
+
if (error && error.mbeditorSkipped) return Promise.reject(error);
|
|
114
|
+
if (_isCanceled(error)) return Promise.reject(error);
|
|
115
|
+
// No response object at all means the request never reached the server.
|
|
116
|
+
if (error && !error.response) ServerReachability.noteNetworkFailure();
|
|
117
|
+
else ServerReachability.noteSuccess();
|
|
118
|
+
return Promise.reject(error);
|
|
119
|
+
});
|
|
120
|
+
|
|
9
121
|
// Surface pending-migration errors as a dismissible banner instead of silently failing.
|
|
10
122
|
axios.interceptors.response.use(null, function(error) {
|
|
11
123
|
if (error.response && error.response.data && error.response.data.pending_migration_error) {
|
|
@@ -42,8 +154,11 @@ var FileService = (function () {
|
|
|
42
154
|
return axios.get(window.mbeditorBasePath() + '/workspace').then(function(res) { return res.data; });
|
|
43
155
|
}
|
|
44
156
|
|
|
45
|
-
|
|
46
|
-
|
|
157
|
+
// opts.background marks an automatic poll rather than something the user
|
|
158
|
+
// asked for, so it can be skipped while the server is unreachable.
|
|
159
|
+
function getTree(opts) {
|
|
160
|
+
var cfg = (opts && opts.background) ? { mbeditorBackground: true } : {};
|
|
161
|
+
return axios.get(window.mbeditorBasePath() + '/files', cfg).then(function(res) { return res.data; });
|
|
47
162
|
}
|
|
48
163
|
|
|
49
164
|
function getFile(path, options) {
|
|
@@ -345,6 +460,8 @@ var FileService = (function () {
|
|
|
345
460
|
runTests: runTests,
|
|
346
461
|
ping: ping,
|
|
347
462
|
getRoutes: getRoutes,
|
|
463
|
+
isServerReachable: ServerReachability.isOnline,
|
|
464
|
+
onReachabilityChange: ServerReachability.onChange,
|
|
348
465
|
getState: getState,
|
|
349
466
|
saveState: saveState,
|
|
350
467
|
getBranchState: getBranchState,
|
|
@@ -35,8 +35,11 @@ var GitService = (function () {
|
|
|
35
35
|
// changed — an external branch switch or a working-tree change. Rich fields
|
|
36
36
|
// from the last full fetch (unpushedCommits, branchCommits, ...) are
|
|
37
37
|
// preserved rather than clobbered.
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
// opts.background marks the 5s poll, which is dropped while the server is
|
|
39
|
+
// unreachable rather than failing over and over in the console.
|
|
40
|
+
function fetchStatusLite(opts) {
|
|
41
|
+
var cfg = (opts && opts.background) ? { mbeditorBackground: true } : {};
|
|
42
|
+
return axios.get(window.mbeditorBasePath() + '/git_status', cfg)
|
|
40
43
|
.then(function(res) {
|
|
41
44
|
var data = res.data;
|
|
42
45
|
if (!data || !data.ok) return fetchInfo();
|
|
@@ -22,7 +22,29 @@ var WebSocketService = (function () {
|
|
|
22
22
|
var _serverSupportsWs = false;
|
|
23
23
|
var _reconnectTimer = null;
|
|
24
24
|
var _lastCableAttemptAt = 0;
|
|
25
|
-
|
|
25
|
+
// Exponential backoff, not a flat interval. This used to wait a fixed 30s
|
|
26
|
+
// before even attempting a reconnect — and because `disconnected` tears the
|
|
27
|
+
// consumer down, Action Cable's own much faster reconnection monitor is
|
|
28
|
+
// discarded too. The result was that any transient blip cost half a minute
|
|
29
|
+
// with no cable: no presence, no collaboration, no file-change push. Start
|
|
30
|
+
// near-instant for the common case (a blip, a server restart) and back off
|
|
31
|
+
// only if the server really is gone.
|
|
32
|
+
// Growth is gentle and the ceiling low on purpose. The dominant case here is a
|
|
33
|
+
// development server restarting, which takes several seconds to boot — a
|
|
34
|
+
// doubling curve spends those seconds growing, so the attempt that finally
|
|
35
|
+
// lands is a long way out. Measured against a real restart: doubling to a 30s
|
|
36
|
+
// cap reconnected in 20s, this reconnects within a few. It is a local dev
|
|
37
|
+
// tool, so retrying every few seconds costs nothing worth saving.
|
|
38
|
+
var RECONNECT_BASE_MS = 1000;
|
|
39
|
+
var RECONNECT_MAX_MS = 8000;
|
|
40
|
+
var RECONNECT_FACTOR = 1.6;
|
|
41
|
+
var _reconnectAttempts = 0;
|
|
42
|
+
|
|
43
|
+
function _reconnectDelay() {
|
|
44
|
+
var exp = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * Math.pow(RECONNECT_FACTOR, _reconnectAttempts));
|
|
45
|
+
// Jitter so a server restart doesn't have every open tab retry in lockstep.
|
|
46
|
+
return Math.round(exp * (0.7 + Math.random() * 0.6));
|
|
47
|
+
}
|
|
26
48
|
|
|
27
49
|
// ---------------------------------------------------------------------------
|
|
28
50
|
// Internal helpers
|
|
@@ -90,11 +112,17 @@ var WebSocketService = (function () {
|
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
function _scheduleReconnect() {
|
|
93
|
-
if (_reconnectTimer || !_serverSupportsWs) return;
|
|
115
|
+
if (_reconnectTimer || !_serverSupportsWs || _connected) return;
|
|
116
|
+
var delay = _reconnectDelay();
|
|
117
|
+
_reconnectAttempts += 1;
|
|
94
118
|
_reconnectTimer = setTimeout(function () {
|
|
95
119
|
_reconnectTimer = null;
|
|
96
120
|
_attemptConnect();
|
|
97
|
-
|
|
121
|
+
// Keep trying: _attemptConnect only fires once, so without this a failed
|
|
122
|
+
// attempt that never reaches `rejected` or `disconnected` would end the
|
|
123
|
+
// retry chain and leave the editor permanently offline.
|
|
124
|
+
_scheduleReconnect();
|
|
125
|
+
}, delay);
|
|
98
126
|
}
|
|
99
127
|
|
|
100
128
|
function _attemptConnect() {
|
|
@@ -113,6 +141,9 @@ var WebSocketService = (function () {
|
|
|
113
141
|
connected: function () {
|
|
114
142
|
_connected = true;
|
|
115
143
|
_status = 'connected';
|
|
144
|
+
// Back to fast retries for the next blip.
|
|
145
|
+
_reconnectAttempts = 0;
|
|
146
|
+
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
|
116
147
|
},
|
|
117
148
|
disconnected: function () {
|
|
118
149
|
_status = _status === 'rejected' ? 'rejected' : 'dropped';
|
|
@@ -32,17 +32,22 @@ module Mbeditor
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# Expand path and confirm it's inside workspace_root.
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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
|
|
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
|
-
#
|
|
159
|
-
# inside the repo cannot escape it
|
|
160
|
-
# ApplicationController#resolve_path
|
|
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
|
|
261
|
+
return :git if git_tier?(root)
|
|
249
262
|
|
|
250
263
|
:grep
|
|
251
264
|
end
|
|
@@ -8,7 +8,12 @@ module Mbeditor
|
|
|
8
8
|
# Mbeditor channels so the development console stays readable.
|
|
9
9
|
# Non-Mbeditor ActionCable messages pass through unchanged.
|
|
10
10
|
class CableLogFilter < SimpleDelegator
|
|
11
|
-
|
|
11
|
+
# Both stream names, not just the editor one. Action Cable's broadcast line
|
|
12
|
+
# ("Broadcasting to <stream>: <payload>") names only the stream, so a
|
|
13
|
+
# channel-class pattern never matches it — and the collaboration stream
|
|
14
|
+
# broadcasts on every keystroke and every cursor move, with the base64 CRDT
|
|
15
|
+
# payload inlined. That floods a development log during pairing.
|
|
16
|
+
SUPPRESS_PATTERN = /Mbeditor::|mbeditor_editor|mbeditor_collab/
|
|
12
17
|
CABLE_WEBSOCKET_REQUEST_PATTERN = /(?:Started|Finished) "\/cable(?:\/[^\"]*)?" \[WebSocket\]/
|
|
13
18
|
|
|
14
19
|
# Provides no-op tagged logging APIs for plain Ruby formatters.
|
|
@@ -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
|
-
|
|
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
|
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.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-
|
|
11
|
+
date: 2026-08-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|