mbeditor 0.14.0 → 0.14.2

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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/app/assets/javascripts/mbeditor/application.js +2 -0
  4. data/app/assets/javascripts/mbeditor/components/CombinedFileView.js +228 -0
  5. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +2 -1
  6. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +18 -1
  7. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +60 -5
  8. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +98 -9
  9. data/app/assets/javascripts/mbeditor/components/PastePlaceModal.js +329 -0
  10. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +16 -0
  11. data/app/assets/javascripts/mbeditor/editor_plugins.js +20 -20
  12. data/app/assets/javascripts/mbeditor/file_service.js +10 -0
  13. data/app/assets/javascripts/mbeditor/tab_manager.js +3 -0
  14. data/app/assets/stylesheets/mbeditor/editor.css +33 -1
  15. data/app/assets/stylesheets/mbeditor/glass.css +11 -0
  16. data/app/assets/stylesheets/mbeditor/themes.css +3 -3
  17. data/app/controllers/mbeditor/editors_controller.rb +7 -0
  18. data/app/services/mbeditor/ruby_method_ranges_service.rb +120 -0
  19. data/lib/mbeditor/rack/pending_migration_bypass.rb +16 -1
  20. data/lib/mbeditor/route_map.rb +1 -0
  21. data/lib/mbeditor/version.rb +1 -1
  22. data/public/monaco-editor/VERSIONS +1 -1
  23. data/public/monaco-editor/codicon.ttf +0 -0
  24. data/public/monaco-editor/css.worker.js +41 -37
  25. data/public/monaco-editor/editor.worker.js +19 -15
  26. data/public/monaco-editor/html.worker.js +53 -49
  27. data/public/monaco-editor/json.worker.js +36 -32
  28. data/public/monaco-editor/monaco.css +1 -1
  29. data/public/monaco-editor/monaco.js +388 -301
  30. data/public/monaco-editor/ts.worker.js +226 -222
  31. data/vendor/assets/javascripts/axios.min.js +3 -3
  32. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89b467923bfe67c35d68e57b667357c96c4217c39fff779f5ff98911eed59f25
4
- data.tar.gz: e1b82cc6eddeaec6d31f00f83eff3b3e56abba423411304c9025b68dd9efbd81
3
+ metadata.gz: ee847e8e0df709bf840fedf585eafa79ef7097336b60f081174d5fc464244c9d
4
+ data.tar.gz: b787b613243fa9848f785d9931a6464b1356ee9207b7ace7bd27c92b8ea6064f
5
5
  SHA512:
6
- metadata.gz: 1e15d0215eba919681e9a435de8aeadcd3d24bbc1c9dc72f95f9036c2a39a89e39b28461d2acce844b4a9b5c2b1507318d84da3cdae62b08162f6412daa4dd8c
7
- data.tar.gz: 611f9efe9f06536eb81ecf2b8f09d2da282958a17131b56baca6b47c07d10c2508a7797cba2f5469df457c2647b052a26f97a281fa8f44d7e574a2d89fe12d83
6
+ metadata.gz: 6e49e0b4921e6e84e4064d7c5f497f57f7ea8992671b9deb773e11adf97c77e7dd2fd6fc83e177147b03ab2d79942b08569b6284b12e75bb6d67771e27d50950
7
+ data.tar.gz: c55fa561506959fdd07b43dabeddab66900e82e3795cb5e4a2c264f89cec4862a298bee59e5419b4c8c61ec6a10fa74a1b699cc078aae4788cb1faad8c539297
data/CHANGELOG.md CHANGED
@@ -5,6 +5,70 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.14.2] - 2026-10-20
9
+
10
+ ### Added
11
+ - **Combined View picker.** One "Copy .md" / "Download .md" at the top
12
+ replaces the per-file buttons, above a scrollable picker with a checkbox per
13
+ file and, for `.rb` files, per method. Unticked files are left out; ticked
14
+ methods limit a file to those methods.
15
+ - The Markdown export now opens with a `# Files` index of the included paths.
16
+ - **Open all editors in Combined View**: a stacked-layers action on the OPEN
17
+ EDITORS header.
18
+
19
+ ### Fixed
20
+ - **Checkboxes inside editor panes did not respond to clicks.** The pane's
21
+ click-capture focus handler set store state on every click, and under the
22
+ legacy React root that re-render reset the controlled checkbox before React
23
+ saw it change. Focusing is now a no-op when nothing changes, and happens on
24
+ mousedown.
25
+ - **The editor right-click menu was dim and unreadable** (since Monaco 0.57):
26
+ it rendered in a shadow root the theme's `--vscode-*` variables cannot reach.
27
+ Editors now use `useShadowDOM: false`.
28
+ - **The explorer's workspace header went transparent on hover** with Liquid
29
+ Glass off, letting the scrolled tree show through.
30
+
31
+ ## [0.14.1] - 2026-10-19
32
+
33
+ ### Added
34
+ - **Combined View.** Ctrl/Cmd-select files or folders in the explorer, right-click,
35
+ "Open in Combined View": every file on one scrolling page under its path, with
36
+ Copy and Download per file. Folders expand recursively; capped at 200 files with
37
+ a visible note. The tab is virtual and is not restored on reload.
38
+ - **Method picking and Markdown export.** In Combined View, Ruby files get a
39
+ method checklist; "Download .md" / "Copy .md" bundle the view into one file,
40
+ method-limited files keeping their `class`/`module` skeleton with `# ...`
41
+ between pieces. Built for chat assistants that take only a few attachments.
42
+ - **Paste & Place** (editor context menu or F1, Ruby). Paste methods from a
43
+ chat reply and mbeditor finds where each goes: the active file, then a repo
44
+ search for `def name` narrowed by class. New methods go into the file that
45
+ holds the class, before `private`. Diff preview first; Apply is one undoable
46
+ edit in the buffer and nothing is saved.
47
+ - **Compare on upload conflicts.** When an upload or drop would overwrite a
48
+ file, each text file gets a "Compare" diff of disk vs incoming.
49
+ - `POST /ruby_methods`: Ripper-based method ranges (owner, singleton, doc
50
+ comment start) behind the two features above.
51
+ - A help-panel section describing all of the above.
52
+
53
+ ### Fixed
54
+ - **Unreadable status bar and buttons.** The Liquid Glass status bar and the
55
+ title-bar buttons painted "text on the accent fill" onto panel-coloured
56
+ surfaces; glass also hid the amber pending-migration background; Dracula,
57
+ Night Owl and Tomorrow Night had white text on light accents. 44 combinations
58
+ (11 themes × glass × pending) now all clear 4.5:1 on the status bar.
59
+ - **Flicker while migrations are pending.** `/cable` was not covered by the
60
+ pending-migration bypass, so every reconnect raised and the live channel
61
+ (presence, collaboration, file-change push) stayed dead.
62
+ - Paste & Place searches use POSIX classes, because the `git grep` search tier
63
+ is ERE and silently matches nothing for `\s`.
64
+
65
+ ### Changed
66
+ - **Monaco 0.57.0** (from 0.55.1). Its TypeScript API moved from
67
+ `monaco.languages.typescript` to `monaco.typescript`; every call site is
68
+ migrated. DOMPurify inside Monaco is still 3.4.15 (advisory rated low) until a
69
+ Monaco release ships 3.4.16+.
70
+ - **axios 1.20.0** (from 1.17.0 vendored), for new high-severity advisories.
71
+
8
72
  ## [0.14.0] - 2026-09-21
9
73
 
10
74
  ### Added
@@ -26,6 +26,7 @@
26
26
  //= require mbeditor/components/CommitGraph
27
27
  //= require mbeditor/components/ModelGraph
28
28
  //= require mbeditor/components/ChangelogView
29
+ //= require mbeditor/components/CombinedFileView
29
30
  //= require mbeditor/components/FileHistoryPanel
30
31
  //= require mbeditor/components/TestResultsPanel
31
32
  //= require mbeditor/components/LogPanel
@@ -42,5 +43,6 @@
42
43
  // whole app down the first time it renders.
43
44
  //= require mbeditor/components/ImportConflictModal
44
45
  //= require mbeditor/components/ImportDialog
46
+ //= require mbeditor/components/PastePlaceModal
45
47
  //= require mbeditor/components/MbeditorApp
46
48
  //= require mbeditor/application_iife_tail
@@ -0,0 +1,228 @@
1
+ "use strict";
2
+
3
+ // CombinedFileView — read-only stack of several files on one scrolling page.
4
+ // `paths` are workspace-relative file paths (already capped by the caller);
5
+ // `total` is the pre-cap count so truncation is shown, never silent.
6
+
7
+ (function() {
8
+ var useEffect = React.useEffect;
9
+ var useState = React.useState;
10
+ var CONCURRENCY = 6;
11
+
12
+ function languageFor(path) {
13
+ if (!(window.monaco && monaco.editor && monaco.editor.colorize)) return null;
14
+ var m = /\.[^./]+$/.exec(path);
15
+ if (!m) return null;
16
+ var ext = m[0].toLowerCase();
17
+ var lang = monaco.languages.getLanguages().find(function (l) { return (l.extensions || []).indexOf(ext) !== -1; });
18
+ return lang ? lang.id : null;
19
+ }
20
+
21
+ function copyText(text, okMsg, failMsg) {
22
+ if (!navigator.clipboard) return;
23
+ navigator.clipboard.writeText(text).then(
24
+ function () { EditorStore.setStatus(okMsg, 'info'); },
25
+ function () { EditorStore.setStatus(failMsg, 'error'); }
26
+ );
27
+ }
28
+
29
+ function indentOf(line) { return /^\s*/.exec(line || '')[0]; }
30
+
31
+ // The selected methods (indexes into info.methods) with the class/module
32
+ // skeleton that holds them, in source order. Pieces that are not adjacent
33
+ // inside the same owner are separated by a `# ...` line.
34
+ function methodSlice(content, info, picked) {
35
+ var lines = content.split('\n');
36
+ var out = [];
37
+ var open = [];
38
+ var prev = null;
39
+ function contains(o, m) { return o.start_line <= m.start_line && o.end_line >= m.end_line; }
40
+ function close(n) {
41
+ while (open.length > n) { var o = open.pop(); out.push(indentOf(lines[o.start_line - 1]) + 'end'); }
42
+ }
43
+ info.methods.filter(function (m, i) { return picked[i]; })
44
+ .sort(function (a, b) { return a.start_line - b.start_line; })
45
+ .forEach(function (m) {
46
+ var chain = info.owners.filter(function (o) { return contains(o, m); });
47
+ var keep = 0;
48
+ while (keep < open.length && keep < chain.length && open[keep] === chain[keep]) keep++;
49
+ close(keep);
50
+ var reopened = open.length < chain.length;
51
+ for (var k = open.length; k < chain.length; k++) { out.push(lines[chain[k].start_line - 1]); open.push(chain[k]); }
52
+ if (prev && !reopened && m.doc_start_line > prev.end_line + 1) out.push(indentOf(lines[m.start_line - 1]) + '# ...');
53
+ else if (prev && !reopened) out.push('');
54
+ out.push.apply(out, lines.slice(m.doc_start_line - 1, m.end_line));
55
+ prev = m;
56
+ });
57
+ close(0);
58
+ return out.join('\n');
59
+ }
60
+
61
+ function fenceFor(text) {
62
+ var f = '```';
63
+ while (text.indexOf(f) !== -1) f += '`';
64
+ return f;
65
+ }
66
+
67
+ // paths: ordered file paths; files: path -> loaded entry; info: path -> {methods, owners};
68
+ // picked: path -> {methodIndex: true}; excluded: path -> true. A file with any picked
69
+ // method exports only those. Returns '' when no loaded file is included.
70
+ function buildMarkdown(paths, files, info, picked, excluded) {
71
+ var index = [];
72
+ var parts = [];
73
+ paths.forEach(function (path) {
74
+ var e = files[path];
75
+ if (excluded[path] || !e || e.content == null) return;
76
+ var sel = picked[path] || {};
77
+ var body = e.content;
78
+ var line = '- ' + path;
79
+ if (info[path] && info[path].methods && Object.keys(sel).some(function (k) { return sel[k]; })) {
80
+ body = methodSlice(e.content, info[path], sel);
81
+ line += ' (methods: ' + info[path].methods.filter(function (m, i) { return sel[i]; }).map(function (m) { return m.name; }).join(', ') + ')';
82
+ }
83
+ index.push(line);
84
+ var fence = fenceFor(body);
85
+ var ext = (/\.([^./]+)$/.exec(path) || [])[1] || '';
86
+ var lang = { rb: 'ruby', js: 'javascript', jsx: 'jsx', ts: 'typescript', tsx: 'tsx', yml: 'yaml', md: 'markdown', sh: 'bash' }[ext] || ext;
87
+ parts.push('## ' + path + '\n\n' + fence + lang + '\n' + body.replace(/\n$/, '') + '\n' + fence + '\n');
88
+ });
89
+ return index.length ? '# Files\n' + index.join('\n') + '\n\n' + parts.join('\n') : '';
90
+ }
91
+
92
+ function bundleName() {
93
+ return 'mbeditor-bundle-' + new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-') + '.md';
94
+ }
95
+
96
+ function downloadText(text, filename) {
97
+ var objectUrl = URL.createObjectURL(new Blob([text], { type: 'text/markdown' }));
98
+ var link = document.createElement('a');
99
+ link.href = objectUrl;
100
+ link.download = filename;
101
+ link.rel = 'noopener';
102
+ document.body.appendChild(link);
103
+ link.click();
104
+ document.body.removeChild(link);
105
+ // Revoking in the same tick can free the blob before the browser has
106
+ // started reading it, which cancels the download instead of saving it.
107
+ setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 60000);
108
+ EditorStore.setStatus('Downloading ' + filename + '...', 'info');
109
+ }
110
+
111
+ var CombinedFileView = function CombinedFileView(props) {
112
+ var paths = props.paths;
113
+ var _s = useState({});
114
+ var files = _s[0];
115
+ var setFiles = _s[1];
116
+ var _m = useState({});
117
+ var info = _m[0];
118
+ var setInfo = _m[1];
119
+ var asked = React.useRef({});
120
+ var _p = useState({});
121
+ var picked = _p[0];
122
+ var setPicked = _p[1];
123
+
124
+ // Method ranges for Ruby files, fetched once each file has loaded. No
125
+ // cancel-on-rerun: every file load re-runs this, and cancelling would drop
126
+ // the requests still in flight. The view is keyed per tab, so paths never
127
+ // change underneath it.
128
+ useEffect(function () {
129
+ paths.forEach(function (path) {
130
+ if (!/\.rb$/.test(path) || !files[path] || files[path].content == null || asked.current[path]) return;
131
+ asked.current[path] = true;
132
+ FileService.getRubyMethods(files[path].content).then(function (r) {
133
+ setInfo(function (prev) { var o = Object.assign({}, prev); o[path] = r.error ? { error: r.error } : r; return o; });
134
+ }, function () {
135
+ setInfo(function (prev) { var o = Object.assign({}, prev); o[path] = { error: 'Could not read methods' }; return o; });
136
+ });
137
+ });
138
+ }, [paths, files]);
139
+
140
+ var _x = useState({});
141
+ var excluded = _x[0];
142
+ var setExcluded = _x[1];
143
+
144
+ var toggle = function (path, i) {
145
+ setPicked(function (prev) {
146
+ var o = Object.assign({}, prev);
147
+ var sel = Object.assign({}, o[path] || {});
148
+ if (sel[i]) delete sel[i]; else sel[i] = true;
149
+ o[path] = sel;
150
+ return o;
151
+ });
152
+ setExcluded(function (prev) { var o = Object.assign({}, prev); delete o[path]; return o; });
153
+ };
154
+
155
+ var toggleFile = function (path) {
156
+ setExcluded(function (prev) { var o = Object.assign({}, prev); if (o[path]) delete o[path]; else o[path] = true; return o; });
157
+ };
158
+
159
+ var exportMd = function (action) {
160
+ var md = buildMarkdown(paths, files, info, picked, excluded);
161
+ if (!md) { EditorStore.setStatus('No files selected', 'info'); return; }
162
+ action(md);
163
+ };
164
+
165
+ useEffect(function () {
166
+ var cancelled = false;
167
+ var next = 0;
168
+ function put(path, entry) {
169
+ if (!cancelled) setFiles(function (prev) { var o = Object.assign({}, prev); o[path] = entry; return o; });
170
+ }
171
+ function worker() {
172
+ if (cancelled || next >= paths.length) return Promise.resolve();
173
+ var path = paths[next++];
174
+ return FileService.getFile(path).then(function (data) {
175
+ if (data && data.image) { put(path, { note: 'Image file, not shown' }); return; }
176
+ if (!data || typeof data.content !== 'string') { put(path, { note: 'Binary or unreadable file, not shown' }); return; }
177
+ var lang = languageFor(path);
178
+ if (!lang) { put(path, { content: data.content }); return; }
179
+ return monaco.editor.colorize(data.content, lang, { tabSize: 2 }).then(
180
+ function (html) { put(path, { content: data.content, html: html }); },
181
+ function () { put(path, { content: data.content }); }
182
+ );
183
+ })['catch'](function () { put(path, { note: 'Could not load file' }); }).then(worker);
184
+ }
185
+ for (var i = 0; i < CONCURRENCY; i++) worker();
186
+ return function () { cancelled = true; };
187
+ }, [paths]);
188
+
189
+ return React.createElement('div', { className: 'combined-view' },
190
+ React.createElement('div', { className: 'combined-toolbar' },
191
+ React.createElement('button', { type: 'button', className: 'combined-file-btn', onClick: function () { exportMd(function (md) { copyText(md, 'Copied Markdown bundle', 'Could not copy Markdown bundle'); }); } }, 'Copy .md'),
192
+ React.createElement('button', { type: 'button', className: 'combined-file-btn', onClick: function () { exportMd(function (md) { downloadText(md, bundleName()); }); } }, 'Download .md')
193
+ ),
194
+ React.createElement('div', { className: 'combined-picker' },
195
+ paths.map(function (path) {
196
+ var methods = info[path] && info[path].methods;
197
+ return React.createElement('div', { key: path },
198
+ React.createElement('label', { className: 'combined-pick-file' },
199
+ React.createElement('input', { type: 'checkbox', checked: !excluded[path], onChange: function () { toggleFile(path); } }),
200
+ path),
201
+ methods && methods.map(function (m, i) {
202
+ var label = (m.owner ? m.owner + (m.singleton ? '.' : '#') : (m.singleton ? '.' : '#')) + m.name;
203
+ return React.createElement('label', { key: i, className: 'combined-pick-method', title: 'lines ' + m.start_line + '-' + m.end_line },
204
+ React.createElement('input', { type: 'checkbox', checked: !!(picked[path] && picked[path][i]), onChange: function () { toggle(path, i); } }),
205
+ label);
206
+ })
207
+ );
208
+ })
209
+ ),
210
+ props.total > paths.length && React.createElement('div', { className: 'combined-note' },
211
+ 'Showing the first ' + paths.length + ' of ' + props.total + ' files.'),
212
+ paths.map(function (path) {
213
+ var e = files[path];
214
+ return React.createElement('section', { key: path, className: 'combined-file' },
215
+ React.createElement('div', { className: 'combined-file-header' },
216
+ React.createElement('span', { className: 'combined-file-path' }, path)
217
+ ),
218
+ !e ? React.createElement('div', { className: 'combined-note' }, 'Loading...') :
219
+ e.note ? React.createElement('div', { className: 'combined-note' }, e.note) :
220
+ e.html ? React.createElement('pre', { className: 'combined-file-body', dangerouslySetInnerHTML: { __html: e.html } }) :
221
+ React.createElement('pre', { className: 'combined-file-body' }, e.content)
222
+ );
223
+ })
224
+ );
225
+ };
226
+
227
+ window.CombinedFileView = CombinedFileView;
228
+ })();
@@ -39,7 +39,8 @@ var DiffViewer = function DiffViewer(_ref) {
39
39
  scrollBeyondLastLine: false,
40
40
  fontFamily: editorPrefs.fontFamily || "'JetBrains Mono', 'Fira Code', 'Menlo', 'Consolas', monospace",
41
41
  fontSize: editorPrefs.fontSize || 13,
42
- wordWrap: editorPrefs.wordWrap || 'off'
42
+ wordWrap: editorPrefs.wordWrap || 'off',
43
+ useShadowDOM: false // same unthemed-context-menu reason as EditorPanel
43
44
  });
44
45
 
45
46
  diffEditor.setModel({
@@ -661,6 +661,11 @@ var EditorPanel = function EditorPanel(_ref) {
661
661
  acceptSuggestionOnEnter: editorPrefs.acceptSuggestionOnEnter || 'on',
662
662
  linkedEditing: true,
663
663
  fixedOverflowWidgets: true,
664
+ // The context menu otherwise renders in a shadow root hung off our
665
+ // container, out of reach of the theme's --vscode-* variables (they live
666
+ // on .monaco-editor/.monaco-component), so it drew transparent with black
667
+ // text. Light DOM puts it in .context-view.monaco-component, which is themed.
668
+ useShadowDOM: false,
664
669
  hover: { above: false }
665
670
  });
666
671
 
@@ -792,6 +797,17 @@ var EditorPanel = function EditorPanel(_ref) {
792
797
  }
793
798
  });
794
799
 
800
+ // Paste & Place: Ruby only. An editor action gets the context-menu entry and
801
+ // the F1 command palette listing without another toolbar button.
802
+ var pastePlaceDisposable = editor.addAction({
803
+ id: 'mbeditor.pastePlace',
804
+ label: 'Paste & Place Ruby Method...',
805
+ precondition: 'editorLangId == ruby',
806
+ contextMenuGroupId: 'navigation',
807
+ contextMenuOrder: 1.7,
808
+ run: function() { EditorStore.setState({ isPastePlaceVisible: true }); }
809
+ });
810
+
795
811
  // No custom "Format Document" action: Ruby now has a real formatting
796
812
  // provider, so Monaco contributes that context-menu entry itself, and a
797
813
  // second one of our own showed up as a duplicate. Languages with no
@@ -1139,6 +1155,7 @@ var EditorPanel = function EditorPanel(_ref) {
1139
1155
  if (editorPluginDisposable) editorPluginDisposable.dispose();
1140
1156
  if (formatActionDisposable) formatActionDisposable.dispose();
1141
1157
  runTestAtCursorDisposable.dispose();
1158
+ pastePlaceDisposable.dispose();
1142
1159
  columnSelectDisposable.dispose();
1143
1160
  contentDisposable.dispose();
1144
1161
  EditorStore.setState({ canUndo: false, canRedo: false });
@@ -2183,7 +2200,7 @@ var EditorPanel = function EditorPanel(_ref) {
2183
2200
  // our own, which makes it async — the worker is where the parse already
2184
2201
  // lives, and it tracks the live buffer, so unsaved edits are included.
2185
2202
  function loadJsOutline(model) {
2186
- var ts = window.monaco && window.monaco.languages && window.monaco.languages.typescript;
2203
+ var ts = window.monaco && window.monaco.typescript;
2187
2204
  if (!ts || !window.JsOutline) return Promise.reject(new Error('outline unavailable'));
2188
2205
 
2189
2206
  var workerFor = model.getLanguageId() === 'typescript' ? ts.getTypeScriptWorker : ts.getJavaScriptWorker;
@@ -58,13 +58,51 @@ window.useModalFocusTrap = function useModalFocusTrap(modalRef, onDismiss) {
58
58
  }, [onDismiss]);
59
59
  };
60
60
 
61
+ // Compare is offered only for files that look like text and are small enough
62
+ // to diff in a modal. file.type is '' for most source files, so this rules
63
+ // out the known-binary families rather than requiring a text/* type.
64
+ var COMPARE_MAX_BYTES = 512 * 1024;
65
+ function canCompare(file) {
66
+ if (!file || file.size > COMPARE_MAX_BYTES) return false;
67
+ return !/^(image|audio|video|font)\/|^application\/(octet-stream|zip|pdf|gzip|x-)/.test(file.type || '');
68
+ }
69
+
61
70
  var ImportConflictModal = function ImportConflictModal(_ref) {
62
71
  var conflicts = _ref.conflicts || [];
63
72
  var errors = _ref.errors || [];
64
73
  var onResolve = _ref.onResolve;
65
74
  var modalRef = React.useRef(null);
75
+ var _cmp = React.useState(null);
76
+ var compare = _cmp[0];
77
+ var setCompare = _cmp[1];
78
+
79
+ // Incoming file per conflicting target path.
80
+ var incoming = React.useMemo(function () {
81
+ var byPath = {};
82
+ FileImport.conflictedEntries(_ref.entries || [], _ref.targetFolderPath, conflicts).forEach(function (e) {
83
+ byPath[FileImport.joinPath(_ref.targetFolderPath, e.relativePath)] = e.file;
84
+ });
85
+ return byPath;
86
+ }, [_ref.entries, _ref.targetFolderPath, conflicts]);
66
87
 
67
- window.useModalFocusTrap(modalRef, React.useCallback(function () { onResolve('skip'); }, [onResolve]));
88
+ // Escape closes an open comparison first; only then does it mean skip.
89
+ var dismiss = React.useCallback(function () {
90
+ if (compare) setCompare(null); else onResolve('skip');
91
+ }, [onResolve, compare]);
92
+ window.useModalFocusTrap(modalRef, dismiss);
93
+
94
+ var openCompare = function (path) {
95
+ var file = incoming[path];
96
+ setCompare({ path: path, loading: true });
97
+ Promise.all([FileService.getFile(path), file.text()]).then(function (r) {
98
+ var current = r[0] && r[0].content;
99
+ if (typeof current !== 'string' || r[1].indexOf('\u0000') !== -1) {
100
+ setCompare({ path: path, note: 'Binary or unreadable file, cannot compare.' });
101
+ } else {
102
+ setCompare({ path: path, original: current, modified: r[1] });
103
+ }
104
+ }, function () { setCompare({ path: path, note: 'Could not load the current file.' }); });
105
+ };
68
106
 
69
107
  var button = function (label, mode, className) {
70
108
  return React.createElement(
@@ -84,7 +122,7 @@ var ImportConflictModal = function ImportConflictModal(_ref) {
84
122
  'div',
85
123
  {
86
124
  ref: modalRef,
87
- className: 'schema-modal import-conflict-modal',
125
+ className: 'schema-modal import-conflict-modal' + (compare ? ' import-conflict-wide' : ''),
88
126
  role: 'dialog',
89
127
  'aria-modal': 'true',
90
128
  'aria-label': 'Resolve import conflicts',
@@ -95,16 +133,33 @@ var ImportConflictModal = function ImportConflictModal(_ref) {
95
133
  'div', { className: 'schema-modal-header' },
96
134
  React.createElement(
97
135
  'div', { className: 'schema-modal-title' },
98
- conflicts.length + ' file' + (conflicts.length === 1 ? '' : 's') + ' already exist' +
136
+ compare ? 'Current (left) vs incoming (right)' : conflicts.length + ' file' + (conflicts.length === 1 ? '' : 's') + ' already exist' +
99
137
  (conflicts.length === 1 ? 's' : '')
100
138
  )
101
139
  ),
102
- React.createElement(
140
+ compare && React.createElement(
141
+ 'div', { className: 'schema-modal-body import-conflict-compare-body' },
142
+ React.createElement('button', { type: 'button', className: 'import-conflict-btn', onClick: function () { setCompare(null); } }, 'Back'),
143
+ compare.original != null
144
+ ? React.createElement('div', { className: 'import-conflict-diff' },
145
+ React.createElement(window.DiffViewer, {
146
+ path: compare.path, original: compare.original, modified: compare.modified,
147
+ isDark: _ref.isDark, editorPrefs: _ref.editorPrefs
148
+ }))
149
+ : React.createElement('div', { className: 'combined-note' }, compare.note || 'Loading...')
150
+ ),
151
+ !compare && React.createElement(
103
152
  'div', { className: 'schema-modal-body' },
104
153
  React.createElement(
105
154
  'ul', { className: 'import-conflict-list' },
106
155
  conflicts.map(function (c) {
107
- return React.createElement('li', { key: c.path, title: c.path }, c.path);
156
+ return React.createElement('li', { key: c.path, title: c.path },
157
+ c.path,
158
+ canCompare(incoming[c.path]) && React.createElement('button', {
159
+ type: 'button', className: 'import-conflict-compare',
160
+ onClick: function () { openCompare(c.path); }
161
+ }, 'Compare')
162
+ );
108
163
  })
109
164
  ),
110
165
  errors.length > 0 && React.createElement(