mbeditor 0.13.1 → 0.14.1

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +132 -0
  3. data/app/assets/javascripts/mbeditor/application.js +5 -0
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +248 -26
  6. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +89 -94
  7. data/app/assets/javascripts/mbeditor/components/CodeReviewPanel.js +6 -9
  8. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +12 -7
  9. data/app/assets/javascripts/mbeditor/components/CombinedDiffViewer.js +20 -0
  10. data/app/assets/javascripts/mbeditor/components/CombinedFileView.js +214 -0
  11. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  12. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +442 -248
  13. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  14. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  15. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  16. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  17. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +60 -5
  18. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  19. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1241 -1256
  20. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  21. data/app/assets/javascripts/mbeditor/components/PastePlaceModal.js +329 -0
  22. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +47 -65
  23. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +26 -8
  24. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  25. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +18 -1
  26. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  27. data/app/assets/javascripts/mbeditor/editor_plugins.js +705 -323
  28. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  29. data/app/assets/javascripts/mbeditor/file_service.js +48 -39
  30. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  31. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  32. data/app/assets/javascripts/mbeditor/search_service.js +9 -0
  33. data/app/assets/javascripts/mbeditor/tab_manager.js +127 -49
  34. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  35. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  36. data/app/assets/stylesheets/mbeditor/editor.css +672 -249
  37. data/app/assets/stylesheets/mbeditor/glass.css +174 -0
  38. data/app/assets/stylesheets/mbeditor/themes.css +93 -33
  39. data/app/channels/mbeditor/collaboration_channel.rb +17 -4
  40. data/app/controllers/mbeditor/application_controller.rb +26 -3
  41. data/app/controllers/mbeditor/editors_controller.rb +124 -439
  42. data/app/services/mbeditor/archive_service.rb +137 -0
  43. data/app/services/mbeditor/collaboration_doc_store.rb +143 -14
  44. data/app/services/mbeditor/editor_state_service.rb +14 -51
  45. data/app/services/mbeditor/file_history_service.rb +222 -0
  46. data/app/services/mbeditor/git_info_service.rb +6 -0
  47. data/app/services/mbeditor/js_syntax_check_service.rb +42 -16
  48. data/app/services/mbeditor/lint_service.rb +137 -0
  49. data/app/services/mbeditor/locked_json_file.rb +67 -0
  50. data/app/services/mbeditor/process_runner.rb +32 -0
  51. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  52. data/app/services/mbeditor/ruby_method_ranges_service.rb +120 -0
  53. data/app/services/mbeditor/search_replace_service.rb +8 -0
  54. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  55. data/lib/mbeditor/audit_log.rb +203 -0
  56. data/lib/mbeditor/configuration.rb +6 -1
  57. data/lib/mbeditor/rack/pending_migration_bypass.rb +31 -10
  58. data/lib/mbeditor/route_map.rb +5 -0
  59. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  60. data/lib/mbeditor/version.rb +1 -1
  61. data/lib/mbeditor.rb +1 -0
  62. data/public/monaco-editor/VERSIONS +1 -1
  63. data/public/monaco-editor/codicon.ttf +0 -0
  64. data/public/monaco-editor/css.worker.js +41 -37
  65. data/public/monaco-editor/editor.worker.js +19 -15
  66. data/public/monaco-editor/html.worker.js +53 -49
  67. data/public/monaco-editor/json.worker.js +36 -32
  68. data/public/monaco-editor/monaco.css +1 -1
  69. data/public/monaco-editor/monaco.js +388 -301
  70. data/public/monaco-editor/ts.worker.js +226 -222
  71. data/vendor/assets/javascripts/axios.min.js +3 -3
  72. metadata +15 -2
@@ -44,6 +44,32 @@ var ProblemsPanel = (function () {
44
44
  return text.length > CODE_PREVIEW_LIMIT ? text.slice(0, CODE_PREVIEW_LIMIT) + '…' : text;
45
45
  }
46
46
 
47
+ // VS Code-style file group header: chevron, file-type icon, basename, then
48
+ // the containing directory in a dimmer colour. `path` is workspace-relative.
49
+ function fileGroupName(path) {
50
+ var slash = path.lastIndexOf('/');
51
+ return {
52
+ base: slash === -1 ? path : path.slice(slash + 1),
53
+ dir: slash === -1 ? '' : path.slice(0, slash)
54
+ };
55
+ }
56
+
57
+ function renderFileGroupHeader(path, count) {
58
+ var parts = fileGroupName(path);
59
+ return React.createElement(
60
+ 'div',
61
+ { className: 'ide-problems-file-name' },
62
+ React.createElement('i', { className: 'fas fa-chevron-down', 'aria-hidden': 'true' }),
63
+ React.createElement('i', {
64
+ className: (window.getFileIcon ? window.getFileIcon(parts.base) : 'far fa-file-code'),
65
+ 'aria-hidden': 'true'
66
+ }),
67
+ React.createElement('span', null, parts.base),
68
+ parts.dir && React.createElement('span', { className: 'ide-problems-dir' }, parts.dir),
69
+ React.createElement('span', { className: 'ide-problems-file-count' }, count)
70
+ );
71
+ }
72
+
47
73
  // Reading markers means walking every model, so callers that only want the
48
74
  // counts share this one pass. Exposed on the component for the status bar.
49
75
  function collect() {
@@ -111,6 +137,9 @@ var ProblemsPanel = (function () {
111
137
 
112
138
  var Panel = function ProblemsPanelComponent(_ref) {
113
139
  var onClose = _ref.onClose;
140
+ // Height is owned by MbeditorApp: the gutter that drags it lives between
141
+ // the editor card and this drawer, not inside it.
142
+ var height = _ref.height;
114
143
  var onOpenFile = _ref.onOpenFile;
115
144
  // Called after `-a` has rewritten files on disk, so the app can re-read
116
145
  // every open tab. Nothing else would: the write came from a subprocess,
@@ -226,34 +255,6 @@ var ProblemsPanel = (function () {
226
255
  });
227
256
  };
228
257
 
229
- var MIN_HEIGHT = 120;
230
- var _height = React.useState(function () {
231
- var saved = parseInt(window.localStorage.getItem('mbeditorProblemsHeight'), 10);
232
- return (saved && saved >= MIN_HEIGHT) ? saved : 240;
233
- });
234
- var height = _height[0], setHeight = _height[1];
235
- var heightRef = React.useRef(height);
236
- heightRef.current = height;
237
-
238
- // Same delta-based resize as the log drawer.
239
- var onResizeMouseDown = function (e) {
240
- e.preventDefault();
241
- var startY = e.clientY;
242
- var startHeight = heightRef.current;
243
- var onMove = function (ev) {
244
- var vh = window.innerHeight || document.documentElement.clientHeight || 0;
245
- var maxH = vh > 0 ? Math.round(vh * 0.85) : Infinity;
246
- setHeight(Math.min(maxH, Math.max(MIN_HEIGHT, startHeight + (startY - ev.clientY))));
247
- };
248
- var onUp = function () {
249
- document.removeEventListener('mousemove', onMove);
250
- document.removeEventListener('mouseup', onUp);
251
- window.localStorage.setItem('mbeditorProblemsHeight', String(heightRef.current));
252
- };
253
- document.addEventListener('mousemove', onMove);
254
- document.addEventListener('mouseup', onUp);
255
- };
256
-
257
258
  React.useEffect(function () {
258
259
  if (!window.monaco || !window.monaco.editor) return;
259
260
  // Debounced for the same reason the status-bar tally is: markers change
@@ -314,16 +315,11 @@ var ProblemsPanel = (function () {
314
315
  return React.createElement(
315
316
  'div',
316
317
  { className: 'ide-problems-drawer', style: { height: height + 'px' } },
317
- React.createElement('div', {
318
- className: 'ide-problems-resize',
319
- title: 'Drag to resize',
320
- onMouseDown: onResizeMouseDown
321
- }),
322
318
  React.createElement(
323
319
  'div',
324
320
  { className: 'ide-problems-header' },
325
- React.createElement('i', { className: 'fas fa-bug' }),
326
321
  React.createElement('span', { className: 'ide-problems-title' }, 'Problems'),
322
+ React.createElement('span', { className: 'ide-problems-total-pill' }, total + wsCounts.error + wsCounts.warning + wsCounts.info),
327
323
  // No prose summary: the severity chips below carry the same three
328
324
  // counts, and restating them was the longest thing in the header.
329
325
  React.createElement(
@@ -340,7 +336,7 @@ var ProblemsPanel = (function () {
340
336
  {
341
337
  key: s.kind,
342
338
  type: 'button',
343
- className: 'ide-problems-sev-chip ide-problems-sev-' + s.kind + (on ? ' is-on' : ''),
339
+ className: 'ide-problems-sev-chip' + (on ? ' is-on' : ''),
344
340
  title: (on ? 'Hide' : 'Show') + ' ' + SEVERITY_LABEL[s.kind].toLowerCase() + 's',
345
341
  'aria-pressed': on ? 'true' : 'false',
346
342
  onClick: function () { toggleSeverity(s.kind); }
@@ -350,19 +346,25 @@ var ProblemsPanel = (function () {
350
346
  );
351
347
  })
352
348
  ),
349
+ React.createElement('input', {
350
+ className: 'ide-problems-filter',
351
+ type: 'text',
352
+ placeholder: 'Filter…',
353
+ value: filter,
354
+ onChange: function (e) { setFilter(e.target.value); }
355
+ }),
353
356
  React.createElement(
354
357
  'div',
355
358
  { className: 'ide-problems-actions' },
356
359
  React.createElement('button', {
357
- type: 'button', className: 'ide-problems-btn',
360
+ type: 'button', className: 'ide-icon-btn',
358
361
  disabled: !!running,
359
362
  title: 'Run rubocop over the whole workspace',
360
363
  onClick: function () { runRubocop('check'); }
361
364
  },
362
- React.createElement('i', { className: running === 'check' ? 'fas fa-spinner fa-spin' : 'fas fa-play' }),
363
- React.createElement('span', null, ' RuboCop')),
365
+ React.createElement('i', { className: running === 'check' ? 'fas fa-spinner fa-spin' : 'fas fa-play' })),
364
366
  React.createElement('button', {
365
- type: 'button', className: 'ide-problems-btn',
367
+ type: 'button', className: 'ide-icon-btn',
366
368
  // Nothing to correct until a run says so, and rerunning `-a` on a
367
369
  // clean tree is a slow no-op.
368
370
  disabled: !!running || !workspace || !workspace.correctable,
@@ -373,28 +375,19 @@ var ProblemsPanel = (function () {
373
375
  : 'Run RuboCop first',
374
376
  onClick: function () { runRubocop('autocorrect'); }
375
377
  },
376
- React.createElement('i', { className: running === 'autocorrect' ? 'fas fa-spinner fa-spin' : 'fas fa-magic' }),
377
- React.createElement('span', null, ' Fix')),
378
+ React.createElement('i', { className: running === 'autocorrect' ? 'fas fa-spinner fa-spin' : 'fas fa-magic' })),
378
379
  React.createElement('button', {
379
- type: 'button', className: 'ide-problems-btn',
380
+ type: 'button', className: 'ide-icon-btn',
380
381
  disabled: !workspace || !(workspace.files || []).length,
381
382
  title: workspace && (workspace.files || []).length
382
383
  ? 'Open all ' + workspace.files.length + ' file(s) with offenses'
383
384
  : 'Run RuboCop first',
384
385
  onClick: openAllOffending
385
386
  },
386
- React.createElement('i', { className: 'fas fa-folder-open' }),
387
- React.createElement('span', null, ' Open all'))
387
+ React.createElement('i', { className: 'fas fa-folder-open' }))
388
388
  ),
389
- React.createElement('input', {
390
- className: 'ide-problems-filter',
391
- type: 'text',
392
- placeholder: 'Filter…',
393
- value: filter,
394
- onChange: function (e) { setFilter(e.target.value); }
395
- }),
396
389
  React.createElement('button', {
397
- type: 'button', className: 'ide-problems-btn',
390
+ type: 'button', className: 'ide-icon-btn',
398
391
  title: 'Close', onClick: onClose
399
392
  }, React.createElement('i', { className: 'fas fa-times' }))
400
393
  ),
@@ -412,13 +405,7 @@ var ProblemsPanel = (function () {
412
405
  return React.createElement(
413
406
  'div',
414
407
  { className: 'ide-problems-file', key: 'ws:' + file.path },
415
- React.createElement(
416
- 'div',
417
- { className: 'ide-problems-file-name' },
418
- React.createElement('i', { className: 'fas fa-gavel', 'aria-hidden': 'true' }),
419
- ' ' + file.path,
420
- React.createElement('span', { className: 'ide-problems-file-count' }, file.offenses.length)
421
- ),
408
+ renderFileGroupHeader(file.path, file.offenses.length),
422
409
  file.offenses.map(function (o, i) {
423
410
  var kind = COP_SEVERITY_KIND[o.severity] || 'info';
424
411
  return React.createElement(
@@ -502,12 +489,7 @@ var ProblemsPanel = (function () {
502
489
  return React.createElement(
503
490
  'div',
504
491
  { className: 'ide-problems-file', key: entry.path },
505
- React.createElement(
506
- 'div',
507
- { className: 'ide-problems-file-name' },
508
- entry.path,
509
- React.createElement('span', { className: 'ide-problems-file-count' }, entry.markers.length)
510
- ),
492
+ renderFileGroupHeader(entry.path, entry.markers.length),
511
493
  entry.markers.map(function (item, index) {
512
494
  var marker = item.marker;
513
495
  var kind = SEVERITY_KIND[marker.severity] || 'info';
@@ -65,10 +65,19 @@ function recentRanks() {
65
65
  // in — a typo'd or transposed query hits nothing literally, so an exact match
66
66
  // can never be demoted by loosening the index search.
67
67
  var FUZZY_ONLY = 4;
68
+
69
+ // CamelCase -> snake_case, so a query like "ThemeController" ranks against
70
+ // "theme_controller.rb" the same way "theme_controller" would. A no-op
71
+ // (beyond lowercasing) for queries with no lower-to-upper transition, so
72
+ // non-CamelCase ranking is unchanged.
73
+ function toSnakeCase(q) {
74
+ return (q || '').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
75
+ }
76
+
68
77
  function getMatchRelevance(result, q) {
69
78
  if (!q) return FUZZY_ONLY;
70
79
  var name = (result.name || (result.path || '').split('/').pop() || '').toLowerCase();
71
- var lq = q.toLowerCase();
80
+ var lq = toSnakeCase(q);
72
81
  if (name === lq) return 0;
73
82
  if (name.slice(0, lq.length) === lq) return 1;
74
83
  if (name.indexOf(lq) >= 0) return 2;
@@ -194,7 +203,17 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
194
203
  // path in the workspace ran on every keystroke, between the keypress and
195
204
  // the character appearing.
196
205
  var timer = setTimeout(function () {
197
- var filtered = rankResults(SearchService.searchFiles(query), query, showFolders);
206
+ var hits = SearchService.searchFiles(query);
207
+ // CamelCase query, e.g. "ThemeController": also search its snake_case
208
+ // form so it finds theme_controller.rb, whose index terms are snake_case.
209
+ var snake = toSnakeCase(query);
210
+ if (snake !== query.toLowerCase()) {
211
+ var seen = new Set(hits.map(function (r) { return r.path; }));
212
+ SearchService.searchFiles(snake).forEach(function (r) {
213
+ if (!seen.has(r.path)) { seen.add(r.path); hits.push(r); }
214
+ });
215
+ }
216
+ var filtered = rankResults(hits, query, showFolders);
198
217
  setResults(filtered.slice(0, 200));
199
218
  setSelectedIndex(0);
200
219
  }, 120);
@@ -291,7 +310,7 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
291
310
  return React.createElement(
292
311
  'div',
293
312
  { className: 'quick-open-section' },
294
- React.createElement('div', { className: 'quick-open-section-header' },
313
+ React.createElement('div', { className: 'quick-open-section-header quick-open-section-header-muted' },
295
314
  React.createElement('i', { className: 'fas fa-history', style: { marginRight: '6px', fontSize: '10px' } }),
296
315
  'Recent Searches'
297
316
  ),
@@ -303,6 +322,8 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
303
322
  var recentFiles = (typeof TabManager !== 'undefined' && TabManager.getRecentFiles)
304
323
  ? TabManager.getRecentFiles() : [];
305
324
  if (recentFiles.length === 0) return null;
325
+ // No section header here (unlike Favourites/Recent Searches) — VS Code
326
+ // marks each row inline with a trailing "recently opened" label instead.
306
327
  var rows = recentFiles.map(function (entry) {
307
328
  var path = entry.path;
308
329
  var name = entry.name || (path.split('/').pop());
@@ -320,16 +341,13 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
320
341
  React.createElement('div', { className: 'quick-open-result-name' }, name),
321
342
  React.createElement('div', { className: 'quick-open-result-path' }, path)
322
343
  ),
344
+ React.createElement('span', { className: 'quick-open-recent-label' }, 'recently opened'),
323
345
  renderStarBtn(path)
324
346
  );
325
347
  });
326
348
  return React.createElement(
327
349
  'div',
328
350
  { className: 'quick-open-section' },
329
- React.createElement('div', { className: 'quick-open-section-header' },
330
- React.createElement('i', { className: 'fas fa-clock', style: { marginRight: '6px', fontSize: '10px' } }),
331
- 'Recently Opened'
332
- ),
333
351
  rows
334
352
  );
335
353
  }
@@ -349,7 +367,7 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
349
367
  ref: inputRef,
350
368
  type: 'text',
351
369
  className: 'quick-open-input',
352
- placeholder: 'Search files by name (Ctrl+P)…',
370
+ placeholder: 'Search files by name',
353
371
  value: query,
354
372
  onChange: function (e) { setQuery(e.target.value); },
355
373
  onKeyDown: handleKeyDown
@@ -0,0 +1,342 @@
1
+ 'use strict';
2
+
3
+ // SettingsModal — the editor preferences screen, as a VS Code-style modal.
4
+ // Owns the row catalogue (SETTINGS_ROWS) and the row renderer; MbeditorApp only
5
+ // mounts it and hands over the prefs pair. Dismissable with ×, the backdrop, or
6
+ // Escape.
7
+
8
+ // Settings rows, in the order they render. `{ header: ... }` entries start a
9
+ // section; everything else is a row descriptor consumed by renderSettingsRow.
10
+ // Checkbox default: plain `!!value` unless `def: true` (default checked,
11
+ // `value !== false`) or `strict: true` (`value === true`, used only where the
12
+ // on-state must be exact). Number default: `value || def` unless `nullish: true`
13
+ // (`value != null ? value : def`, needed so 0 is a valid stored value).
14
+ var SETTINGS_ROWS = [
15
+ { header: 'Appearance' },
16
+ { key: 'theme', type: 'select', label: 'Theme', title: 'Color theme for the editor', def: 'vs-dark', options: [
17
+ ['vs-dark', 'Dark'], ['vs', 'Light'], ['hc-black', 'HC Dark'], ['hc-light', 'HC Light'],
18
+ ['dracula', 'Dracula'], ['night-owl', 'Night Owl'], ['monokai', 'Monokai'], ['nord', 'Nord'],
19
+ ['github-dark', 'GitHub Dark'], ['tomorrow-night', 'Tomorrow Night'], ['github-light', 'GitHub Light']
20
+ ] },
21
+ { key: 'glass', type: 'checkbox', label: 'Liquid Glass chrome', title: 'Translucent, blurred panels layered over the current theme. Monaco stays solid' },
22
+ { key: 'fontSize', type: 'number', label: 'Font size', title: 'Editor font size in pixels (8–32)', min: 8, max: 32, step: 1, def: 13 },
23
+ { key: 'fontFamily', type: 'text', label: 'Font family', title: 'Font stack used in the editor — the first font available on your system is used', def: "'JetBrains Mono', 'Fira Code', Consolas, 'Courier New', monospace" },
24
+ { key: 'lineHeight', type: 'number', label: 'Line height (0=auto)', title: 'Row height in pixels. 0 = auto (roughly font size × 1.5)', min: 0, max: 100, step: 1, def: 0, nullish: true },
25
+ { key: 'letterSpacing', type: 'number', label: 'Letter spacing (px)', title: 'Extra space between characters in pixels. 0 = default', min: -5, max: 20, step: 0.5, parse: 'float', def: 0, nullish: true },
26
+
27
+ { header: 'Indentation' },
28
+ { key: 'tabSize', type: 'number', label: 'Tab size', title: 'Number of spaces per indentation level (also sets Prettier tab width)', min: 1, max: 8, step: 1, def: 4 },
29
+ { key: 'insertSpaces', type: 'checkbox', label: 'Use spaces', title: 'Insert spaces instead of tab characters when pressing Tab' },
30
+
31
+ { header: 'Editor' },
32
+ { key: 'wordWrap', type: 'select', label: 'Word wrap', title: 'How long lines are handled — Off: scroll horizontally, On: wrap at viewport width, Column: wrap at a fixed column', def: 'off', options: [
33
+ ['off', 'Off'], ['on', 'On'], ['wordWrapColumn', 'Column']
34
+ ] },
35
+ { key: 'lineNumbers', type: 'select', label: 'Line numbers', title: 'Show line numbers in the gutter — On, Off, or Relative (useful with Vim mode)', def: 'on', options: [
36
+ ['on', 'On'], ['off', 'Off'], ['relative', 'Relative']
37
+ ] },
38
+ { key: 'renderWhitespace', type: 'select', label: 'Whitespace', title: 'Render whitespace characters visually — None, Selection only, Boundary (leading/trailing), or All', def: 'none', options: [
39
+ ['none', 'None'], ['selection', 'Selection'], ['boundary', 'Boundary'], ['all', 'All']
40
+ ] },
41
+ { key: 'minimap', type: 'checkbox', label: 'Minimap', title: 'Show a scaled-down overview of the file on the right edge of the editor' },
42
+ { key: 'scrollBeyondLastLine', type: 'checkbox', label: 'Scroll past end', title: 'Allow scrolling past the last line so it can be positioned at the top of the viewport' },
43
+ { key: 'bracketPairColorization', type: 'checkbox', label: 'Bracket colors', title: 'Colorize matching bracket pairs with distinct colors to make nesting easier to read' },
44
+ { key: 'vimMode', type: 'checkbox', label: 'Vim mode', title: 'Enable Vim keybindings (Normal/Insert/Visual modes). Press Escape to return to Normal mode.' },
45
+ { key: 'autoClosingBrackets', type: 'select', label: 'Auto-close brackets', title: 'When to insert a matching closing bracket automatically', def: 'always', options: [
46
+ ['always', 'Always'], ['languageDefined', 'Per language rules'], ['beforeWhitespace', 'Only before whitespace'], ['never', 'Never']
47
+ ] },
48
+ { key: 'autoClosingQuotes', type: 'select', label: 'Auto-close quotes', title: 'When to insert a matching closing quote automatically', def: 'always', options: [
49
+ ['always', 'Always'], ['languageDefined', 'Per language rules'], ['beforeWhitespace', 'Only before whitespace'], ['never', 'Never']
50
+ ] },
51
+ { key: 'renderLineHighlight', type: 'select', label: 'Line highlight', title: 'What to highlight on the current editor line', def: 'none', options: [
52
+ ['none', 'None'], ['gutter', 'Line number only'], ['line', 'Current line background'], ['all', 'Line number + background']
53
+ ] },
54
+ { key: 'cursorStyle', type: 'select', label: 'Cursor style', title: 'Shape of the text cursor in the editor', def: 'line', options: [
55
+ ['line', 'Line (|)'], ['block', 'Block (filled)'], ['underline', 'Underline (_)'],
56
+ ['line-thin', 'Line thin'], ['block-outline', 'Block outline'], ['underline-thin', 'Underline thin']
57
+ ] },
58
+ { key: 'cursorBlinking', type: 'select', label: 'Cursor blinking', title: 'Cursor animation style — Blink (on/off), Smooth (fade), Phase (offset fade), Expand (grow), or Solid (no animation)', def: 'blink', options: [
59
+ ['blink', 'Blink (on/off)'], ['smooth', 'Smooth (fade)'], ['phase', 'Phase (offset fade)'],
60
+ ['expand', 'Expand (grow/shrink)'], ['solid', 'Solid (no blink)']
61
+ ] },
62
+ { key: 'folding', type: 'checkbox', label: 'Code folding', title: 'Show collapse arrows next to foldable regions (functions, classes, blocks)', def: true },
63
+ { key: 'smoothScrolling', type: 'checkbox', label: 'Smooth scrolling', title: 'Animate scrolling instead of jumping instantly' },
64
+ { key: 'mouseWheelZoom', type: 'checkbox', label: 'Ctrl+scroll to zoom', title: 'Hold Ctrl (or Cmd) and scroll the mouse wheel to zoom the font size' },
65
+
66
+ { header: 'Behaviour' },
67
+ { key: 'autoIndent', type: 'select', label: 'Auto indent', title: 'How aggressively the editor re-indents lines as you type', def: 'full', options: [
68
+ ['none', 'None (disabled)'], ['keep', 'Keep current level'], ['brackets', 'Indent on { and ['],
69
+ ['advanced', 'Language indent rules'], ['full', 'Full (language grammar)']
70
+ ] },
71
+ { key: 'acceptSuggestionOnEnter', type: 'select', label: 'Accept suggestion on Enter', title: 'Whether pressing Enter accepts the highlighted autocomplete suggestion', def: 'on', options: [
72
+ ['on', 'Always'], ['smart', 'Only when navigated (↑↓)'], ['off', 'Never (Tab only)']
73
+ ] },
74
+ { key: 'wordBasedSuggestions', type: 'select', label: 'Word-based suggestions', title: 'Suggest completions based on words already present in open files', def: 'matchingDocuments', options: [
75
+ ['off', 'Off'], ['currentDocument', 'Current file only'], ['matchingDocuments', 'Same language files'], ['allDocuments', 'All open files']
76
+ ] },
77
+ { key: 'formatOnType', type: 'checkbox', label: 'Format on type', title: 'Re-indent and auto-close blocks as you type (e.g. after pressing Enter inside {})', strict: true },
78
+ { key: 'formatOnSave', type: 'checkbox', label: 'Format on save', title: 'Format the file before every save — RuboCop -A for Ruby, Prettier for JS/JSX/CSS/HTML/Markdown', strict: true },
79
+ { key: 'quickSuggestions', type: 'checkbox', label: 'Quick suggestions', title: 'Show autocomplete suggestions while typing (not just on trigger characters like .)', def: true },
80
+
81
+ { header: 'Formatting' },
82
+ { key: 'prettierPrintWidth', type: 'number', label: 'Print width', title: 'Prettier: maximum line length before wrapping (40–200)', min: 40, max: 200, step: 1, def: 80, nullish: true },
83
+ { key: 'prettierTrailingComma', type: 'select', label: 'Trailing commas', title: 'Prettier: add trailing commas in multi-line expressions — All (ES2017+), ES5 (objects/arrays only), or None', def: 'all', options: [
84
+ ['all', 'All'], ['es5', 'ES5'], ['none', 'None']
85
+ ] },
86
+ { key: 'prettierSemi', type: 'checkbox', label: 'Semicolons', title: 'Prettier: add semicolons at the end of statements', def: true },
87
+ { key: 'prettierSingleQuote', type: 'checkbox', label: 'Single quotes', title: 'Prettier: use single quotes instead of double quotes for strings' },
88
+ { key: 'prettierBracketSpacing', type: 'checkbox', label: 'Bracket spacing', title: 'Prettier: add spaces inside object literal braces, e.g. { a: 1 } vs {a: 1}', def: true },
89
+
90
+ { header: 'Interface' },
91
+ { key: 'autoRevealInExplorer', type: 'checkbox', label: 'Explorer follows active file', title: 'Automatically scroll the file explorer to reveal and highlight the file you are editing' },
92
+ { key: 'fileTreeTypeahead', type: 'checkbox', label: 'Explorer type-ahead', title: 'Jump to a file in the explorer by typing its name when the sidebar is focused', def: true },
93
+ { key: 'showDotFiles', type: 'checkbox', label: 'Show dotfiles', title: 'Show hidden files and directories (those starting with a dot, e.g. .env, .gitignore) in the file explorer' },
94
+ { key: 'tabDisplayMode', type: 'select', label: 'Tab bar layout', title: 'Scroll: tabs overflow horizontally with a scrollbar; Wrap: tabs flow onto multiple rows', def: 'scroll', options: [
95
+ ['scroll', 'Scroll'], ['wrap', 'Wrap (multi-row)']
96
+ ] },
97
+ { key: 'quickOpenShowFolders', type: 'checkbox', label: 'Quick Open: show folders', title: 'Include folder names in the Quick Open picker (Ctrl+P / Cmd+P) results, not just files' },
98
+ // The stored preference, not the derived value: at a narrow width labels are
99
+ // dropped regardless, and the box must not appear to do nothing.
100
+ { key: 'toolbarLabels', type: 'checkbox', label: 'Toolbar: show labels', title: 'Show a text label beside each toolbar icon. Every button already names itself on hover' },
101
+ { key: 'persistFindState', type: 'checkbox', label: 'Persist find state across files', title: 'Keep the search/replace text when switching between files in the editor', def: true },
102
+ { key: 'branchStateRestore', type: 'checkbox', label: 'Restore tabs on branch switch', title: 'Save which files are open per branch and restore them when switching branches. Disable to always start with a clean slate when switching.', def: true },
103
+ { key: 'routeHints', type: 'checkbox', label: 'Controller route hints', title: 'Show the verb and path that route to each controller action after its def line, and mark public actions nothing routes to', def: true },
104
+
105
+ { header: 'RuboCop' },
106
+ { key: 'rubocopLintEnabled', type: 'checkbox', label: 'Enable RuboCop linting', title: 'Run RuboCop in the background and show lint warnings/errors as markers in the editor gutter', def: true },
107
+
108
+ { header: 'Diagnostics' },
109
+ { key: 'auditLog', type: 'checkbox', label: 'Record audit log', title: 'Record a numbers-only trace of editor activity you can download and hand to an AI to analyse. It never contains code, file names, URLs or host paths.', def: true },
110
+ // Lazy references: the handlers are declared in MbeditorApp.js, which loads
111
+ // after this file in the shared IIFE.
112
+ { key: 'auditLogDownload', type: 'button', label: 'Download log', action: 'Download', title: 'Save the recorded trace as a JSON file to hand to an AI. It carries numbers only — no code, file names, URLs or host paths', onClick: function () { downloadAuditLog(); } },
113
+ { key: 'auditLogClear', type: 'button', label: 'Clear log', action: 'Clear', title: 'Discard everything recorded so far, so the next download is a clean trace', onClick: function () { clearAuditLog(); } }
114
+ ];
115
+
116
+ function setEditorPref(setEditorPrefs, key, value) {
117
+ setEditorPrefs(function(p) {
118
+ var next = Object.assign({}, p);
119
+ next[key] = value;
120
+ return next;
121
+ });
122
+ }
123
+
124
+ // Label + description line, then the control. The checkbox is the exception:
125
+ // it sits inline before the label, with the description under both.
126
+ function settingsRowText(desc) {
127
+ return [
128
+ React.createElement('span', { className: 'ide-settings-label', key: 'l' }, desc.label),
129
+ desc.title ? React.createElement('span', { className: 'ide-settings-desc', key: 'd' }, desc.title) : null
130
+ ];
131
+ }
132
+
133
+ function renderSettingsRow(desc, editorPrefs, setEditorPrefs) {
134
+ var raw = editorPrefs[desc.key];
135
+ function set(v) { setEditorPref(setEditorPrefs, desc.key, v); }
136
+
137
+ if (desc.type === 'checkbox') {
138
+ var checked = desc.strict ? raw === true : (desc.def === true ? raw !== false : !!raw);
139
+ return React.createElement(
140
+ 'label', { className: 'ide-settings-row ide-settings-row-check', key: desc.key },
141
+ React.createElement('input', {
142
+ type: 'checkbox',
143
+ className: 'ide-settings-checkbox',
144
+ checked: checked,
145
+ onChange: function(e) { set(e.target.checked); }
146
+ }),
147
+ settingsRowText(desc)
148
+ );
149
+ }
150
+
151
+ if (desc.type === 'button') {
152
+ return React.createElement(
153
+ 'div', { className: 'ide-settings-row', key: desc.key },
154
+ settingsRowText(desc),
155
+ React.createElement('button', {
156
+ type: 'button', className: 'ide-settings-reset-btn', onClick: desc.onClick
157
+ }, desc.action)
158
+ );
159
+ }
160
+
161
+ var control = null;
162
+ if (desc.type === 'select') {
163
+ control = React.createElement(
164
+ 'select', {
165
+ className: 'ide-settings-input',
166
+ value: raw || desc.def,
167
+ onChange: function(e) { set(e.target.value); }
168
+ },
169
+ desc.options.map(function(opt) {
170
+ return React.createElement('option', { value: opt[0], key: opt[0] }, opt[1]);
171
+ })
172
+ );
173
+ } else if (desc.type === 'number') {
174
+ var val = desc.nullish ? (raw != null ? raw : desc.def) : (raw || desc.def);
175
+ var parse = function(e) { return desc.parse === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value, 10); };
176
+ control = React.createElement('input', {
177
+ key: String(val),
178
+ type: 'number', min: String(desc.min), max: String(desc.max), step: String(desc.step),
179
+ className: 'ide-settings-input ide-settings-input-number',
180
+ defaultValue: val,
181
+ onChange: function(e) {
182
+ var v = parse(e);
183
+ if (!isNaN(v) && v >= desc.min && v <= desc.max) set(v);
184
+ },
185
+ onBlur: function(e) {
186
+ var v = parse(e);
187
+ if (isNaN(v) || v < desc.min || v > desc.max) e.target.value = String(val);
188
+ }
189
+ });
190
+ } else if (desc.type === 'text') {
191
+ control = React.createElement('input', {
192
+ type: 'text',
193
+ className: 'ide-settings-input ide-settings-input-wide',
194
+ value: raw || desc.def,
195
+ onChange: function(e) { set(e.target.value); }
196
+ });
197
+ } else {
198
+ return null;
199
+ }
200
+
201
+ return React.createElement(
202
+ 'label', { className: 'ide-settings-row', key: desc.key },
203
+ settingsRowText(desc), control
204
+ );
205
+ }
206
+
207
+ // Flat SETTINGS_ROWS → [{ name, rows }], split on the `{ header }` entries.
208
+ function settingsSections(rows) {
209
+ var out = [];
210
+ var cur = null;
211
+ rows.forEach(function(d) {
212
+ if (d.header) { cur = { name: d.header, rows: [] }; out.push(cur); }
213
+ else if (cur) cur.rows.push(d);
214
+ });
215
+ return out;
216
+ }
217
+
218
+ var SETTINGS_SECTIONS = settingsSections(SETTINGS_ROWS);
219
+
220
+ var SettingsModal = function SettingsModal(props) {
221
+ var _React = React;
222
+ var useState = _React.useState;
223
+ var useEffect = _React.useEffect;
224
+ var useRef = _React.useRef;
225
+
226
+ var onClose = props.onClose;
227
+ var editorPrefs = props.editorPrefs;
228
+ var setEditorPrefs = props.setEditorPrefs;
229
+
230
+ var _q = useState('');
231
+ var query = _q[0];
232
+ var setQuery = _q[1];
233
+
234
+ var sections = SETTINGS_SECTIONS;
235
+ var _as = useState(sections[0] ? sections[0].name : null);
236
+ var activeSection = _as[0];
237
+ var setActiveSection = _as[1];
238
+
239
+ var sectionRefs = useRef({});
240
+
241
+ useEffect(function() {
242
+ function onKeyDown(e) { if (e.key === 'Escape') onClose(); }
243
+ window.addEventListener('keydown', onKeyDown);
244
+ return function() { window.removeEventListener('keydown', onKeyDown); };
245
+ }, [onClose]);
246
+
247
+ var q = query.trim().toLowerCase();
248
+ var visible = sections.map(function(s) {
249
+ if (!q) return s;
250
+ return {
251
+ name: s.name,
252
+ rows: s.rows.filter(function(d) {
253
+ return (d.label || '').toLowerCase().indexOf(q) !== -1 ||
254
+ (d.title || '').toLowerCase().indexOf(q) !== -1;
255
+ })
256
+ };
257
+ }).filter(function(s) { return s.rows.length > 0; });
258
+
259
+ return React.createElement(
260
+ 'div',
261
+ { className: 'ide-settings-overlay', onClick: onClose },
262
+ React.createElement(
263
+ 'div',
264
+ { className: 'ide-settings-modal', onClick: function(e) { e.stopPropagation(); } },
265
+ React.createElement(
266
+ 'div', { className: 'ide-settings-modal-header' },
267
+ React.createElement('span', { className: 'ide-settings-modal-title' }, 'Settings'),
268
+ React.createElement('input', {
269
+ type: 'search',
270
+ className: 'ide-settings-search',
271
+ placeholder: 'Search settings',
272
+ autoFocus: true,
273
+ value: query,
274
+ onChange: function(e) { setQuery(e.target.value); }
275
+ }),
276
+ React.createElement('button', {
277
+ type: 'button', className: 'ide-settings-modal-close', title: 'Close', 'aria-label': 'Close settings', onClick: onClose
278
+ }, React.createElement('i', { className: 'fas fa-times', 'aria-hidden': 'true' }))
279
+ ),
280
+ React.createElement(
281
+ 'div', { className: 'ide-settings-modal-body' },
282
+ React.createElement(
283
+ 'nav', { className: 'ide-settings-nav' },
284
+ visible.map(function(s) {
285
+ return React.createElement('button', {
286
+ type: 'button',
287
+ key: s.name,
288
+ className: 'ide-settings-nav-item' + (s.name === activeSection ? ' active' : ''),
289
+ onClick: function() {
290
+ setActiveSection(s.name);
291
+ var el = sectionRefs.current[s.name];
292
+ if (el && el.scrollIntoView) el.scrollIntoView({ block: 'start' });
293
+ }
294
+ }, s.name);
295
+ })
296
+ ),
297
+ React.createElement(
298
+ 'div', { className: 'ide-settings-body' },
299
+ visible.map(function(s) {
300
+ return React.createElement(
301
+ 'section', { className: 'ide-settings-section', key: s.name,
302
+ ref: function(el) { sectionRefs.current[s.name] = el; } },
303
+ React.createElement('h3', { className: 'ide-settings-section-header' }, s.name),
304
+ s.rows.map(function(d) { return renderSettingsRow(d, editorPrefs, setEditorPrefs); }),
305
+ s.name === 'RuboCop' && props.rubocopAvailable && props.rubocopConfigPath
306
+ ? React.createElement(
307
+ 'div', { className: 'ide-settings-row' },
308
+ React.createElement('span', { className: 'ide-settings-label' }, 'Config file'),
309
+ React.createElement(
310
+ 'button', {
311
+ type: 'button',
312
+ className: 'ide-settings-config-link',
313
+ title: 'Open ' + props.rubocopConfigPath,
314
+ onClick: function() { props.onOpenRubocopConfig(); onClose(); }
315
+ },
316
+ React.createElement('i', { className: 'fas fa-file-alt', style: { marginRight: 5 } }),
317
+ props.rubocopConfigPath
318
+ )
319
+ )
320
+ : null
321
+ );
322
+ }),
323
+ visible.length === 0
324
+ ? React.createElement('div', { className: 'ide-settings-empty' }, 'No settings match "' + query + '".')
325
+ : null,
326
+ React.createElement(
327
+ 'button', {
328
+ className: 'ide-settings-reset-btn',
329
+ type: 'button',
330
+ title: 'Restore every editor preference on this page to its default',
331
+ onClick: props.onReset
332
+ },
333
+ React.createElement('i', { className: 'fas fa-undo', style: { marginRight: 6 } }),
334
+ 'Reset to defaults'
335
+ )
336
+ )
337
+ )
338
+ )
339
+ );
340
+ };
341
+
342
+ window.SettingsModal = SettingsModal;
@@ -102,7 +102,8 @@ var ShortcutHelp = function ShortcutHelp(_ref) {
102
102
  'tbody',
103
103
  null,
104
104
  React.createElement(Row, { keys: 'Ctrl+P', desc: 'Quick-open any file by name' }),
105
- React.createElement(Row, { keys: 'PgUp/PgDn', desc: 'Cycle between cursor position and last jump origin' }),
105
+ React.createElement(Row, { keys: 'PgUp', desc: 'Navigate back through cursor history (across files)' }),
106
+ React.createElement(Row, { keys: 'PgDn', desc: 'Navigate forward through cursor history' }),
106
107
  React.createElement(Row, { keys: 'Ctrl+S', desc: 'Save the active file' }),
107
108
  React.createElement(Row, { keys: 'Ctrl+Shift+G', desc: 'Toggle git panel' }),
108
109
  React.createElement(Row, { keys: 'Ctrl+Shift+L', desc: 'Toggle Rails log panel' }),
@@ -181,6 +182,22 @@ var ShortcutHelp = function ShortcutHelp(_ref) {
181
182
  ]
182
183
  }
183
184
  )
185
+ ),
186
+
187
+ React.createElement(
188
+ Section,
189
+ { title: 'Sharing code with a chat assistant' },
190
+ React.createElement(
191
+ Tips,
192
+ {
193
+ items: [
194
+ 'Combined View: Ctrl/Cmd-click several files or folders, right-click, "Open in Combined View" — every file on one page with Copy and Download',
195
+ 'Pick methods: in Combined View, tick methods under a Ruby file to send only those; "Download .md" / "Copy .md" bundles everything into one attachment',
196
+ 'Paste & Place: right-click inside a Ruby file (or F1) → "Paste & Place Ruby Method...", paste the reply, "Find placement", check the diff, "Apply to editor" — then save; Ctrl+Z undoes',
197
+ 'Upload compare: drop or upload a file over an existing one and click "Compare" to see the diff before choosing Skip / Keep both / Overwrite'
198
+ ]
199
+ }
200
+ )
184
201
  )
185
202
  )
186
203
  )