mbeditor 0.10.1 → 0.12.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +191 -0
  3. data/README.md +226 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +948 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +661 -140
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +68 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +273 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +481 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/js_globals_service.rb +31 -2
  37. data/app/services/mbeditor/js_program_service.rb +173 -0
  38. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  39. data/app/services/mbeditor/model_graph_service.rb +232 -0
  40. data/app/services/mbeditor/presence_registry.rb +83 -0
  41. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  42. data/app/services/mbeditor/search_replace_service.rb +24 -4
  43. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  44. data/lib/mbeditor/configuration.rb +43 -3
  45. data/lib/mbeditor/engine.rb +34 -0
  46. data/lib/mbeditor/exception_log.rb +84 -0
  47. data/lib/mbeditor/route_map.rb +6 -0
  48. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  49. data/lib/mbeditor/version.rb +1 -1
  50. data/lib/mbeditor.rb +1 -0
  51. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  52. metadata +16 -2
@@ -17,8 +17,18 @@ module Mbeditor
17
17
  MAX_DESC_LINES = 3
18
18
 
19
19
  # When a method is defined on many classes, prefer these over the
20
- # first-alphabetical class (e.g. prefer BasicObject#new over Addrinfo#new).
21
- PREFERRED_CLASSES = %w[BasicObject Object Kernel Module Class].freeze
20
+ # first-alphabetical class, which is almost never the one meant:
21
+ # `new` would resolve to Addrinfo and `each` to ARGF.
22
+ #
23
+ # Ordered most-general first. We don't know the receiver's type, so the
24
+ # honest answer for a bare `each` is Enumerable's, not the first class
25
+ # ri happens to list.
26
+ PREFERRED_CLASSES = %w[
27
+ BasicObject Object Kernel Module Class
28
+ Enumerable Comparable
29
+ Array Hash String Symbol Integer Float Numeric Range Enumerator
30
+ Struct Set Time File IO Exception Proc Method NilClass
31
+ ].freeze
22
32
 
23
33
  @cache = {}
24
34
  @mutex = Mutex.new
@@ -55,6 +65,12 @@ module Mbeditor
55
65
 
56
66
  private
57
67
 
68
+ # "map()" or "each()" — a name and empty parens, telling you nothing about
69
+ # arguments, block or return value.
70
+ def bare_signature?(signature)
71
+ signature.to_s.strip.match?(/\A[\w.:]+\(\)\z/)
72
+ end
73
+
58
74
  def run_ri
59
75
  out = nil
60
76
  Open3.popen3("ri", "--no-pager", "--format=rdoc", @symbol) do |stdin, stdout, _stderr, wait_thr|
@@ -83,8 +99,23 @@ module Mbeditor
83
99
  blocks = extract_blocks(lines)
84
100
  return [] if blocks.empty?
85
101
 
86
- best = blocks.find { |b| PREFERRED_CLASSES.include?(b[:impl]) } || blocks.first
87
- return [] if best[:sigs].empty?
102
+ # Rank by position in PREFERRED_CLASSES, not by ri's own ordering, so the
103
+ # most general implementation wins rather than whichever preferred class
104
+ # ri happened to print first.
105
+ #
106
+ # Choose only among recognised classes when there are any — otherwise a
107
+ # rich signature on some obscure class (Enumerator::Lazy#map) would beat
108
+ # the terse one on the class actually meant.
109
+ preferred = blocks.select { |b| PREFERRED_CLASSES.include?(b[:impl]) }
110
+ candidates = preferred.any? ? preferred : blocks
111
+
112
+ # Within that set, skip entries ri prints as a bare "map()" — they
113
+ # document nothing — before falling back to class order.
114
+ best = candidates.min_by do |b|
115
+ [bare_signature?(b[:sigs].first) ? 1 : 0,
116
+ PREFERRED_CLASSES.index(b[:impl]) || PREFERRED_CLASSES.length]
117
+ end
118
+ return [] if best.nil? || best[:sigs].empty?
88
119
 
89
120
  desc = best[:desc]
90
121
  .first(MAX_DESC_LINES)
@@ -127,8 +158,11 @@ module Mbeditor
127
158
  sep_indices = lines.each_index.select { |i| lines[i].match?(/\A-{5,}\z/) }
128
159
  return nil if sep_indices.length < 2
129
160
 
161
+ # ri pads signatures into columns for its terminal output, e.g.
162
+ # "each(sep=$/) {|line| block } -> ARGF". Those runs of
163
+ # spaces are meaningless here and render as a gaping hole in the hover.
130
164
  sigs = lines[(sep_indices[0] + 1)..(sep_indices[1] - 1)]
131
- .map(&:strip)
165
+ .map { |l| l.strip.squeeze(" ") }
132
166
  .reject(&:empty?)
133
167
  return nil if sigs.empty?
134
168
 
@@ -39,6 +39,19 @@ module Mbeditor
39
39
  AvailabilityProbe.rg
40
40
  end
41
41
 
42
+ # The resolved ripgrep executable, shared with CodeSearchService so both
43
+ # search paths run the same binary.
44
+ def rg_command
45
+ AvailabilityProbe.rg_command || "rg"
46
+ end
47
+
48
+ # Which backend a search on this workspace will actually use. Reported by
49
+ # GET /workspace: the difference between the tiers is 10-30x, so "why is
50
+ # search slow" needs to be answerable without reading the source.
51
+ def backend(workspace_root)
52
+ pick_tier(workspace_root.to_s)
53
+ end
54
+
42
55
  # Returns up to +limit+ result rows. When +paths+ is given the scan is
43
56
  # restricted to those files (used by the live result-refresh) and the
44
57
  # result cache is bypassed.
@@ -242,7 +255,7 @@ module Mbeditor
242
255
 
243
256
  case tier
244
257
  when :rg
245
- args = ["rg", "--json"]
258
+ args = [rg_command, "--json"]
246
259
  args << "--no-ignore" unless respect_gitignore?
247
260
  args << "-F" unless use_regex
248
261
  args << "--ignore-case" unless match_case
@@ -264,9 +277,16 @@ module Mbeditor
264
277
  args << "-w" if whole_word
265
278
  args += ["-e", query, "--"]
266
279
  args += paths.map { |p| relative_path(File.expand_path(p, root), root) } if paths
267
- # C locale restores fast byte-wise case folding for -i (non-ASCII
268
- # characters simply don't case-fold, which is acceptable here).
269
- [{ "LC_ALL" => "C" }, args]
280
+ # Exclusions have to reach git as pathspecs, not just be dropped from
281
+ # the results by the matcher below: otherwise git walks node_modules
282
+ # and every other excluded tree in full before we discard the matches.
283
+ # A pathspec list of nothing but :(exclude) entries means
284
+ # "everything except these", which is exactly what the unscoped
285
+ # search wants.
286
+ args += exclusions.map { |p| ":(exclude)#{p}" }
287
+ # No LC_ALL=C: measured neutral for the -F -i default and 2.2x slower
288
+ # for -E, and the UTF-8 locale case-folds non-ASCII correctly.
289
+ [{}, args]
270
290
  else
271
291
  base_flags = use_regex ? "-E" : "-F"
272
292
  args = ["grep", "-I", "-Hn", base_flags]
@@ -29,6 +29,8 @@
29
29
  <script defer src="<%= asset_path('react-dom.min.js') %>"></script>
30
30
  <script defer src="<%= asset_path('axios.min.js') %>"></script>
31
31
  <script defer src="<%= asset_path('lodash.min.js') %>"></script>
32
+ <!-- Yjs + y-monaco + y-protocols (awareness) — maintainer-prebuilt bundle for collaborative editing; exposes window.Y / window.MonacoBinding / window.awarenessProtocol -->
33
+ <script defer src="<%= asset_path('yjs-collab.js') %>"></script>
32
34
  <script defer src="<%= asset_path('minisearch.min.js') %>"></script>
33
35
  <script defer src="<%= asset_path('marked.min.js') %>"></script>
34
36
  <!-- ── Monaco (ESM bundle, ADR 0002): stylesheet here, JS loaded in body ── -->
@@ -5,13 +5,15 @@ module Mbeditor
5
5
  attr_accessor :allowed_environments, :workspace_root, :excluded_paths, :rubocop_command, :rubocop_server,
6
6
  :redmine_enabled, :redmine_url, :redmine_api_key, :redmine_ticket_source,
7
7
  :test_framework, :test_command, :test_timeout,
8
- :authenticate_with, :authentication_cache_ttl,
8
+ :authenticate_with, :authentication_cache_ttl, :user_name_callback,
9
9
  :lint_timeout, :base_branch_candidates, :git_timeout, :search_timeout,
10
10
  :ruby_def_include_dirs, :related_files_custom_paths,
11
11
  :mount_path, :resilient_routing, :js_global_identifiers,
12
+ :js_program, :js_program_exclude,
12
13
  :js_syntax_check, :babel_standalone_path,
13
14
  :ruby_lsp, :ruby_lsp_command, :ruby_lsp_timeout,
14
- :search_respect_gitignore
15
+ :exception_capture,
16
+ :search_respect_gitignore, :ripgrep_command
15
17
 
16
18
  def initialize
17
19
  @allowed_environments = [:development]
@@ -30,16 +32,54 @@ module Mbeditor
30
32
  @base_branch_candidates = %w[origin/develop origin/main origin/master develop main master]
31
33
  @git_timeout = 10 # seconds; nil disables (no timeout on git subprocesses)
32
34
  @search_timeout = 15 # seconds; wall-clock bound on every search subprocess, nil disables
33
- @search_respect_gitignore = false # true skips .gitignore'd files in project search and definition lookups
35
+ # Skip .gitignore'd files in project search and definition lookups.
36
+ #
37
+ # Defaults to true, matching VS Code and ripgrep. The false path has to
38
+ # ask git for --no-index, which walks every ignored tree the app has —
39
+ # node_modules, public/packs, app/assets/builds, caches — and on the
40
+ # git-grep tier (no ripgrep installed) that measured 29x slower on this
41
+ # repo alone: 3714 files walked instead of 253. It also fills results
42
+ # with matches inside minified bundles.
43
+ #
44
+ # Set to false to search ignored files too; expect it to be slow on a
45
+ # machine without ripgrep.
46
+ @search_respect_gitignore = true
47
+ # Path to the ripgrep binary. nil auto-resolves: PATH first, then the
48
+ # usual install prefixes.
49
+ #
50
+ # This matters more than it looks. ripgrep is 10-30x faster than the
51
+ # git-grep fallback, and the probe used to run a bare "rg" — so it found
52
+ # ripgrep only if the *server process* had it on PATH. A Rails server
53
+ # started from launchd, systemd, foreman or an IDE typically inherits a
54
+ # stripped PATH without /opt/homebrew/bin or /usr/local/bin, so ripgrep
55
+ # could be installed and still invisible, silently dropping every search
56
+ # to the slow tier. GET /workspace reports the tier actually in use.
57
+ @ripgrep_command = nil
34
58
  @ruby_def_include_dirs = %w[app/models app/controllers app/helpers app/concerns]
35
59
  @related_files_custom_paths = []
36
60
  @authentication_cache_ttl = 0
61
+ @user_name_callback = nil # proc resolved in controller context (instance_exec) → collaboration display name; nil falls through to the client-generated name
37
62
  @js_global_identifiers = [] # extra ambient JS globals for the editor (runtime-only names invisible to static scan, e.g. %w[Routes I18n])
63
+ # Load the workspace's own JS source into Monaco's TypeScript program, so
64
+ # cross-file references get real inferred types instead of ambient `any`.
65
+ @js_program = true
66
+ # Excluded from that program on top of excluded_paths. Vendored libraries
67
+ # are UMD-wrapped (the global is assigned inside a closure), so their
68
+ # source yields no globals to TypeScript and only costs parse time — they
69
+ # stay on ambient declarations instead. Add any other directory of
70
+ # third-party or generated JS here, e.g. "app/assets/javascripts/react".
71
+ @js_program_exclude = %w[vendor]
38
72
  @js_syntax_check = :auto # save-time babel parse check via host mini_racer + babel-standalone; false disables
39
73
  @babel_standalone_path = nil # explicit path to babel-standalone JS; nil auto-detects via the asset pipeline
40
74
  @ruby_lsp = :auto # use the host's ruby-lsp for Ruby definitions/hover/completion when available; false disables
41
75
  @ruby_lsp_command = nil # override the ruby-lsp launch command (String or Array); nil auto-resolves bin/ruby-lsp > gem > bundle exec
42
76
  @ruby_lsp_timeout = 3 # seconds per LSP request before falling back to the built-in services
77
+ # Record exceptions raised by the host app's controllers so they show up
78
+ # in the editor's Problems panel instead of only in the log. Development
79
+ # only. Exception messages can contain interpolated request params — the
80
+ # same exposure the log panel already has, since it tails the dev log.
81
+ # Set to false to record nothing.
82
+ @exception_capture = :auto
43
83
  @mount_path = nil # explicit URL prefix override; nil falls through to detection/"/mbeditor"
44
84
  @resilient_routing = true # serve /mbeditor from middleware so the editor survives a broken host routes.rb; false is the escape hatch
45
85
  end
@@ -77,6 +77,8 @@ module Mbeditor
77
77
 
78
78
  cfg = Mbeditor.configuration
79
79
 
80
+ subscribe_to_exceptions if Rails.env.development? && cfg.exception_capture != false
81
+
80
82
  if cfg.workspace_root.present? && !File.directory?(cfg.workspace_root.to_s)
81
83
  raise ArgumentError, "[mbeditor] config.workspace_root is set to '#{cfg.workspace_root}' but that path is not a directory"
82
84
  end
@@ -110,6 +112,7 @@ module Mbeditor
110
112
  react-dom.min.js
111
113
  axios.min.js
112
114
  lodash.min.js
115
+ yjs-collab.js
113
116
  minisearch.min.js
114
117
  marked.min.js
115
118
  prettier-standalone.js
@@ -125,5 +128,36 @@ module Mbeditor
125
128
  fa-solid-900.woff2
126
129
  ]
127
130
  end
131
+
132
+ # Records controller exceptions into ExceptionLog and pushes them to any
133
+ # open editor over the existing cable channel.
134
+ #
135
+ # process_action.action_controller rather than a Rack middleware:
136
+ # ActionDispatch::DebugExceptions rescues and renders the error, so nothing
137
+ # outside it ever sees the raise. Rails.error.subscribe would be a cleaner
138
+ # API but only carries unhandled request errors reliably on newer Rails,
139
+ # and this gem supports 7.1.
140
+ def self.subscribe_to_exceptions
141
+ return if @exception_subscriber
142
+
143
+ @exception_subscriber = ActiveSupport::Notifications.subscribe(
144
+ "process_action.action_controller"
145
+ ) do |*args|
146
+ payload = ActiveSupport::Notifications::Event.new(*args).payload
147
+ exception = payload[:exception_object]
148
+ next unless exception
149
+
150
+ entry = Mbeditor::ExceptionLog.record(
151
+ exception, payload,
152
+ workspace_root: Mbeditor::WorkspaceRootResolver.call
153
+ )
154
+ next unless entry && defined?(ActionCable.server)
155
+
156
+ ActionCable.server.broadcast("mbeditor_editor", entry)
157
+ rescue StandardError => e
158
+ # A monitoring hook must never be able to break the request it observes.
159
+ Rails.logger.debug("[mbeditor] exception capture failed: #{e.class}: #{e.message}")
160
+ end
161
+ end
128
162
  end
129
163
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # A bounded, in-memory ring of exceptions raised by the host app, so a failed
5
+ # request shows up in the editor instead of only in the log.
6
+ #
7
+ # Lives in lib/ (required from lib/mbeditor.rb, NOT autoloaded) for the same
8
+ # reason as RubyLspClient: a Zeitwerk reload in the host's dev environment
9
+ # must not wipe the buffer you are trying to read.
10
+ #
11
+ # Fed by an ActiveSupport::Notifications subscriber wired up in the engine.
12
+ # A Rack middleware cannot be used here: ActionDispatch::DebugExceptions
13
+ # rescues and renders the error, so nothing outside it ever sees the raise.
14
+ module ExceptionLog
15
+ MAX_ENTRIES = 50
16
+
17
+ # Backtraces are mostly framework and gem frames, which this editor cannot
18
+ # open and which push the app's own frames off the end.
19
+ MAX_FRAMES = 10
20
+
21
+ MUTEX = Mutex.new
22
+ private_constant :MUTEX
23
+
24
+ class << self
25
+ # Returns the recorded entry, or nil when there was nothing to record.
26
+ def record(exception, payload = {}, workspace_root: nil)
27
+ return nil if exception.nil?
28
+
29
+ entry = build(exception, payload, workspace_root)
30
+ MUTEX.synchronize do
31
+ @entries ||= []
32
+ @entries << entry
33
+ @entries.shift while @entries.length > MAX_ENTRIES
34
+ end
35
+ entry
36
+ end
37
+
38
+ # Newest first — the one you just triggered is the one you want.
39
+ def entries
40
+ MUTEX.synchronize { (@entries || []).reverse }
41
+ end
42
+
43
+ def clear!
44
+ MUTEX.synchronize { @entries = [] }
45
+ nil
46
+ end
47
+
48
+ private
49
+
50
+ def build(exception, payload, workspace_root)
51
+ @seq = (@seq || 0) + 1
52
+ {
53
+ type: "exception",
54
+ id: @seq,
55
+ at: Time.now.utc.iso8601,
56
+ klass: exception.class.name,
57
+ message: exception.message.to_s[0, 2000],
58
+ controller: payload[:controller],
59
+ action: payload[:action],
60
+ path: payload[:path],
61
+ frames: workspace_frames(exception, workspace_root)
62
+ }
63
+ end
64
+
65
+ # Only frames inside the workspace, with the root stripped. Absolute host
66
+ # paths are both a leak and useless to an editor that addresses files
67
+ # workspace-relative.
68
+ def workspace_frames(exception, workspace_root)
69
+ root = workspace_root.to_s
70
+ return [] if root.empty?
71
+
72
+ prefix = root.end_with?("/") ? root : "#{root}/"
73
+ Array(exception.backtrace).filter_map do |frame|
74
+ next unless frame.start_with?(prefix)
75
+
76
+ file, line = frame.delete_prefix(prefix).split(":", 3)
77
+ next if file.nil? || file.empty?
78
+
79
+ { file: file, line: line.to_i }
80
+ end.first(MAX_FRAMES)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -17,6 +17,7 @@ module Mbeditor
17
17
  post 'file', to: 'editors#save'
18
18
  post 'create_file', to: 'editors#create_file'
19
19
  post 'create_dir', to: 'editors#create_dir'
20
+ post 'import', to: 'editors#import'
20
21
  patch 'rename', to: 'editors#rename'
21
22
  delete 'delete', to: 'editors#destroy_path'
22
23
  get 'state', to: 'editors#state'
@@ -32,7 +33,12 @@ module Mbeditor
32
33
  get 'js_definition', to: 'editors#js_definition'
33
34
  get 'js_members', to: 'editors#js_members'
34
35
  get 'js_globals', to: 'editors#js_globals'
36
+ get 'js_program', to: 'editors#js_program'
35
37
  post 'ruby_lsp', to: 'editors#ruby_lsp'
38
+ post 'ruby_rename', to: 'editors#ruby_rename'
39
+ get 'model_graph', to: 'editors#model_graph'
40
+ get 'exceptions', to: 'editors#exceptions'
41
+ delete 'exceptions', to: 'editors#clear_exceptions'
36
42
  get 'module_members', to: 'editors#module_members'
37
43
  get 'file_includes', to: 'editors#file_includes'
38
44
  get 'client_config', to: 'editors#client_config'
@@ -76,6 +76,7 @@ module Mbeditor
76
76
  @next_id = 0
77
77
  @state = :stopped # :stopped | :ready | :crashed | :failed
78
78
  @crash_times = []
79
+ @last_error = nil # last start failure, surfaced to the editor's status chip
79
80
  end
80
81
 
81
82
  attr_reader :state
@@ -85,6 +86,28 @@ module Mbeditor
85
86
  @state == :ready
86
87
  end
87
88
 
89
+ # A snapshot for the editor's status indicator. Deliberately does not start
90
+ # the process — asking "how are you?" must not be what boots the server.
91
+ def health
92
+ @state_mutex.synchronize do
93
+ { state: @state, restarts: @crash_times.length, error: @last_error }
94
+ end
95
+ end
96
+
97
+ # Clears the crash budget so a client latched at :failed can be revived
98
+ # without restarting the whole Rails process. Clearing @crash_times is the
99
+ # load-bearing part: restart_allowed? re-latches :failed immediately if the
100
+ # window still holds MAX_RESTARTS entries.
101
+ def reset!
102
+ stop
103
+ @state_mutex.synchronize do
104
+ @crash_times.clear
105
+ @last_error = nil
106
+ @state = :stopped
107
+ end
108
+ ready?
109
+ end
110
+
88
111
  # Syncs the document (didOpen / full-text didChange) and issues a request
89
112
  # against it under one mutex, so concurrent Puma threads can't interleave
90
113
  # a positional request with a stale document.
@@ -215,6 +238,7 @@ module Mbeditor
215
238
  @state = :ready
216
239
  rescue StandardError => e
217
240
  Rails.logger.warn("[mbeditor] ruby-lsp start failed: #{e.class}: #{e.message}") if defined?(Rails)
241
+ @last_error = "#{e.class}: #{e.message}"
218
242
  record_crash
219
243
  cleanup_process
220
244
  @state = @crash_times.length >= MAX_RESTARTS ? :failed : :crashed
@@ -310,10 +334,13 @@ module Mbeditor
310
334
  def start_monitor_thread
311
335
  wait_thr = @wait_thr
312
336
  @monitor_thread = Thread.new do
313
- wait_thr.value # blocks until process exit
337
+ status = wait_thr.value # blocks until process exit
314
338
  @state_mutex.synchronize do
315
339
  next if @stopping || @wait_thr != wait_thr
316
340
 
341
+ # A crash mid-session leaves no exception to quote, so the exit
342
+ # status is the only reason the status chip can show.
343
+ @last_error = "ruby-lsp exited (#{status.exitstatus || status})"
317
344
  record_crash
318
345
  cleanup_process
319
346
  @state = @crash_times.length >= MAX_RESTARTS ? :failed : :crashed
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
- VERSION = "0.10.1"
4
+ VERSION = "0.12.0"
5
5
  end
data/lib/mbeditor.rb CHANGED
@@ -7,6 +7,7 @@ require "mbeditor/route_map"
7
7
  require "mbeditor/private_routes"
8
8
  require "mbeditor/editor_bootstrap"
9
9
  require "mbeditor/ruby_lsp_client"
10
+ require "mbeditor/exception_log"
10
11
  require "mbeditor/engine"
11
12
 
12
13
  module Mbeditor