mbeditor 0.12.9 → 0.13.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -0
  3. data/app/assets/javascripts/mbeditor/application.js +7 -1
  4. data/app/assets/javascripts/mbeditor/collaboration_service.js +31 -0
  5. data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
  6. data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +102 -25
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
  9. data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
  10. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
  11. data/app/assets/javascripts/mbeditor/components/ImportDialog.js +216 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +598 -130
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +162 -6
  15. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +39 -33
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
  17. data/app/assets/javascripts/mbeditor/components/TestRunPanel.js +312 -0
  18. data/app/assets/javascripts/mbeditor/editor_plugins.js +427 -111
  19. data/app/assets/javascripts/mbeditor/file_import.js +78 -0
  20. data/app/assets/javascripts/mbeditor/file_service.js +122 -18
  21. data/app/assets/javascripts/mbeditor/git_service.js +130 -8
  22. data/app/assets/javascripts/mbeditor/history_service.js +33 -38
  23. data/app/assets/javascripts/mbeditor/log_service.js +4 -0
  24. data/app/assets/javascripts/mbeditor/search_service.js +30 -2
  25. data/app/assets/javascripts/mbeditor/tab_manager.js +59 -6
  26. data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
  27. data/app/assets/stylesheets/mbeditor/editor.css +268 -53
  28. data/app/channels/mbeditor/channel_authentication.rb +7 -0
  29. data/app/channels/mbeditor/editor_channel.rb +6 -3
  30. data/app/controllers/mbeditor/application_controller.rb +5 -0
  31. data/app/controllers/mbeditor/editors_controller.rb +173 -29
  32. data/app/controllers/mbeditor/git_controller.rb +3 -3
  33. data/app/controllers/mbeditor/logs_controller.rb +3 -1
  34. data/app/services/mbeditor/collaboration_doc_store.rb +7 -0
  35. data/app/services/mbeditor/editor_state_service.rb +36 -26
  36. data/app/services/mbeditor/exclusion_matcher.rb +12 -10
  37. data/app/services/mbeditor/file_operation_service.rb +38 -3
  38. data/app/services/mbeditor/file_tree_service.rb +30 -2
  39. data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
  40. data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
  41. data/app/services/mbeditor/git_diff_service.rb +5 -1
  42. data/app/services/mbeditor/git_info_service.rb +20 -8
  43. data/app/services/mbeditor/git_line_diff_service.rb +6 -2
  44. data/app/services/mbeditor/git_service.rb +65 -15
  45. data/app/services/mbeditor/js_definition_service.rb +3 -1
  46. data/app/services/mbeditor/js_globals_service.rb +7 -4
  47. data/app/services/mbeditor/js_members_service.rb +3 -2
  48. data/app/services/mbeditor/js_program_service.rb +15 -5
  49. data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
  50. data/app/services/mbeditor/process_runner.rb +42 -12
  51. data/app/services/mbeditor/ri_definition_service.rb +8 -1
  52. data/app/services/mbeditor/route_service.rb +45 -8
  53. data/app/services/mbeditor/rubocop_run_service.rb +86 -0
  54. data/app/services/mbeditor/ruby_definition_service.rb +23 -4
  55. data/app/services/mbeditor/schema_service.rb +65 -64
  56. data/app/services/mbeditor/search_replace_service.rb +31 -5
  57. data/app/services/mbeditor/test_runner_service.rb +98 -10
  58. data/lib/mbeditor/cable_log_filter.rb +8 -2
  59. data/lib/mbeditor/configuration.rb +12 -1
  60. data/lib/mbeditor/editor_bootstrap.rb +18 -12
  61. data/lib/mbeditor/engine.rb +22 -3
  62. data/lib/mbeditor/exception_log.rb +2 -2
  63. data/lib/mbeditor/pending_migrations.rb +33 -0
  64. data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
  65. data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
  66. data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
  67. data/lib/mbeditor/route_map.rb +2 -0
  68. data/lib/mbeditor/ruby_lsp_client.rb +71 -12
  69. data/lib/mbeditor/version.rb +1 -1
  70. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 264dfef604baf70be3fcb9984f588a93a8388fe13371cb3c8707d5ee7149d441
4
- data.tar.gz: d584056fbb108ea55ddb98a819af73e4eabb939d659fc7d54442446b34b2c203
3
+ metadata.gz: b65424322b66f78929de0629db6e4644a76467e889adaee77121b8c52e0e50de
4
+ data.tar.gz: 53cd7724f2a80011d474a9616bdb35d88859ddf797a0fa51d3657740038624bd
5
5
  SHA512:
6
- metadata.gz: 20141c5dcd65e482c4d0437c5b1a4b1431507f424dc1a31b80af5490d30b4611c2c2e050f0716a4a382c9f2b3804001127e03292cfe371b50049c6d058f3c7b0
7
- data.tar.gz: 551f8ad767679d4b63364d2c6e9bcb267663c35bf1750a355b29ff22f7149e445511a0fd9be8aa7a279754f6c0a3ccb79a1b3941d1bec3b11792911541081616
6
+ metadata.gz: f4470580292e46d6de7eb2e3687193452bfbcbee5d478e48c6460e22739fd2942d1a525b04e6d3f7877175db5d18da86c3241032dfc748ca91bf62f0f008cd90
7
+ data.tar.gz: 05e4226ce7151a7e72d18f34018c30acc04adfbaea235a891afe12ecadb52170b1b598820ea7de326068eae7bb7811cce13ec5cf42b0c69fd6c77683c3b7c819
data/CHANGELOG.md CHANGED
@@ -7,6 +7,156 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.0] - 2026-08-20
11
+
12
+ ### Added
13
+ - **Whole-suite test run, in a drawer.** `POST /test_all` runs the suite with
14
+ no file argument under its own `test_all_timeout` (30 min) — a suite run is
15
+ minutes, not seconds, and forcing `test_timeout` up to cover it would let one
16
+ hung example block for half an hour. It renders as a bottom drawer beside
17
+ Problems and the log rather than a modal: a backdrop that blocks the editor
18
+ for the length of a suite run turns the wait into dead time. Failures group
19
+ by file, each row jumps to its line, and "Open failing" opens them all at
20
+ once. The result survives closing the drawer and a reload, and a branch
21
+ switch drops it.
22
+ - **Whole-workspace RuboCop run in the Problems panel.** The panel could only
23
+ ever show diagnostics for files Monaco had open, because markers are
24
+ per-model. Three buttons now cover the rest of the project: RuboCop (a full
25
+ `rubocop --format json` run), Fix (`-a`, safe corrections only), and Open all.
26
+
27
+ ### Changed
28
+ - **Activity bar and explorer are larger.** Activity buttons go 36px to 60px
29
+ with a 25px glyph and fill the bar edge to edge, so the active indicator sits
30
+ flush at the screen edge; state is carried by glyph colour alone and the
31
+ hover box is gone. Explorer rows and icons go 12px to 14px.
32
+ - **Per-file test timeout raised 60s to 180s**, and the client now derives its
33
+ request timeout from the server's (`/workspace` reports both ceilings)
34
+ instead of a hard-coded 120s. Raising the server timeout past the client's
35
+ used to make the browser abort a run the server was still executing.
36
+
37
+ ### Security
38
+ - **Every entry point is gated on `allowed_environments`.** The check moved to
39
+ `ApplicationController` ahead of `run_authentication`. `LogsController`
40
+ declared no gate at all, so `GET /mbeditor/logs/tail` returned the tail of
41
+ `log/<env>.log` in any environment the engine was mounted in; both cable
42
+ channels checked only the (nil by default) authentication hook, so anything
43
+ reaching the host's `/cable` could subscribe and start a log tail or write
44
+ state. Running the host's auth proc now happens after the environment check,
45
+ so a disallowed environment 404s without it.
46
+ - **`model_schema` validates its parameter.** The model name flowed into a file
47
+ path without a format check, so `..` segments escaped the workspace.
48
+ - **Git ref patterns reject a leading dash**, and `git show` takes
49
+ `--end-of-options` — a ref beginning with `-` was argument injection into a
50
+ documented git option.
51
+
52
+ ### Performance
53
+ - **Typing no longer re-renders the whole application.** Every keystroke wrote
54
+ the full document into the store, which re-rendered an app subscribed to all
55
+ of it. Content writes are now throttled (250 ms, trailing) with a documented
56
+ flush-on-save contract, and the dirty flag is written only on a clean/dirty
57
+ transition. On top of that: the file tree is no longer deep-copied per render,
58
+ the model-graph scene (~10k SVG elements) no longer rebuilds when unrelated
59
+ state moves, and blame/test view zones, the tab strip's smooth scroll, and the
60
+ problem counts no longer churn per keypress.
61
+ - **Monaco's ruby-lsp providers honour cancellation.** `documentHighlight`
62
+ fires per cursor move and was POSTing the entire buffer with no way to cancel
63
+ it, so superseded requests stayed in flight; all eight raw providers now
64
+ thread Monaco's `CancellationToken` through one shared path, and highlights
65
+ memoise per word occurrence.
66
+ - **ruby-lsp requests pipeline again.** The document mutex was held across the
67
+ whole request/response round-trip, so a single diagnostics call (10 s budget)
68
+ or rename (30 s) blocked every hover and highlight in the process. It is now
69
+ held only across the write, which is what actually needs ordering. Abandoned
70
+ requests send `$/cancelRequest` rather than leaving a single-threaded server
71
+ computing a result nobody will read.
72
+ - **Conditional GETs on the polled and static routes.** Monaco's 14 MB of
73
+ assets carried no validators and were re-fetched on every page load; `/files`
74
+ shipped roughly 1 MB of JSON on a 10 s poll and now answers 304 from a digest
75
+ memoised with the existing tree cache. `/git_status`, hit every 5 s, is TTL
76
+ cached instead of spawning two git subprocesses per poll.
77
+ - **`find_branch_base` costs 2 git spawns instead of up to 12**, resolving the
78
+ candidate list with one `for-each-ref`. Commit detail drops from 3 spawns to
79
+ 2, and the line-diff probe only asks whether a file is untracked when the
80
+ diff came back empty.
81
+ - **A JS save no longer rebuilds the whole workspace JS program behind a global
82
+ lock**, and a `.rb` save no longer invalidates the JS program and globals
83
+ caches at all. Go-to-definition no longer rewrites the entire on-disk cache
84
+ (tens of MB of JSON) on any lookup that parsed something new.
85
+ - Smaller: one `lstat` per tree entry instead of three stats, unbounded
86
+ subprocess output capped, and the exclusion matcher splits each path once
87
+ rather than once per pattern.
88
+
89
+ ### Fixed
90
+ - **Pending migrations no longer lock the editor.** `CheckPending` is swapped
91
+ for a wrapper that runs the identical check for host traffic but records
92
+ rather than raises for the editor's, so the tree, opening a file and saving
93
+ one keep working — including saving the migration causing it. The warning
94
+ rides a response header, so it appears for a migration created mid-session
95
+ and clears once migrated without a reload. Host traffic is still blocked
96
+ exactly as Rails intends. The fallback shell also requested its Sprockets
97
+ assets under the engine mount, where they 404, and hardcoded `/mbeditor`
98
+ instead of the configured mount path.
99
+ - **Saves no longer degrade under a burst.** A timeout was counted as an
100
+ unreachable server, which suppressed the polls that would have proven it up;
101
+ `files_changed` is coalesced once at the WebSocket dispatch (15 writes: 15
102
+ dispatches to 1); panes re-ran `git diff` on every save anywhere in the
103
+ workspace; and the lite git poll escalated to the full fan-out twice per save
104
+ (12 saves: 12 fan-outs to 1).
105
+ - **Replace All left open tabs showing pre-replace text.** The refresh treated
106
+ a model-registry entry as a Monaco model, so the update silently no-opped
107
+ into a bare `catch` — and saving that tab afterwards wrote the stale content
108
+ back over the replacement.
109
+ - **A dropped cable connection disconnected the host application's own
110
+ ActionCable consumer** when the host provided one, taking its channels down
111
+ with it, and left collaboration rooms bound to the dead consumer with no
112
+ indication they were dead. Consumers are disconnected only when mbeditor
113
+ created them, and rooms rejoin on reconnect.
114
+ - **ruby-lsp failed entirely for any workspace path containing a space** or
115
+ other character needing escaping: the `file://` URI was built by
116
+ interpolation, so ruby-lsp's own `URI` parse raised and every request fell
117
+ back silently. Return-path comparisons unescape before matching the
118
+ workspace root.
119
+ - **Git status mangled paths with spaces, non-ASCII characters, or renames.**
120
+ Porcelain output was read verbatim, so quoted and octal-escaped paths arrived
121
+ unopenable and missed their numstat enrichment; both sides now use `-z`.
122
+ - **Saves and editor state are written atomically.** Both truncated in place,
123
+ so a reader landing mid-write parsed an empty file and silently reported no
124
+ open tabs, and a crash mid-write left the file empty for good. Writes go to a
125
+ temporary file and rename, resolving through symlinks so a linked file stays
126
+ a link.
127
+ - **Literal (non-regex) replace corrupted replacements containing
128
+ backslashes** — `\1`, `\&` and `\\` were interpreted as backreferences across
129
+ up to 500 files.
130
+ - **A user-supplied search regex could pin a worker indefinitely.** Patterns
131
+ are compiled with `Regexp.timeout` where the Ruby version supports it;
132
+ `Timeout.timeout` cannot interrupt a regex match, which is what the previous
133
+ guard relied on.
134
+ - **A failed ruby-lsp handshake orphaned the child process**, leaving it
135
+ indexing and holding memory, and up to three could accumulate.
136
+ - **Branch switches left stale buffers and diagnostics.** A checkout rewrites
137
+ the working tree and nothing told the editor; the git poll now announces it,
138
+ the app re-reads the tree and every open tab (dirty ones still prompt rather
139
+ than being clobbered), and `/files?refresh=1` bypasses the 15 s tree cache.
140
+ - **The Problems panel filled with vendored gem source** when a host
141
+ `.rubocop.yml` replaced RuboCop's default excludes, and reported TypeScript's
142
+ implicit-any advice on plain JS/JSX, where there are no annotations to add.
143
+ - **`HistoryService` rejected an empty base**, so the first history entry for a
144
+ file 400'd.
145
+ - Also: a V8 isolate leak in the JS syntax checker, unbounded growth in the
146
+ collaboration delta log and several per-path caches, JS member completions
147
+ cached with a stale insert range, `quick_fix`/format/haml-lint running with
148
+ no timeout, a negative log offset returning a 500, and configured JS globals
149
+ being dropped when the symbol cap was reached.
150
+
151
+ ### Tests
152
+ - The pending-migration middleware tests built `ActiveRecord::PendingMigrationError`
153
+ with no arguments, whose initializer lists the pending migrations and so needs
154
+ a database connection; every CI runner without one failed all six.
155
+ - Two stubs restored `AvailabilityProbe.rg` by rebinding a captured `Method`,
156
+ which raises `can't bind singleton method to a different class` if the class
157
+ is reloaded in between — leaving `rg` undefined and failing every later search
158
+ and workspace test in that process. They alias instead.
159
+
10
160
  ## [0.12.9] - 2026-08-07
11
161
 
12
162
  ### Added
@@ -27,12 +27,18 @@
27
27
  //= require mbeditor/components/TestResultsPanel
28
28
  //= require mbeditor/components/LogPanel
29
29
  //= require mbeditor/components/ProblemsPanel
30
+ //= require mbeditor/components/TestRunPanel
30
31
  //= require mbeditor/components/CodeReviewPanel
31
32
  //= require mbeditor/components/EditorPanel
32
33
  //= require mbeditor/components/FileTree
33
34
  //= require mbeditor/components/GitPanel
34
35
  //= require mbeditor/components/QuickOpenDialog
35
36
  //= require mbeditor/components/TabBar
37
+ // Both import modals must be required *inside* the IIFE: React is an IIFE
38
+ // parameter (window.React is deliberately never set), so a component file
39
+ // placed after the tail resolves a bare `React` to undefined and takes the
40
+ // whole app down the first time it renders.
41
+ //= require mbeditor/components/ImportConflictModal
42
+ //= require mbeditor/components/ImportDialog
36
43
  //= require mbeditor/components/MbeditorApp
37
44
  //= require mbeditor/application_iife_tail
38
- //= require mbeditor/components/ImportConflictModal
@@ -295,6 +295,29 @@ var CollaborationService = (function () {
295
295
  }
296
296
  }
297
297
 
298
+ // Re-open every room's channel subscription on the current consumer.
299
+ //
300
+ // A cable drop tears the consumer down and takes every CollaborationChannel
301
+ // subscription with it — silently, because isAvailable() reports cable
302
+ // *support*, not liveness. Nothing here noticed: the rooms stayed in _rooms
303
+ // with dead subscriptions, so edits stopped reaching peers until the file was
304
+ // closed and reopened. WebSocketService calls this when the EditorChannel
305
+ // reconnects. Only the wire is replaced; the Yjs doc, the binding and the undo
306
+ // history all survive, and the re-sent sync snapshot re-applies harmlessly
307
+ // (Y.applyUpdate is idempotent).
308
+ function rejoinRooms() {
309
+ Object.keys(_rooms).forEach(function (path) {
310
+ var room = _rooms[path];
311
+ try { if (room.subscription) room.subscription.unsubscribe(); } catch (e) { /* already dead */ }
312
+ room.subscription = WebSocketService.subscribeCollaboration(path, {
313
+ // Awareness is never server-persisted, so peers lost our caret with the
314
+ // connection and would not see it again until we happened to move.
315
+ connected: function () { _sendLocalAwareness(room); },
316
+ received: function (data) { _onMessage(room, data); }
317
+ });
318
+ });
319
+ }
320
+
298
321
  // Register the current editor/model for a room and request a binding. Called on
299
322
  // every editor (re)creation (tab switch recreates the editor; the model and the
300
323
  // Yjs doc persist). opts.onSeeded is invoked once the binding has set the model,
@@ -561,6 +584,7 @@ var CollaborationService = (function () {
561
584
  // Client IDs are globally unique, so one shared <style> element covers all open
562
585
  // files. Re-runs on any awareness change (cheap; small participant counts).
563
586
  var _styleEl = null;
587
+ var _cursorCss = null;
564
588
  function _renderCursorStyles() {
565
589
  if (typeof document === 'undefined') return;
566
590
  if (!_styleEl) {
@@ -593,6 +617,12 @@ var CollaborationService = (function () {
593
617
  'font-family:sans-serif;z-index:10;pointer-events:none;}';
594
618
  });
595
619
  });
620
+ // Awareness changes on every caret move and every scroll frame, but the
621
+ // styles only depend on who is here and what colour they chose — which
622
+ // almost never changes. Re-assigning textContent invalidates the document's
623
+ // style rules, so skip the write when the CSS is identical.
624
+ if (css === _cursorCss) return;
625
+ _cursorCss = css;
596
626
  _styleEl.textContent = css;
597
627
  }
598
628
 
@@ -739,6 +769,7 @@ var CollaborationService = (function () {
739
769
  onAvailabilityChange: onAvailabilityChange,
740
770
  isEnabledFor: isEnabledFor,
741
771
  ensureRoom: ensureRoom,
772
+ rejoinRooms: rejoinRooms,
742
773
  bindEditor: bindEditor,
743
774
  unbindEditor: unbindEditor,
744
775
  leaveRoom: leaveRoom,
@@ -87,6 +87,11 @@
87
87
  function provideDocumentColors(model) {
88
88
  var colors = [];
89
89
  var lineCount = model.getLineCount();
90
+ // Registered for every language and re-run on every change, so a big file
91
+ // pays two regex passes per line for every keystroke. Same threshold
92
+ // EditorPanel's applyLargeFileTuning uses to switch off the other
93
+ // whole-document decorations.
94
+ if (lineCount > 2500) return [];
90
95
  for (var ln = 1; ln <= lineCount; ln++) {
91
96
  var line = model.getLineContent(ln);
92
97
  if (line.length > 2000) continue; // skip pathological lines
@@ -49,7 +49,9 @@ var CollapsibleSection = function CollapsibleSection(_ref) {
49
49
  icon && React.createElement("i", { className: "collapsible-icon " + icon }),
50
50
  React.createElement(
51
51
  "span",
52
- { className: "collapsible-title" },
52
+ // The title truncates away entirely on a narrow sidebar, so it carries
53
+ // its own tooltip — otherwise the section becomes unidentifiable.
54
+ { className: "collapsible-title", title: typeof title === 'string' ? title : undefined },
53
55
  title
54
56
  ),
55
57
  actions && React.createElement(
@@ -6,6 +6,7 @@ var _React = React;
6
6
  var useState = _React.useState;
7
7
  var useEffect = _React.useEffect;
8
8
  var useRef = _React.useRef;
9
+ var useMemo = _React.useMemo;
9
10
 
10
11
  var IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'bmp', 'avif'];
11
12
 
@@ -611,7 +612,7 @@ var EditorPanel = function EditorPanel(_ref) {
611
612
  var _histBranch = EditorStore.getState().gitBranch || '';
612
613
  if (_histBranch) {
613
614
  if (_reusingModel) {
614
- HistoryService.resumeTracking(_histBranch, tab.path);
615
+ HistoryService.resumeTracking(_histBranch, tab.path, modelObj.getValue());
615
616
  } else {
616
617
  HistoryService.beginTracking(_histBranch, tab.path, tab.content || '');
617
618
  }
@@ -647,9 +648,8 @@ var EditorPanel = function EditorPanel(_ref) {
647
648
  autoClosingBrackets: editorPrefs.autoClosingBrackets || 'always',
648
649
  autoClosingQuotes: editorPrefs.autoClosingQuotes || 'always',
649
650
  autoIndent: editorPrefs.autoIndent || 'full',
650
- // Monaco own format-on-paste is left off: it never ran the formatter
651
- // here. attachEditorFeatures drives re-indent-on-paste from onDidPaste
652
- // instead, gated on editorPrefs.indentOnPaste.
651
+ // Paste inserts what was copied, unchanged. See attachEditorFeatures for
652
+ // why re-indenting it was removed rather than repaired.
653
653
  formatOnPaste: false,
654
654
  formatOnType: editorPrefs.formatOnType === true, // off by default: on-type formatting adds per-keystroke latency on slow machines
655
655
  quickSuggestions: editorPrefs.quickSuggestions !== false,
@@ -1213,10 +1213,9 @@ var EditorPanel = function EditorPanel(_ref) {
1213
1213
  autoClosingBrackets: editorPrefs.autoClosingBrackets || 'always',
1214
1214
  autoClosingQuotes: editorPrefs.autoClosingQuotes || 'always',
1215
1215
  autoIndent: editorPrefs.autoIndent || 'full',
1216
- // Monaco own format-on-paste is left off: it never ran the formatter
1217
- // here. attachEditorFeatures drives re-indent-on-paste from onDidPaste
1218
- // instead, gated on editorPrefs.indentOnPaste.
1219
- formatOnPaste: false,
1216
+ // Paste inserts what was copied, unchanged. See attachEditorFeatures
1217
+ // for why re-indenting it was removed rather than repaired.
1218
+ formatOnPaste: false,
1220
1219
  formatOnType: editorPrefs.formatOnType === true, // off by default: on-type formatting adds per-keystroke latency on slow machines
1221
1220
  quickSuggestions: editorPrefs.quickSuggestions !== false,
1222
1221
  wordBasedSuggestions: editorPrefs.wordBasedSuggestions || 'currentDocument',
@@ -1495,8 +1494,19 @@ var EditorPanel = function EditorPanel(_ref) {
1495
1494
  gitLineRefreshRef.current = refresh;
1496
1495
 
1497
1496
  // Immediate path for writes mbeditor made itself.
1497
+ //
1498
+ // Only when *this* file is one of the ones that changed: saving some other
1499
+ // file cannot move this file's diff against HEAD. Refreshing regardless
1500
+ // meant every open pane ran a git diff on every save anywhere in the
1501
+ // workspace, so the cost of a save scaled with how many editors were open.
1502
+ // A broadcast that names no paths still refreshes — that is the "something
1503
+ // happened, re-check everything" case (a commit, a branch switch), which
1504
+ // genuinely can change this file's diff without touching it.
1498
1505
  var hasSocket = typeof WebSocketService !== 'undefined' && WebSocketService.onFilesChanged;
1499
- var onChanged = hasSocket ? function () { refresh(); } : null;
1506
+ var onChanged = hasSocket ? function (payload) {
1507
+ if (payload && payload.paths && payload.paths.indexOf(tab.path) === -1) return;
1508
+ refresh();
1509
+ } : null;
1500
1510
  if (onChanged) WebSocketService.onFilesChanged(onChanged);
1501
1511
 
1502
1512
  return function () {
@@ -1572,6 +1582,16 @@ var EditorPanel = function EditorPanel(_ref) {
1572
1582
  //
1573
1583
  // Decorations, not view zones: a hint belongs on the line, and a zone would
1574
1584
  // push the code around every time a file was opened.
1585
+ //
1586
+ // Redrawn whenever the text changes, not only when the file is opened. A
1587
+ // decoration is a zero-width range pinned at the end of the `def` line, and
1588
+ // Monaco keeps that position through an edit rather than re-deriving it — so
1589
+ // replacing a method (a paste over the selection is the usual way) left the
1590
+ // hint at a column that now falls in the middle of whatever text took its
1591
+ // place, rendering as `def sho GET /orders/:idw`. Nothing recomputed it,
1592
+ // so saving did not clear it either; it survived until the file was
1593
+ // reopened. The route data itself only depends on the route set, so an edit
1594
+ // redraws from the cached response without asking the server again.
1575
1595
  useEffect(function () {
1576
1596
  var editor = monacoRef.current;
1577
1597
  if (!editor || !window.monaco) return;
@@ -1581,17 +1601,19 @@ var EditorPanel = function EditorPanel(_ref) {
1581
1601
  routeDecorationsRef.current = editor.deltaDecorations(routeDecorationsRef.current, []);
1582
1602
  };
1583
1603
 
1584
- if (!tab.path || !/^app\/controllers\/.+_controller\.rb$/.test(tab.path)) {
1604
+ if (editorPrefs.routeHints === false ||
1605
+ !tab.path || !/^app\/controllers\/.+_controller\.rb$/.test(tab.path)) {
1585
1606
  clear();
1586
1607
  return;
1587
1608
  }
1588
1609
 
1589
1610
  var cancelled = false;
1590
- FileService.getRoutes(tab.path).then(function (data) {
1611
+ var redrawTimer = null;
1612
+
1613
+ var draw = function (actions) {
1591
1614
  if (cancelled) return;
1592
- var actions = (data && data.actions) || {};
1593
1615
  var model = editor.getModel();
1594
- if (!model) return;
1616
+ if (!model || model.isDisposed()) return;
1595
1617
 
1596
1618
  var decorations = [];
1597
1619
  var text = model.getValue().split('\n');
@@ -1634,10 +1656,47 @@ var EditorPanel = function EditorPanel(_ref) {
1634
1656
  });
1635
1657
 
1636
1658
  routeDecorationsRef.current = editor.deltaDecorations(routeDecorationsRef.current, decorations);
1659
+ };
1660
+
1661
+ var contentSub = null;
1662
+ FileService.getRoutes(tab.path).then(function (data) {
1663
+ if (cancelled) return;
1664
+ var actions = (data && data.actions) || {};
1665
+ draw(actions);
1666
+
1667
+ // Debounced: a redraw per keystroke would rebuild every decoration in
1668
+ // the file for an edit that usually cannot have moved one.
1669
+ var model = editor.getModel();
1670
+ if (!model || !model.onDidChangeContent) return;
1671
+ contentSub = model.onDidChangeContent(function () {
1672
+ clearTimeout(redrawTimer);
1673
+ redrawTimer = setTimeout(function () { draw(actions); }, 250);
1674
+ });
1637
1675
  }).catch(function () { /* hints are additive; a failure just means none */ });
1638
1676
 
1639
- return function () { cancelled = true; clear(); };
1640
- }, [tab.path, tab.externalContentVersion, monacoReady]);
1677
+ return function () {
1678
+ cancelled = true;
1679
+ clearTimeout(redrawTimer);
1680
+ if (contentSub) contentSub.dispose();
1681
+ clear();
1682
+ };
1683
+ }, [tab.path, tab.externalContentVersion, monacoReady, editorPrefs.routeHints]);
1684
+
1685
+ // Blame and test view zones are anchored to line numbers, so they only need
1686
+ // rebuilding once an edit has settled. Both effects used to list tab.content
1687
+ // directly, which tore down and re-created every zone in the file on every
1688
+ // keystroke. This ticks 250 ms after the last change instead — the same
1689
+ // treatment the route-hint redraw above already gets.
1690
+ var _useStateZT = useState(0);
1691
+ var _useStateZT2 = _slicedToArray(_useStateZT, 2);
1692
+ var zoneTick = _useStateZT2[0];
1693
+ var setZoneTick = _useStateZT2[1];
1694
+ useEffect(function () {
1695
+ var timer = setTimeout(function () {
1696
+ setZoneTick(function (n) { return n + 1; });
1697
+ }, 250);
1698
+ return function () { clearTimeout(timer); };
1699
+ }, [tab.content]);
1641
1700
 
1642
1701
  // Render Blame block headers (author + summary) above contiguous commit regions.
1643
1702
  useEffect(function () {
@@ -1725,8 +1784,8 @@ var EditorPanel = function EditorPanel(_ref) {
1725
1784
  blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
1726
1785
  }
1727
1786
 
1728
- // Include tab.content so blame re-renders once async file contents finish loading.
1729
- }, [blameData, isBlameVisible, tab.id, tab.content]);
1787
+ // zoneTick, not tab.content: the zones follow the content, 250 ms behind it.
1788
+ }, [blameData, isBlameVisible, tab.id, zoneTick]);
1730
1789
 
1731
1790
  // Check whether the current tab is the source file for the test that was run.
1732
1791
  // e.g. testPanelFile = "test/controllers/theme_controller_test.rb"
@@ -1804,6 +1863,14 @@ var EditorPanel = function EditorPanel(_ref) {
1804
1863
  return null;
1805
1864
  };
1806
1865
 
1866
+ // treeHasPath walks the whole file tree once per candidate, and the Test
1867
+ // button asked for this on every render — so once per keystroke. treeData is
1868
+ // deliberately outside the React.memo comparator below, so it can only change
1869
+ // on a render this component was going to do anyway.
1870
+ var testFileForTab = useMemo(function () {
1871
+ return matchingTestFilePath(tab.path);
1872
+ }, [tab.path, treeData]);
1873
+
1807
1874
  // Map a test method name to the best-matching line in the source file.
1808
1875
  // Extracts keywords from the test name and scores each source line.
1809
1876
  var mapTestToSourceLine = function(testName, sourceContent) {
@@ -1840,9 +1907,17 @@ var EditorPanel = function EditorPanel(_ref) {
1840
1907
  var model = editor.getModel();
1841
1908
  var lineCount = model ? model.getLineCount() : 0;
1842
1909
 
1843
- var showHere = testPanelFile && tab.path && isSourceForTest(tab.path, testPanelFile);
1910
+ // Which test file a result entry belongs to. A whole-suite run spans many
1911
+ // of them and stamps each entry with its own `file`, so there is no single
1912
+ // testPanelFile to gate on; a per-file run leaves `file` unset on the
1913
+ // passing entries and testPanelFile is the answer for those.
1914
+ var testFileFor = function (t) { return (t && t.file) || testPanelFile; };
1915
+ var relevantTests = ((testResult && testResult.tests) || []).filter(function (t) {
1916
+ var file = testFileFor(t);
1917
+ return file && tab.path && isSourceForTest(tab.path, file);
1918
+ });
1844
1919
 
1845
- if (!testResult || !testInlineVisible || !showHere) {
1920
+ if (!testResult || !testInlineVisible || relevantTests.length === 0) {
1846
1921
  clearTestZones(editor);
1847
1922
  testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
1848
1923
  return;
@@ -1853,10 +1928,8 @@ var EditorPanel = function EditorPanel(_ref) {
1853
1928
  testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
1854
1929
 
1855
1930
  var normPath = function(p) { return p ? p.replace(/^\/+/, '') : ''; };
1856
- var viewingTestFile = normPath(tab.path) === normPath(testPanelFile);
1857
1931
 
1858
- var tests = testResult.tests || [];
1859
- var testsWithStatus = tests.filter(function (t) {
1932
+ var testsWithStatus = relevantTests.filter(function (t) {
1860
1933
  return t.status === 'pass' || t.status === 'fail' || t.status === 'error';
1861
1934
  });
1862
1935
 
@@ -1865,6 +1938,9 @@ var EditorPanel = function EditorPanel(_ref) {
1865
1938
  // Determine line number for each test
1866
1939
  var sourceContent = tab.content || '';
1867
1940
  var mapped = testsWithStatus.map(function(t) {
1941
+ // Per entry, not per panel: in a suite run this tab is the test file
1942
+ // for some entries and the source counterpart for others.
1943
+ var viewingTestFile = normPath(tab.path) === normPath(testFileFor(t));
1868
1944
  var line;
1869
1945
  if (viewingTestFile && t.line && t.line >= 1 && t.line <= lineCount) {
1870
1946
  line = t.line;
@@ -1923,8 +1999,9 @@ var EditorPanel = function EditorPanel(_ref) {
1923
1999
  testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
1924
2000
  }
1925
2001
 
1926
- // Include tab.content so zones re-render once async file content loads (same as blame).
1927
- }, [testResult, testInlineVisible, testPanelFile, tab.id, tab.path, tab.content]);
2002
+ // zoneTick, not tab.content see the blame effect. The effect still reads
2003
+ // tab.content for mapTestToSourceLine; the tick only decides when it re-runs.
2004
+ }, [testResult, testInlineVisible, testPanelFile, tab.id, tab.path, zoneTick]);
1928
2005
 
1929
2006
  var sourceTab = tab.isPreview ? findTabByPath(tab.previewFor) : null;
1930
2007
  var markdownContent = tab.isPreview ? sourceTab && sourceTab.content || tab.content || '' : tab.content || '';
@@ -2253,7 +2330,7 @@ var EditorPanel = function EditorPanel(_ref) {
2253
2330
  React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
2254
2331
  !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, isBlameLoading ? 'Loading...' : 'Blame')
2255
2332
  ),
2256
- testAvailable && matchingTestFilePath(tab.path) && React.createElement(
2333
+ testAvailable && testFileForTab && React.createElement(
2257
2334
  'button',
2258
2335
  {
2259
2336
  className: 'ide-icon-btn',
@@ -423,7 +423,12 @@ var FileTree = function FileTree(_ref) {
423
423
  return result;
424
424
  };
425
425
 
426
- var flatItems = flattenVisible(items || [], 0, '');
426
+ // A per-level localeCompare sort of the whole visible tree, re-run on every
427
+ // render — including the ones caused by a git poll or a tab switch, neither
428
+ // of which can change the shape of the tree. Only these three inputs can.
429
+ var flatItems = useMemo(function () {
430
+ return flattenVisible(items || [], 0, '');
431
+ }, [items, expandedDirs, pendingCreate]);
427
432
  flatItemsRef.current = flatItems;
428
433
 
429
434
  var totalHeight = flatItems.length * ROW_HEIGHT;
@@ -516,4 +516,13 @@ var GitPanel = function GitPanel(_ref) {
516
516
  );
517
517
  };
518
518
 
519
- window.GitPanel = GitPanel;
519
+ // Same convention as FileTreeMemo: compare the data props only. The handlers
520
+ // are re-created on every MbeditorApp render but each one only calls a setState
521
+ // or a service, so they are stable in effect. Without this the panel — commit
522
+ // history, file lists, the Redmine card — re-rendered on every keystroke in the
523
+ // editor, because MbeditorApp is subscribed to the whole store.
524
+ window.GitPanel = React.memo(GitPanel, function (prev, next) {
525
+ return prev.gitInfo === next.gitInfo &&
526
+ prev.error === next.error &&
527
+ prev.redmineEnabled === next.redmineEnabled;
528
+ });
@@ -7,12 +7,10 @@
7
7
  //
8
8
  // Errors from the same pass render here too, so a drop that both conflicts
9
9
  // and fails needs one dialog rather than two.
10
- var ImportConflictModal = function ImportConflictModal(_ref) {
11
- var conflicts = _ref.conflicts || [];
12
- var errors = _ref.errors || [];
13
- var onResolve = _ref.onResolve;
14
- var modalRef = React.useRef(null);
15
-
10
+ // Focus containment for a modal: initial focus in, Tab cycles inside, Escape
11
+ // dismisses, focus restored on unmount. Shared with ImportDialog — both are
12
+ // overlays over an editor that will happily steal focus back otherwise.
13
+ window.useModalFocusTrap = function useModalFocusTrap(modalRef, onDismiss) {
16
14
  React.useEffect(function () {
17
15
  var previouslyFocused = document.activeElement;
18
16
  var modal = modalRef.current;
@@ -25,7 +23,7 @@ var ImportConflictModal = function ImportConflictModal(_ref) {
25
23
  if (e.key === 'Escape') {
26
24
  e.preventDefault();
27
25
  e.stopPropagation();
28
- onResolve('skip');
26
+ onDismiss();
29
27
  return;
30
28
  }
31
29
  if (e.key !== 'Tab' || !modal) return;
@@ -57,7 +55,16 @@ var ImportConflictModal = function ImportConflictModal(_ref) {
57
55
  previouslyFocused.focus();
58
56
  }
59
57
  };
60
- }, [onResolve]);
58
+ }, [onDismiss]);
59
+ };
60
+
61
+ var ImportConflictModal = function ImportConflictModal(_ref) {
62
+ var conflicts = _ref.conflicts || [];
63
+ var errors = _ref.errors || [];
64
+ var onResolve = _ref.onResolve;
65
+ var modalRef = React.useRef(null);
66
+
67
+ window.useModalFocusTrap(modalRef, React.useCallback(function () { onResolve('skip'); }, [onResolve]));
61
68
 
62
69
  var button = function (label, mode, className) {
63
70
  return React.createElement(