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
@@ -12,13 +12,6 @@ module Mbeditor
12
12
  module RouteService
13
13
  module_function
14
14
 
15
- # Actions with no route are worth calling out, but only for controllers Rails
16
- # would actually dispatch to. These are the inherited ones every controller
17
- # has and nobody routes.
18
- NON_ACTION_METHODS = %w[
19
- new inspect method_of to_s hash class dup freeze
20
- ].freeze
21
-
22
15
  # "app/controllers/admin/users_controller.rb" -> "admin/users", which is the
23
16
  # key Rails stores in a route's defaults.
24
17
  def controller_key(relative_path)
@@ -29,18 +22,62 @@ module Mbeditor
29
22
  match[1]
30
23
  end
31
24
 
25
+ MUTEX = Mutex.new
26
+ private_constant :MUTEX
27
+
28
+ # Matches the other read-through caches in this engine (FileTreeService 15s,
29
+ # GitInfoService 10s). A write invalidates it outright, so the TTL only ever
30
+ # bounds staleness from a route change made outside the editor.
31
+ CACHE_TTL = 10
32
+
32
33
  # => { "show" => [{ verb:, path:, name: }], ... }
34
+ #
35
+ # Cached because every call walks the host app's ENTIRE route set, and the
36
+ # caller is the inline route hints — which re-request on every activation of
37
+ # a controller tab and on every external content change. Switching between
38
+ # two controllers repeatedly was therefore a full O(routes) scan per switch,
39
+ # and a large app has thousands of routes. The scan itself is unchanged;
40
+ # it just stops happening once per glance.
33
41
  def for_controller(key)
34
42
  return {} if key.nil? || key.empty?
35
43
  return {} unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
36
44
 
37
- routes_for(key)
45
+ cached = cached_routes(key)
46
+ return cached if cached
47
+
48
+ # Built outside the mutex: it runs arbitrary host-app route code, and
49
+ # holding the lock across it would serialise every other controller's
50
+ # lookup behind the slowest one.
51
+ computed = routes_for(key)
52
+ store_routes(key, computed)
53
+ computed
38
54
  rescue StandardError
39
55
  # A broken route set must not take the editor down with it — the file still
40
56
  # opens, just without hints.
41
57
  {}
42
58
  end
43
59
 
60
+ def invalidate
61
+ MUTEX.synchronize { @cache = {} }
62
+ nil
63
+ end
64
+
65
+ def cached_routes(key)
66
+ MUTEX.synchronize do
67
+ entry = (@cache ||= {})[key]
68
+ return entry[:data] if entry && (Process.clock_gettime(Process::CLOCK_MONOTONIC) - entry[:ts]) < CACHE_TTL
69
+ end
70
+ nil
71
+ end
72
+ private_class_method :cached_routes
73
+
74
+ def store_routes(key, data)
75
+ MUTEX.synchronize do
76
+ (@cache ||= {})[key] = { ts: Process.clock_gettime(Process::CLOCK_MONOTONIC), data: data }
77
+ end
78
+ end
79
+ private_class_method :store_routes
80
+
44
81
  def routes_for(key)
45
82
  out = {}
46
83
  Rails.application.routes.routes.each do |route|
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tmpdir"
5
+
6
+ module Mbeditor
7
+ # Whole-workspace `rubocop` run — the counterpart to the per-buffer /lint
8
+ # endpoint, which can only report on files Monaco already has open.
9
+ #
10
+ # `-a` (safe autocorrect) rather than `-A`: unsafe corrections can change
11
+ # behaviour, and this runs over every file in the project at once, so an
12
+ # unreviewed `-A` is a much bigger blast radius than the per-buffer quick fix.
13
+ module RubocopRunService
14
+ module_function
15
+
16
+ def run(workspace_root, mode: :check, timeout: 300)
17
+ matcher = ExclusionMatcher.new(Mbeditor.configuration.excluded_paths, root: workspace_root)
18
+ cmd = AvailabilityProbe.rubocop_command(workspace_root) +
19
+ [AvailabilityProbe.rubocop_server_flag(workspace_root), "--format", "json", "--no-color"]
20
+ cmd << "-a" if mode.to_sym == :autocorrect
21
+
22
+ result = ProcessRunner.call(
23
+ cmd,
24
+ timeout: timeout,
25
+ chdir: workspace_root.to_s,
26
+ env: { "RUBOCOP_CACHE_ROOT" => File.join(Dir.tmpdir, "rubocop") }
27
+ )
28
+ parse(result[:stdout], result[:stderr], matcher)
29
+ rescue ProcessRunner::TimeoutError
30
+ error("RuboCop timed out after #{timeout}s")
31
+ rescue StandardError => e
32
+ error(e.message)
33
+ end
34
+
35
+ # RuboCop writes its JSON to stdout, but anything the host app prints while
36
+ # loading (a deprecation warning, a bundler notice) lands in front of it —
37
+ # hence the seek to the first brace, same as the /lint path.
38
+ # +matcher+ drops offenses in paths the workspace excludes — vendored gems
39
+ # above all. RuboCop's own defaults exclude `vendor/**/*`, but a host
40
+ # .rubocop.yml that sets AllCops/Exclude without `inherit_mode: merge`
41
+ # silently replaces them, and the panel then fills with gem source.
42
+ #
43
+ # ponytail: filtered after the fact, so a host in that state still pays for
44
+ # the walk. Generate a config that inherits from theirs and re-adds the
45
+ # excludes if the run time ever becomes the complaint.
46
+ def parse(stdout, stderr = nil, matcher = nil)
47
+ idx = stdout.index("{")
48
+ return error(stderr.to_s.strip.split("\n").last || "RuboCop produced no output") unless idx
49
+
50
+ data = JSON.parse(stdout[idx..])
51
+ files = (data["files"] || []).filter_map do |file|
52
+ next if matcher&.excluded?(file["path"].to_s)
53
+
54
+ offenses = file["offenses"] || []
55
+ next if offenses.empty?
56
+
57
+ { path: file["path"], offenses: offenses.map { |o| offense(o) } }
58
+ end
59
+
60
+ {
61
+ ok: true,
62
+ files: files.sort_by { |f| f[:path].to_s },
63
+ summary: data["summary"] || {},
64
+ correctable: files.sum { |f| f[:offenses].count { |o| o[:correctable] } }
65
+ }
66
+ rescue JSON::ParserError => e
67
+ error(e.message)
68
+ end
69
+
70
+ def offense(o)
71
+ {
72
+ copName: o["cop_name"],
73
+ message: o["message"],
74
+ severity: o["severity"],
75
+ correctable: o["correctable"] == true,
76
+ corrected: o["corrected"] == true,
77
+ line: o.dig("location", "start_line") || o.dig("location", "line") || 1,
78
+ column: o.dig("location", "start_column") || o.dig("location", "column") || 1
79
+ }
80
+ end
81
+
82
+ def error(message)
83
+ { ok: false, error: message, files: [], summary: {}, correctable: 0 }
84
+ end
85
+ end
86
+ end
@@ -34,6 +34,8 @@ module Mbeditor
34
34
  # Upper bound on cached files. Entries carry full file contents, so this
35
35
  # caps both the on-disk JSON and the resident hash.
36
36
  MAX_CACHE_ENTRIES = 2_000
37
+ # Minimum gap between disk writes of that cache (seconds).
38
+ PERSIST_INTERVAL = 30
37
39
 
38
40
  # In-process file-index cache.
39
41
  # Structure: { absolute_path => {
@@ -63,6 +65,7 @@ module Mbeditor
63
65
  def clear_cache!
64
66
  @mutex.synchronize { @file_cache.clear; @cache_loaded = false }
65
67
  @last_evict_at = nil
68
+ @last_persist_at = nil
66
69
  path = @cache_path.to_s
67
70
  File.delete(path) if !path.empty? && File.exist?(path)
68
71
  rescue StandardError
@@ -154,10 +157,18 @@ module Mbeditor
154
157
  end
155
158
 
156
159
  # Atomically write the in-memory cache to disk (tmp-file + rename).
160
+ # Debounced: the snapshot carries every cached file's full source, so a
161
+ # lookup that parsed one new file would otherwise rewrite tens of MB of
162
+ # JSON. Skipping a write only delays it — the entries stay in memory and
163
+ # a later lookup flushes them.
157
164
  def persist_cache
158
165
  path = @cache_path.to_s
159
166
  return if path.empty?
160
167
 
168
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
169
+ return if @last_persist_at && (now - @last_persist_at) < PERSIST_INTERVAL
170
+
171
+ @last_persist_at = now
161
172
  snapshot = @mutex.synchronize do
162
173
  # Each entry holds the file's full source lines, so an uncapped cache
163
174
  # grows to tens of MB on a large workspace and is rewritten in full on
@@ -263,7 +274,10 @@ module Mbeditor
263
274
  signature: (cached[:lines][def_line - 1] || "").strip,
264
275
  comments: extract_comments(cached[:lines], def_line)
265
276
  }
266
- return results if results.length >= MAX_RESULTS
277
+ if results.length >= MAX_RESULTS
278
+ persist_cache if @new_entries
279
+ return results
280
+ end
267
281
  end
268
282
  rescue StandardError
269
283
  # Malformed file or unreadable; skip silently
@@ -284,14 +298,19 @@ module Mbeditor
284
298
 
285
299
  def evict_deleted_cache_entries
286
300
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
287
- stale_keys = @shared_mutex.synchronize do
301
+ keys = @shared_mutex.synchronize do
288
302
  last = self.class.instance_variable_get(:@last_evict_at)
289
303
  next nil if last && (now - last) < EVICT_INTERVAL
290
304
 
291
305
  self.class.instance_variable_set(:@last_evict_at, now)
292
- @shared_cache.keys.select { |p| !File.exist?(p) }
306
+ @shared_cache.keys
293
307
  end
294
- return if stale_keys.nil? || stale_keys.empty?
308
+ return if keys.nil?
309
+
310
+ # stat()ing outside the lock: up to MAX_CACHE_ENTRIES syscalls, and every
311
+ # other cache reader would otherwise wait on them.
312
+ stale_keys = keys.reject { |p| File.exist?(p) }
313
+ return if stale_keys.empty?
295
314
 
296
315
  @shared_mutex.synchronize { stale_keys.each { |k| @shared_cache.delete(k) } }
297
316
  @new_entries = true
@@ -235,10 +235,11 @@ module Mbeditor
235
235
  def parse_sql_indexes(content, table_name)
236
236
  indexes = []
237
237
 
238
- # Match CREATE INDEX ... ON table_name (columns)
239
- pattern = /CREATE\s+(?:UNIQUE\s+)?INDEX\s+["`]?(\w+)["`]?\s+ON\s+(?:public\.)?["`]?#{Regexp.escape(table_name)}["`]?\s*\((.*?)\)/mi
238
+ # Match CREATE INDEX ... ON table_name (columns). UNIQUE is captured here
239
+ # rather than re-scanned per index with a freshly compiled regex.
240
+ pattern = /CREATE\s+(UNIQUE\s+)?INDEX\s+["`]?(\w+)["`]?\s+ON\s+(?:public\.)?["`]?#{Regexp.escape(table_name)}["`]?\s*\((.*?)\)/mi
240
241
 
241
- content.scan(pattern) do |index_name, columns_str|
242
+ content.scan(pattern) do |unique, index_name, columns_str|
242
243
  cols = columns_str.split(',').map { |c| c.strip.gsub(/["`]/, '').split(/\s+/).first }.compact
243
244
  next if cols.empty?
244
245
 
@@ -246,7 +247,7 @@ module Mbeditor
246
247
  name: index_name,
247
248
  columns: cols
248
249
  }
249
- idx[:unique] = true if content.match?(/CREATE\s+UNIQUE\s+INDEX\s+["`]?#{Regexp.escape(index_name)}/mi)
250
+ idx[:unique] = true if unique
250
251
 
251
252
  indexes << idx
252
253
  end
@@ -255,67 +256,67 @@ module Mbeditor
255
256
  end
256
257
 
257
258
  # Map SQL types to Rails column types
258
- def sql_type_to_rails(sql_type)
259
- type_map = {
260
- 'integer' => 'integer',
261
- 'int' => 'integer',
262
- 'int4' => 'integer',
263
- 'int2' => 'integer',
264
- 'int8' => 'bigint',
265
- 'bigint' => 'bigint',
266
- 'smallint' => 'integer',
267
- 'bigserial' => 'bigint',
268
- 'serial' => 'integer',
269
- 'varchar' => 'string',
270
- 'character varying' => 'string',
271
- 'character' => 'string',
272
- 'char' => 'string',
273
- 'text' => 'text',
274
- 'citext' => 'string',
275
- 'boolean' => 'boolean',
276
- 'bool' => 'boolean',
277
- 'decimal' => 'decimal',
278
- 'numeric' => 'decimal',
279
- 'real' => 'float',
280
- 'float' => 'float',
281
- 'float4' => 'float',
282
- 'float8' => 'float',
283
- 'double precision' => 'float',
284
- 'double' => 'float',
285
- 'money' => 'decimal',
286
- 'timestamp' => 'datetime',
287
- 'timestamp without time zone' => 'datetime',
288
- 'timestamp with time zone' => 'datetime',
289
- 'timestamptz' => 'datetime',
290
- 'datetime' => 'datetime',
291
- 'date' => 'date',
292
- 'time' => 'time',
293
- 'time without time zone' => 'time',
294
- 'time with time zone' => 'time',
295
- 'interval' => 'string',
296
- 'json' => 'json',
297
- 'jsonb' => 'jsonb',
298
- 'uuid' => 'uuid',
299
- 'bytea' => 'binary',
300
- 'bit' => 'string',
301
- 'bit varying' => 'string',
302
- 'inet' => 'string',
303
- 'cidr' => 'string',
304
- 'macaddr' => 'string',
305
- 'xml' => 'string',
306
- 'hstore' => 'hstore',
307
- 'tsvector' => 'string',
308
- 'ltree' => 'string',
309
- 'point' => 'string',
310
- 'line' => 'string',
311
- 'lseg' => 'string',
312
- 'box' => 'string',
313
- 'path' => 'string',
314
- 'polygon' => 'string',
315
- 'circle' => 'string'
316
- }
259
+ SQL_TYPE_TO_RAILS = {
260
+ 'integer' => 'integer',
261
+ 'int' => 'integer',
262
+ 'int4' => 'integer',
263
+ 'int2' => 'integer',
264
+ 'int8' => 'bigint',
265
+ 'bigint' => 'bigint',
266
+ 'smallint' => 'integer',
267
+ 'bigserial' => 'bigint',
268
+ 'serial' => 'integer',
269
+ 'varchar' => 'string',
270
+ 'character varying' => 'string',
271
+ 'character' => 'string',
272
+ 'char' => 'string',
273
+ 'text' => 'text',
274
+ 'citext' => 'string',
275
+ 'boolean' => 'boolean',
276
+ 'bool' => 'boolean',
277
+ 'decimal' => 'decimal',
278
+ 'numeric' => 'decimal',
279
+ 'real' => 'float',
280
+ 'float' => 'float',
281
+ 'float4' => 'float',
282
+ 'float8' => 'float',
283
+ 'double precision' => 'float',
284
+ 'double' => 'float',
285
+ 'money' => 'decimal',
286
+ 'timestamp' => 'datetime',
287
+ 'timestamp without time zone' => 'datetime',
288
+ 'timestamp with time zone' => 'datetime',
289
+ 'timestamptz' => 'datetime',
290
+ 'datetime' => 'datetime',
291
+ 'date' => 'date',
292
+ 'time' => 'time',
293
+ 'time without time zone' => 'time',
294
+ 'time with time zone' => 'time',
295
+ 'interval' => 'string',
296
+ 'json' => 'json',
297
+ 'jsonb' => 'jsonb',
298
+ 'uuid' => 'uuid',
299
+ 'bytea' => 'binary',
300
+ 'bit' => 'string',
301
+ 'bit varying' => 'string',
302
+ 'inet' => 'string',
303
+ 'cidr' => 'string',
304
+ 'macaddr' => 'string',
305
+ 'xml' => 'string',
306
+ 'hstore' => 'hstore',
307
+ 'tsvector' => 'string',
308
+ 'ltree' => 'string',
309
+ 'point' => 'string',
310
+ 'line' => 'string',
311
+ 'lseg' => 'string',
312
+ 'box' => 'string',
313
+ 'path' => 'string',
314
+ 'polygon' => 'string',
315
+ 'circle' => 'string'
316
+ }.freeze
317
317
 
318
- type_map[sql_type.downcase] || sql_type
318
+ def sql_type_to_rails(sql_type)
319
+ SQL_TYPE_TO_RAILS[sql_type.downcase] || sql_type
319
320
  end
320
321
  end
321
322
  end
@@ -23,6 +23,12 @@ module Mbeditor
23
23
  MAX_RESULTS = 10_000
24
24
  RESULT_CACHE_TTL = 30 # seconds; also invalidated on mbeditor file mutations
25
25
  RESULT_CACHE_MAX_ENTRIES = 3
26
+ # A user-supplied regex is matched in-process, and Timeout.timeout cannot
27
+ # interrupt a CRuby regex — only Regexp's own timeout (Ruby >= 3.2) can.
28
+ # Below that the gemspec floor (3.0) leaves a catastrophic pattern
29
+ # unguarded, which is the pre-existing behaviour.
30
+ REGEXP_TIMEOUT = 1 # seconds
31
+ REGEXP_TIMEOUT_SUPPORTED = Regexp.method_defined?(:timeout)
26
32
 
27
33
  STATE_MUTEX = Mutex.new
28
34
  private_constant :STATE_MUTEX
@@ -112,6 +118,10 @@ module Mbeditor
112
118
  else
113
119
  @result_cache = {}
114
120
  end
121
+ # Bumped so a scan already in flight can tell its results are stale
122
+ # and skip filling the cache it just emptied. One counter for every
123
+ # root: an unrelated invalidation only costs a cache miss.
124
+ @cache_generation = (@cache_generation || 0) + 1
115
125
  end
116
126
  end
117
127
 
@@ -153,7 +163,14 @@ module Mbeditor
153
163
  content = File.binread(full_path).force_encoding("UTF-8")
154
164
  .encode("UTF-8", invalid: :replace, undef: :replace)
155
165
  replacements_in_file = content.scan(pattern).length
156
- new_content = content.gsub(pattern, replacement)
166
+ # Block form for a literal search: the two-argument gsub expands
167
+ # \1, \& and \\ in the replacement, which a non-regex replace
168
+ # must insert verbatim.
169
+ new_content = if use_regex
170
+ content.gsub(pattern, replacement)
171
+ else
172
+ content.gsub(pattern) { replacement }
173
+ end
157
174
  if new_content != content
158
175
  File.binwrite(full_path, new_content.encode("UTF-8", invalid: :replace, undef: :replace))
159
176
  files_affected << rel_path
@@ -185,10 +202,12 @@ module Mbeditor
185
202
  key = [workspace_root.to_s, query, use_regex, match_case, whole_word,
186
203
  Array(excluded_paths).map(&:to_s).sort]
187
204
  now = monotonic
188
- STATE_MUTEX.synchronize do
205
+ generation = STATE_MUTEX.synchronize do
189
206
  @result_cache ||= {}
190
207
  entry = @result_cache[key]
191
208
  return entry[:data] if entry && (now - entry[:ts]) < RESULT_CACHE_TTL
209
+
210
+ @cache_generation ||= 0
192
211
  end
193
212
 
194
213
  data = scan(workspace_root, query, use_regex: use_regex, match_case: match_case,
@@ -198,6 +217,10 @@ module Mbeditor
198
217
  # Don't cache superseded scans — their results are truncated by the kill.
199
218
  unless data[:superseded]
200
219
  STATE_MUTEX.synchronize do
220
+ # A save landed while this scan ran: these rows predate it, and
221
+ # storing them now would serve pre-save matches for a full TTL.
222
+ next if (@cache_generation ||= 0) != generation
223
+
201
224
  @result_cache[key] = { ts: monotonic, data: data }
202
225
  @result_cache.delete(@result_cache.keys.first) while @result_cache.length > RESULT_CACHE_MAX_ENTRIES
203
226
  end
@@ -229,11 +252,20 @@ module Mbeditor
229
252
  end
230
253
  end
231
254
 
255
+ # Used to locate the match within each hit line so a result can carry
256
+ # its columns. Invalid user regexes are already reported elsewhere;
257
+ # here a nil pattern just means the rows come back without columns.
258
+ pattern = begin
259
+ build_pattern(query, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
260
+ rescue RegexpError
261
+ nil
262
+ end
263
+
232
264
  begin
233
265
  io.each_line do |raw|
234
266
  break if results.length >= max
235
267
 
236
- row = parse_line(tier, raw, root)
268
+ row = parse_line(tier, raw, root, pattern)
237
269
  next unless row
238
270
  next if matcher.excluded?(row[:file])
239
271
 
@@ -336,7 +368,7 @@ module Mbeditor
336
368
  end
337
369
  end
338
370
 
339
- def parse_line(tier, raw, root)
371
+ def parse_line(tier, raw, root, pattern = nil)
340
372
  if tier == :rg
341
373
  begin
342
374
  data = JSON.parse(raw)
@@ -346,11 +378,23 @@ module Mbeditor
346
378
  return nil unless data["type"] == "match"
347
379
 
348
380
  md = data["data"]
381
+ raw_text = md.dig("lines", "text").to_s
382
+ # rg reports submatch offsets in BYTES; Monaco columns are character
383
+ # based, so slice the prefix and measure it as characters.
384
+ sub = Array(md["submatches"]).first
385
+ cols = if sub && sub["start"] && sub["end"]
386
+ bytes = raw_text.dup.force_encoding(Encoding::BINARY)
387
+ start_chars = bytes[0, sub["start"]].to_s.force_encoding(Encoding::UTF_8).scrub.length
388
+ match_chars = bytes[sub["start"], sub["end"] - sub["start"]].to_s.force_encoding(Encoding::UTF_8).scrub.length
389
+ { col: start_chars + 1, end_col: start_chars + match_chars + 1 }
390
+ else
391
+ match_columns(raw_text, pattern)
392
+ end
349
393
  return {
350
394
  file: relative_path(md.dig("path", "text").to_s, root),
351
395
  line: md.dig("line_number"),
352
- text: md.dig("lines", "text").to_s.strip
353
- }
396
+ text: raw_text.strip
397
+ }.merge(cols)
354
398
  end
355
399
 
356
400
  # git grep / grep emit "path:line:text" — possibly with bytes that are
@@ -367,7 +411,25 @@ module Mbeditor
367
411
  file_path = relative_path(file_path, root)
368
412
  end
369
413
 
370
- { file: file_path, line: Regexp.last_match(2).to_i, text: Regexp.last_match(3).strip }
414
+ raw_text = Regexp.last_match(3)
415
+ { file: file_path, line: Regexp.last_match(2).to_i, text: raw_text.strip }
416
+ .merge(match_columns(raw_text, pattern))
417
+ end
418
+
419
+ # 1-based Monaco columns for the first match on a hit line. Returns an
420
+ # empty hash when there is no usable pattern or it doesn't match — the
421
+ # row is still a valid result, it just opens at the start of the line.
422
+ # Measured against the RAW line, never the stripped `text`: the client
423
+ # cannot recover the leading whitespace the strip removed.
424
+ def match_columns(raw_text, pattern)
425
+ return {} unless pattern
426
+
427
+ m = pattern.match(raw_text)
428
+ return {} unless m
429
+
430
+ { col: m.begin(0) + 1, end_col: m.end(0) + 1 }
431
+ rescue StandardError
432
+ {}
371
433
  end
372
434
 
373
435
  def register_search(root, pid)
@@ -407,11 +469,14 @@ module Mbeditor
407
469
 
408
470
  def build_pattern(query, use_regex:, match_case:, whole_word:)
409
471
  flags = match_case ? 0 : Regexp::IGNORECASE
410
- if use_regex
411
- Regexp.new(whole_word ? "\\b(?:#{query})\\b" : query, flags)
472
+ source = if use_regex
473
+ whole_word ? "\\b(?:#{query})\\b" : query
412
474
  else
413
- Regexp.new(whole_word ? "\\b#{Regexp.escape(query)}\\b" : Regexp.escape(query), flags)
475
+ whole_word ? "\\b#{Regexp.escape(query)}\\b" : Regexp.escape(query)
414
476
  end
477
+ return Regexp.new(source, flags, timeout: REGEXP_TIMEOUT) if REGEXP_TIMEOUT_SUPPORTED
478
+
479
+ Regexp.new(source, flags)
415
480
  end
416
481
 
417
482
  def relative_path(absolute_path, workspace_root)