mbeditor 0.12.8 → 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 +207 -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 +135 -40
  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 +629 -137
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +218 -13
  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 +444 -116
  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 +302 -55
  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 +75 -10
  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: 4f4e31cf2770935dab538488ed3b946b26c7bacfb8519a7d1069cd84d8064838
4
- data.tar.gz: db294fee8ac1938dc03f58d0332e13c2da4c8f2a322c62a22fbcd859b8d3b17c
3
+ metadata.gz: b65424322b66f78929de0629db6e4644a76467e889adaee77121b8c52e0e50de
4
+ data.tar.gz: 53cd7724f2a80011d474a9616bdb35d88859ddf797a0fa51d3657740038624bd
5
5
  SHA512:
6
- metadata.gz: d3d8769779acc2c71a535c2d324385f182937a117218471f42dccac8ebe10c0e0741b46da229d3f388c70d8f10c27e510ae25c0eb6e8507dc7967c6e694c46d6
7
- data.tar.gz: 130c85228b63ab45775d542086a7bb3104515cf07ecf15e88c8c23e8fe79f42207c9c1ab1f2f3ee16bf611a712c599335d5074e2060d1da90ac37dc52b833b09
6
+ metadata.gz: f4470580292e46d6de7eb2e3687193452bfbcbee5d478e48c6460e22739fd2942d1a525b04e6d3f7877175db5d18da86c3241032dfc748ca91bf62f0f008cd90
7
+ data.tar.gz: 05e4226ce7151a7e72d18f34018c30acc04adfbaea235a891afe12ecadb52170b1b598820ea7de326068eae7bb7811cce13ec5cf42b0c69fd6c77683c3b7c819
data/CHANGELOG.md CHANGED
@@ -7,6 +7,213 @@ 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
+
160
+ ## [0.12.9] - 2026-08-07
161
+
162
+ ### Added
163
+ - **Problems panel severity filter.** Three toggle chips — errors, warnings,
164
+ info — each showing its live count, filtering the list independently of the
165
+ text filter. Multi-select rather than one-of-three, so "errors and warnings
166
+ but not the convention noise" is expressible. The selection persists, and the
167
+ last active severity stays latched on so the panel can't be filtered down to
168
+ an unexplained blank.
169
+ - **Search results carry match columns.** Clicking a result now puts the cursor
170
+ at the end of the matched text instead of the start of the line, ready to
171
+ type. Columns are measured against the raw source line on the server —
172
+ the row's display text is stripped, so a column derived from it would land
173
+ short by the indent on every indented hit.
174
+
175
+ ### Changed
176
+ - **The Problems header no longer restates its counts in prose.** The severity
177
+ chips carry the same three numbers.
178
+ - **Paste re-indents instead of reformatting.** Pasting ran the pasted range
179
+ through Prettier, which reprinted the whole enclosing statement — and for a
180
+ paste that filled the document, every line of it — so pasting a snippet
181
+ rewrote code you never touched. Paste now only re-indents to the file's
182
+ indentation setting; quotes, semicolons and spacing come through exactly as
183
+ copied. The setting is renamed "Format on paste" → "Indent on paste"
184
+ (`indentOnPaste`, still on by default). Monaco ships no indentation rules for
185
+ JavaScript, which would have made the re-indent a silent no-op there, so
186
+ VS Code's JS rules are now registered. Format-on-save is unchanged and still
187
+ off by default.
188
+
189
+ ### Fixed
190
+ - **Go-to-definition on a JS symbol defined in the same file opened a junk tab
191
+ named after a number.** Models are created without an explicit URI, so Monaco
192
+ identifies each as `inmemory://model/N`; the TS worker returns that URI for
193
+ an in-file definition, and the editor opener stripped it to a path and opened
194
+ a phantom tab called "57". Only `file://` resources are treated as workspace
195
+ files now — the rest go back to Monaco, which reveals the position in the
196
+ current editor.
197
+ - **Clicking a search result sometimes landed on the wrong line.** Opening a
198
+ hit in a file that wasn't already open ran the jump against a still-empty
199
+ model: it clamped to line 1 and then cleared the pending jump, so when the
200
+ content finally arrived nothing re-triggered it. Whether it misbehaved came
201
+ down to whether the fetch beat a 50 ms timer — hence "sometimes". The jump
202
+ now waits for the content, cancels a superseded timer, and clamps to the
203
+ file's real length when a result outlives the line it pointed at.
204
+ - **Search results ignored created and deleted files.** Only saves dropped the
205
+ client-side search cache, so a search re-run after adding or removing a file
206
+ was served the stale cached page. Every structural mutation now invalidates
207
+ it and re-runs the active query.
208
+ - **Search and the git status counts lagged behind file changes.** The
209
+ live-result refresh sat behind a 2 s debounce (now 250 ms), and every save
210
+ fired the full `/git_info` fan-out — the most expensive request the editor
211
+ makes — twice over, once directly and once from the broadcast handler, which
212
+ on a dev server with a few threads queued the tree and search requests behind
213
+ it. Saves and file mutations now use the cheap `/git_status` probe, which
214
+ patches the branch and file list immediately and escalates to the fan-out
215
+ itself only when the branch actually changed.
216
+
10
217
  ## [0.12.8] - 2026-08-07
11
218
 
12
219
  ### 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's own format-on-paste is left off: it never ran the formatter
651
- // here. attachEditorFeatures drives it from onDidPaste instead, gated on
652
- // the same editorPrefs.formatOnPaste setting.
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's own format-on-paste is left off: it never ran the formatter
1217
- // here. attachEditorFeatures drives it from onDidPaste instead, gated on
1218
- // the same editorPrefs.formatOnPaste setting.
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',
@@ -1301,22 +1300,40 @@ var EditorPanel = function EditorPanel(_ref) {
1301
1300
  return function() { window.removeEventListener('mbeditor:focusPane', onFocusPane); };
1302
1301
  }, [paneId]);
1303
1302
 
1304
- // Jump to line if specified
1303
+ // Jump to line if specified.
1304
+ //
1305
+ // The wait for content is load-bearing, not defensive. Opening a search hit
1306
+ // in a file that wasn't already open runs this effect once while the model
1307
+ // is still empty: the jump clamped to line 1 and then cleared gotoLine, so
1308
+ // when the content finally arrived there was nothing left to re-trigger it
1309
+ // and the cursor stayed on the wrong line. Whether it misbehaved came down
1310
+ // to whether the fetch beat a 50 ms timer — hence "sometimes".
1305
1311
  useEffect(function () {
1306
- if (tab.gotoLine && monacoRef.current) {
1307
- (function () {
1308
- var editor = monacoRef.current;
1309
- setTimeout(function () {
1310
- editor.revealLineInCenter(tab.gotoLine);
1311
- editor.setPosition({ lineNumber: tab.gotoLine, column: tab.gotoCol || 1 });
1312
- editor.focus();
1313
-
1314
- TabManager.saveTabViewState(tab.id, editor.saveViewState());
1315
- TabManager.clearGotoLine(paneId, tab.path);
1316
- }, 50);
1317
- })();
1318
- }
1319
- }, [tab.gotoLine, tab.content]); // need tab.content in dep array so if it loads asynchronously, the jump happens AFTER content loads
1312
+ if (!tab.gotoLine || !monacoRef.current) return;
1313
+ if (tab.loading) return;
1314
+
1315
+ var editor = monacoRef.current;
1316
+ var timer = setTimeout(function () {
1317
+ var model = editor.getModel();
1318
+ if (!model || model.isDisposed()) return;
1319
+
1320
+ // A result can outlive the line it pointed at (the file shrank since the
1321
+ // scan). Clamp instead of asking Monaco for a line that isn't there.
1322
+ var line = Math.max(1, Math.min(tab.gotoLine, model.getLineCount()));
1323
+ var column = tab.gotoCol || 1;
1324
+ var maxColumn = model.getLineMaxColumn(line);
1325
+ if (column > maxColumn) column = maxColumn;
1326
+
1327
+ editor.revealLineInCenter(line);
1328
+ editor.setPosition({ lineNumber: line, column: column });
1329
+ editor.focus();
1330
+
1331
+ TabManager.saveTabViewState(tab.id, editor.saveViewState());
1332
+ TabManager.clearGotoLine(paneId, tab.path);
1333
+ }, 50);
1334
+
1335
+ return function () { clearTimeout(timer); };
1336
+ }, [tab.gotoLine, tab.gotoCol, tab.content, tab.loading]);
1320
1337
 
1321
1338
  // Apply RuboCop markers
1322
1339
  useEffect(function () {
@@ -1477,8 +1494,19 @@ var EditorPanel = function EditorPanel(_ref) {
1477
1494
  gitLineRefreshRef.current = refresh;
1478
1495
 
1479
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.
1480
1505
  var hasSocket = typeof WebSocketService !== 'undefined' && WebSocketService.onFilesChanged;
1481
- 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;
1482
1510
  if (onChanged) WebSocketService.onFilesChanged(onChanged);
1483
1511
 
1484
1512
  return function () {
@@ -1554,6 +1582,16 @@ var EditorPanel = function EditorPanel(_ref) {
1554
1582
  //
1555
1583
  // Decorations, not view zones: a hint belongs on the line, and a zone would
1556
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.
1557
1595
  useEffect(function () {
1558
1596
  var editor = monacoRef.current;
1559
1597
  if (!editor || !window.monaco) return;
@@ -1563,17 +1601,19 @@ var EditorPanel = function EditorPanel(_ref) {
1563
1601
  routeDecorationsRef.current = editor.deltaDecorations(routeDecorationsRef.current, []);
1564
1602
  };
1565
1603
 
1566
- 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)) {
1567
1606
  clear();
1568
1607
  return;
1569
1608
  }
1570
1609
 
1571
1610
  var cancelled = false;
1572
- FileService.getRoutes(tab.path).then(function (data) {
1611
+ var redrawTimer = null;
1612
+
1613
+ var draw = function (actions) {
1573
1614
  if (cancelled) return;
1574
- var actions = (data && data.actions) || {};
1575
1615
  var model = editor.getModel();
1576
- if (!model) return;
1616
+ if (!model || model.isDisposed()) return;
1577
1617
 
1578
1618
  var decorations = [];
1579
1619
  var text = model.getValue().split('\n');
@@ -1616,10 +1656,47 @@ var EditorPanel = function EditorPanel(_ref) {
1616
1656
  });
1617
1657
 
1618
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
+ });
1619
1675
  }).catch(function () { /* hints are additive; a failure just means none */ });
1620
1676
 
1621
- return function () { cancelled = true; clear(); };
1622
- }, [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]);
1623
1700
 
1624
1701
  // Render Blame block headers (author + summary) above contiguous commit regions.
1625
1702
  useEffect(function () {
@@ -1707,8 +1784,8 @@ var EditorPanel = function EditorPanel(_ref) {
1707
1784
  blameDecorationsRef.current = editor.deltaDecorations(blameDecorationsRef.current, []);
1708
1785
  }
1709
1786
 
1710
- // Include tab.content so blame re-renders once async file contents finish loading.
1711
- }, [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]);
1712
1789
 
1713
1790
  // Check whether the current tab is the source file for the test that was run.
1714
1791
  // e.g. testPanelFile = "test/controllers/theme_controller_test.rb"
@@ -1786,6 +1863,14 @@ var EditorPanel = function EditorPanel(_ref) {
1786
1863
  return null;
1787
1864
  };
1788
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
+
1789
1874
  // Map a test method name to the best-matching line in the source file.
1790
1875
  // Extracts keywords from the test name and scores each source line.
1791
1876
  var mapTestToSourceLine = function(testName, sourceContent) {
@@ -1822,9 +1907,17 @@ var EditorPanel = function EditorPanel(_ref) {
1822
1907
  var model = editor.getModel();
1823
1908
  var lineCount = model ? model.getLineCount() : 0;
1824
1909
 
1825
- 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
+ });
1826
1919
 
1827
- if (!testResult || !testInlineVisible || !showHere) {
1920
+ if (!testResult || !testInlineVisible || relevantTests.length === 0) {
1828
1921
  clearTestZones(editor);
1829
1922
  testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
1830
1923
  return;
@@ -1835,10 +1928,8 @@ var EditorPanel = function EditorPanel(_ref) {
1835
1928
  testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
1836
1929
 
1837
1930
  var normPath = function(p) { return p ? p.replace(/^\/+/, '') : ''; };
1838
- var viewingTestFile = normPath(tab.path) === normPath(testPanelFile);
1839
1931
 
1840
- var tests = testResult.tests || [];
1841
- var testsWithStatus = tests.filter(function (t) {
1932
+ var testsWithStatus = relevantTests.filter(function (t) {
1842
1933
  return t.status === 'pass' || t.status === 'fail' || t.status === 'error';
1843
1934
  });
1844
1935
 
@@ -1847,6 +1938,9 @@ var EditorPanel = function EditorPanel(_ref) {
1847
1938
  // Determine line number for each test
1848
1939
  var sourceContent = tab.content || '';
1849
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));
1850
1944
  var line;
1851
1945
  if (viewingTestFile && t.line && t.line >= 1 && t.line <= lineCount) {
1852
1946
  line = t.line;
@@ -1905,8 +1999,9 @@ var EditorPanel = function EditorPanel(_ref) {
1905
1999
  testDecorationIdsRef.current = editor.deltaDecorations(testDecorationIdsRef.current, []);
1906
2000
  }
1907
2001
 
1908
- // Include tab.content so zones re-render once async file content loads (same as blame).
1909
- }, [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]);
1910
2005
 
1911
2006
  var sourceTab = tab.isPreview ? findTabByPath(tab.previewFor) : null;
1912
2007
  var markdownContent = tab.isPreview ? sourceTab && sourceTab.content || tab.content || '' : tab.content || '';
@@ -2235,7 +2330,7 @@ var EditorPanel = function EditorPanel(_ref) {
2235
2330
  React.createElement('i', { className: 'fas fa-shoe-prints', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
2236
2331
  !editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, isBlameLoading ? 'Loading...' : 'Blame')
2237
2332
  ),
2238
- testAvailable && matchingTestFilePath(tab.path) && React.createElement(
2333
+ testAvailable && testFileForTab && React.createElement(
2239
2334
  'button',
2240
2335
  {
2241
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;