mbeditor 0.13.0 → 0.14.0

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +157 -1
  3. data/app/assets/javascripts/mbeditor/application.js +3 -1
  4. data/app/assets/javascripts/mbeditor/audit_log.js +165 -0
  5. data/app/assets/javascripts/mbeditor/collaboration_service.js +264 -22
  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/DiffViewer.js +1 -1
  11. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +440 -334
  12. data/app/assets/javascripts/mbeditor/components/FileHistoryPanel.js +6 -9
  13. data/app/assets/javascripts/mbeditor/components/FileTree.js +26 -12
  14. data/app/assets/javascripts/mbeditor/components/GitPanel.js +3 -0
  15. data/app/assets/javascripts/mbeditor/components/Gutter.js +51 -0
  16. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +9 -1
  17. data/app/assets/javascripts/mbeditor/components/LogPanel.js +3 -44
  18. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +1322 -1431
  19. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +94 -37
  20. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +82 -72
  21. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +121 -81
  22. data/app/assets/javascripts/mbeditor/components/SettingsModal.js +342 -0
  23. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +2 -1
  24. data/app/assets/javascripts/mbeditor/components/TabBar.js +67 -98
  25. data/app/assets/javascripts/mbeditor/editor_plugins.js +694 -306
  26. data/app/assets/javascripts/mbeditor/file_import.js +13 -15
  27. data/app/assets/javascripts/mbeditor/file_service.js +46 -57
  28. data/app/assets/javascripts/mbeditor/git_service.js +15 -1
  29. data/app/assets/javascripts/mbeditor/history_service.js +5 -13
  30. data/app/assets/javascripts/mbeditor/search_service.js +29 -2
  31. data/app/assets/javascripts/mbeditor/tab_manager.js +135 -54
  32. data/app/assets/javascripts/mbeditor/websocket_service.js +13 -5
  33. data/app/assets/stylesheets/mbeditor/application.css +6 -1
  34. data/app/assets/stylesheets/mbeditor/editor.css +762 -297
  35. data/app/assets/stylesheets/mbeditor/glass.css +163 -0
  36. data/app/assets/stylesheets/mbeditor/themes.css +90 -30
  37. data/app/channels/mbeditor/collaboration_channel.rb +25 -6
  38. data/app/controllers/mbeditor/application_controller.rb +26 -3
  39. data/app/controllers/mbeditor/editors_controller.rb +125 -465
  40. data/app/services/mbeditor/archive_service.rb +137 -0
  41. data/app/services/mbeditor/collaboration_doc_store.rb +180 -12
  42. data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
  43. data/app/services/mbeditor/editor_state_service.rb +14 -51
  44. data/app/services/mbeditor/file_history_service.rb +222 -0
  45. data/app/services/mbeditor/git_info_service.rb +6 -0
  46. data/app/services/mbeditor/js_globals_service.rb +12 -1
  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/rubocop_run_service.rb +17 -5
  52. data/app/services/mbeditor/ruby_lsp_result_translator.rb +226 -0
  53. data/app/services/mbeditor/schema_service.rb +8 -2
  54. data/app/services/mbeditor/search_replace_service.rb +19 -2
  55. data/app/services/mbeditor/test_runner_service.rb +3 -56
  56. data/app/views/layouts/mbeditor/application.html.erb +1 -1
  57. data/lib/mbeditor/audit_log.rb +203 -0
  58. data/lib/mbeditor/configuration.rb +6 -9
  59. data/lib/mbeditor/rack/pending_migration_bypass.rb +15 -9
  60. data/lib/mbeditor/route_map.rb +4 -1
  61. data/lib/mbeditor/ruby_lsp_client.rb +82 -14
  62. data/lib/mbeditor/version.rb +1 -1
  63. data/lib/mbeditor.rb +1 -0
  64. data/lib/tasks/mbeditor.rake +23 -0
  65. metadata +14 -3
  66. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +0 -312
@@ -23,6 +23,109 @@ function saveFavourites(list) {
23
23
  try { localStorage.setItem(FAVS_KEY, JSON.stringify(list)); } catch (e) {}
24
24
  }
25
25
 
26
+ // ── Ranking ────────────────────────────────────────────────────────────────
27
+ // Module scope, not component scope, so `rankResults` can be exercised without
28
+ // rendering — see script/check-quick-open-ranking.mjs.
29
+
30
+ // Priority tier for a file path: lower = shown first.
31
+ // Order: controller > model > helper > concern > view > job > other > noise
32
+ function getFilePriority(path) {
33
+ var p = (path || '').toLowerCase();
34
+ if (p.indexOf('/controllers/') >= 0) return 1;
35
+ if (p.indexOf('/models/') >= 0) return 2;
36
+ if (p.indexOf('/helpers/') >= 0) return 3;
37
+ if (p.indexOf('/concerns/') >= 0) return 4;
38
+ if (p.indexOf('/views/') >= 0) return 5;
39
+ if (p.indexOf('/jobs/') >= 0) return 6;
40
+ // Deprioritise: migrations, schema, compiled assets, vendor, lock files
41
+ if (p.indexOf('/migrate/') >= 0 || p.indexOf('schema.rb') >= 0) return 90;
42
+ if (p.indexOf('/public/') >= 0 || p.indexOf('/vendor/') >= 0) return 91;
43
+ if (p.slice(-7) === '.min.js' || p.slice(-8) === '.min.css' ||
44
+ p.slice(-4) === '.map' || p.slice(-5) === '.lock') return 92;
45
+ return 50;
46
+ }
47
+
48
+ // Recently opened files, most recent first, as a path -> rank lookup. Built
49
+ // once per query rather than per comparison, since the sort calls this for
50
+ // every pair.
51
+ function recentRanks() {
52
+ var ranks = {};
53
+ var recent = (typeof TabManager !== 'undefined' && TabManager.getRecentFiles)
54
+ ? TabManager.getRecentFiles() : [];
55
+ recent.forEach(function (entry, index) {
56
+ var path = typeof entry === 'string' ? entry : (entry && entry.path);
57
+ if (path && !(path in ranks)) ranks[path] = index;
58
+ });
59
+ return ranks;
60
+ }
61
+
62
+ // Match relevance within a priority tier:
63
+ // exact basename > basename prefix > basename substring > path substring > fuzzy-only.
64
+ // FUZZY_ONLY is the bucket everything MiniSearch matched approximately lands
65
+ // in — a typo'd or transposed query hits nothing literally, so an exact match
66
+ // can never be demoted by loosening the index search.
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
+
77
+ function getMatchRelevance(result, q) {
78
+ if (!q) return FUZZY_ONLY;
79
+ var name = (result.name || (result.path || '').split('/').pop() || '').toLowerCase();
80
+ var lq = toSnakeCase(q);
81
+ if (name === lq) return 0;
82
+ if (name.slice(0, lq.length) === lq) return 1;
83
+ if (name.indexOf(lq) >= 0) return 2;
84
+ if ((result.path || '').toLowerCase().indexOf(lq) >= 0) return 3;
85
+ return FUZZY_ONLY;
86
+ }
87
+
88
+ // Filter SearchService hits by type and order them for display.
89
+ // Precedence:
90
+ // 1. files before folders — a folder is never what Ctrl+P is for, and as a
91
+ // tier below match quality an exactly-named folder used to outrank every
92
+ // file that matched
93
+ // 2. match quality (see getMatchRelevance), so a worse match can never jump
94
+ // the queue however recently it was opened
95
+ // 3. MiniSearch's own score, but only within the fuzzy-only bucket: nothing
96
+ // there matched literally, so edit distance is the only signal for which
97
+ // near-miss the user meant
98
+ // 4. how recently the file was opened, most recent first
99
+ // 5. the static file-type tier (controller > model > … > noise)
100
+ //
101
+ // Recency sits above the type tier deliberately: when two files match a query
102
+ // equally well, the one you were just working in is almost always the one you
103
+ // meant, and that beats a guess made from the directory name. Files never
104
+ // opened all tie here and fall through to the type tier, which is what orders
105
+ // the bulk of a cold result list.
106
+ //
107
+ // JS sort is stable in modern engines, so MiniSearch's own relevance order
108
+ // remains the final tiebreaker.
109
+ function rankResults(res, query, showFolders) {
110
+ var filtered = showFolders ? res.slice() : res.filter(function (r) { return r.type !== 'dir'; });
111
+ var ranks = recentRanks();
112
+ var NEVER_OPENED = Infinity;
113
+ filtered.sort(function (a, b) {
114
+ var aDir = a.type === 'dir' ? 1 : 0;
115
+ var bDir = b.type === 'dir' ? 1 : 0;
116
+ if (aDir !== bDir) return aDir - bDir;
117
+ var aRelevance = getMatchRelevance(a, query);
118
+ var bRelevance = getMatchRelevance(b, query);
119
+ if (aRelevance !== bRelevance) return aRelevance - bRelevance;
120
+ if (aRelevance === FUZZY_ONLY && a.score !== b.score) return (b.score || 0) - (a.score || 0);
121
+ var aRecent = a.path in ranks ? ranks[a.path] : NEVER_OPENED;
122
+ var bRecent = b.path in ranks ? ranks[b.path] : NEVER_OPENED;
123
+ if (aRecent !== bRecent) return aRecent - bRecent;
124
+ return getFilePriority(a.path) - getFilePriority(b.path);
125
+ });
126
+ return filtered;
127
+ }
128
+
26
129
  // ── Component ──────────────────────────────────────────────────────────────
27
130
 
28
131
  var QuickOpenDialog = function QuickOpenDialog(_ref) {
@@ -76,49 +179,6 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
76
179
  if (inputRef.current) inputRef.current.focus();
77
180
  };
78
181
 
79
- // Priority tier for a file path: lower = shown first.
80
- // Order: controller > model > helper > concern > view > job > other > noise
81
- function getFilePriority(path) {
82
- var p = (path || '').toLowerCase();
83
- if (p.indexOf('/controllers/') >= 0) return 1;
84
- if (p.indexOf('/models/') >= 0) return 2;
85
- if (p.indexOf('/helpers/') >= 0) return 3;
86
- if (p.indexOf('/concerns/') >= 0) return 4;
87
- if (p.indexOf('/views/') >= 0) return 5;
88
- if (p.indexOf('/jobs/') >= 0) return 6;
89
- // Deprioritise: migrations, schema, compiled assets, vendor, lock files
90
- if (p.indexOf('/migrate/') >= 0 || p.indexOf('schema.rb') >= 0) return 90;
91
- if (p.indexOf('/public/') >= 0 || p.indexOf('/vendor/') >= 0) return 91;
92
- if (p.slice(-7) === '.min.js' || p.slice(-8) === '.min.css' ||
93
- p.slice(-4) === '.map' || p.slice(-5) === '.lock') return 92;
94
- return 50;
95
- }
96
-
97
- // Recently opened files, most recent first, as a path -> rank lookup. Built
98
- // once per query rather than per comparison, since the sort calls this for
99
- // every pair.
100
- function recentRanks() {
101
- var ranks = {};
102
- var recent = (typeof TabManager !== 'undefined' && TabManager.getRecentFiles)
103
- ? TabManager.getRecentFiles() : [];
104
- recent.forEach(function (entry, index) {
105
- var path = typeof entry === 'string' ? entry : (entry && entry.path);
106
- if (path && !(path in ranks)) ranks[path] = index;
107
- });
108
- return ranks;
109
- }
110
-
111
- // Match relevance within a priority tier: exact basename > prefix > substring > other.
112
- function getMatchRelevance(result, q) {
113
- if (!q) return 3;
114
- var name = (result.name || (result.path || '').split('/').pop() || '').toLowerCase();
115
- var lq = q.toLowerCase();
116
- if (name === lq) return 0;
117
- if (name.slice(0, lq.length) === lq) return 1;
118
- if (name.indexOf(lq) >= 0) return 2;
119
- return 3;
120
- }
121
-
122
182
  var getQuickOpenIcon = function getQuickOpenIcon(path, name, type) {
123
183
  if (type === 'dir') {
124
184
  return React.createElement('i', { className: 'fas fa-folder quick-open-result-icon quick-open-folder-icon', 'aria-hidden': 'true' });
@@ -143,37 +203,17 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
143
203
  // path in the workspace ran on every keystroke, between the keypress and
144
204
  // the character appearing.
145
205
  var timer = setTimeout(function () {
146
- var res = SearchService.searchFiles(query);
147
- // Filter by type: always include files; include dirs only when showFolders is on
148
- var filtered = showFolders ? res : res.filter(function(r) { return r.type !== 'dir'; });
149
- // Sort, in order of precedence:
150
- // 1. match quality — exact basename > prefix > substring > other, so a
151
- // worse match can never jump the queue however recently it was opened
152
- // 2. how recently the file was opened, most recent first
153
- // 3. the static file-type tier (controller > model > … > noise)
154
- //
155
- // Recency sits above the type tier deliberately: when two files match a
156
- // query equally well, the one you were just working in is almost always
157
- // the one you meant, and that beats a guess made from the directory name.
158
- // Files never opened all tie here and fall through to the type tier, which
159
- // is what orders the bulk of a cold result list.
160
- //
161
- // Directories keep their +100 penalty inside the type tier so files still
162
- // come first. JS sort is stable in modern engines, so MiniSearch's own
163
- // relevance order remains the final tiebreaker.
164
- var ranks = recentRanks();
165
- var NEVER_OPENED = Infinity;
166
- filtered.sort(function(a, b) {
167
- var aRelevance = getMatchRelevance(a, query);
168
- var bRelevance = getMatchRelevance(b, query);
169
- if (aRelevance !== bRelevance) return aRelevance - bRelevance;
170
- var aRecent = a.path in ranks ? ranks[a.path] : NEVER_OPENED;
171
- var bRecent = b.path in ranks ? ranks[b.path] : NEVER_OPENED;
172
- if (aRecent !== bRecent) return aRecent - bRecent;
173
- var aPriority = getFilePriority(a.path) + (a.type === 'dir' ? 100 : 0);
174
- var bPriority = getFilePriority(b.path) + (b.type === 'dir' ? 100 : 0);
175
- return aPriority - bPriority;
176
- });
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);
177
217
  setResults(filtered.slice(0, 200));
178
218
  setSelectedIndex(0);
179
219
  }, 120);
@@ -270,7 +310,7 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
270
310
  return React.createElement(
271
311
  'div',
272
312
  { className: 'quick-open-section' },
273
- React.createElement('div', { className: 'quick-open-section-header' },
313
+ React.createElement('div', { className: 'quick-open-section-header quick-open-section-header-muted' },
274
314
  React.createElement('i', { className: 'fas fa-history', style: { marginRight: '6px', fontSize: '10px' } }),
275
315
  'Recent Searches'
276
316
  ),
@@ -282,6 +322,8 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
282
322
  var recentFiles = (typeof TabManager !== 'undefined' && TabManager.getRecentFiles)
283
323
  ? TabManager.getRecentFiles() : [];
284
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.
285
327
  var rows = recentFiles.map(function (entry) {
286
328
  var path = entry.path;
287
329
  var name = entry.name || (path.split('/').pop());
@@ -299,16 +341,13 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
299
341
  React.createElement('div', { className: 'quick-open-result-name' }, name),
300
342
  React.createElement('div', { className: 'quick-open-result-path' }, path)
301
343
  ),
344
+ React.createElement('span', { className: 'quick-open-recent-label' }, 'recently opened'),
302
345
  renderStarBtn(path)
303
346
  );
304
347
  });
305
348
  return React.createElement(
306
349
  'div',
307
350
  { className: 'quick-open-section' },
308
- React.createElement('div', { className: 'quick-open-section-header' },
309
- React.createElement('i', { className: 'fas fa-clock', style: { marginRight: '6px', fontSize: '10px' } }),
310
- 'Recently Opened'
311
- ),
312
351
  rows
313
352
  );
314
353
  }
@@ -328,7 +367,7 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
328
367
  ref: inputRef,
329
368
  type: 'text',
330
369
  className: 'quick-open-input',
331
- placeholder: 'Search files by name (Ctrl+P)…',
370
+ placeholder: 'Search files by name',
332
371
  value: query,
333
372
  onChange: function (e) { setQuery(e.target.value); },
334
373
  onKeyDown: handleKeyDown
@@ -395,7 +434,7 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
395
434
  }),
396
435
  results.length === 0 && React.createElement(
397
436
  'div',
398
- { style: { padding: '12px 16px', color: '#666', fontSize: '12px' } },
437
+ { style: { padding: '14px 18px', color: '#666', fontSize: '14px' } },
399
438
  'No matching files.'
400
439
  )
401
440
  )
@@ -404,4 +443,5 @@ var QuickOpenDialog = function QuickOpenDialog(_ref) {
404
443
  );
405
444
  };
406
445
 
446
+ QuickOpenDialog.rankResults = rankResults;
407
447
  window.QuickOpenDialog = QuickOpenDialog;
@@ -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' }),