mbeditor 0.8.1 → 0.10.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +85 -0
  3. data/README.md +31 -0
  4. data/app/assets/javascripts/mbeditor/application.js +3 -0
  5. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +281 -60
  6. data/app/assets/javascripts/mbeditor/components/LogPanel.js +50 -1
  7. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +91 -16
  8. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +217 -0
  9. data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +34 -3
  10. data/app/assets/javascripts/mbeditor/editor_plugins.js +112 -52
  11. data/app/assets/javascripts/mbeditor/git_service.js +8 -0
  12. data/app/assets/javascripts/mbeditor/js_outline.js +110 -0
  13. data/app/assets/javascripts/mbeditor/ruby_outline.js +427 -0
  14. data/app/assets/stylesheets/mbeditor/editor.css +224 -5
  15. data/app/assets/stylesheets/mbeditor/themes.css +35 -0
  16. data/app/controllers/mbeditor/application_controller.rb +5 -11
  17. data/app/controllers/mbeditor/editors_controller.rb +34 -5
  18. data/app/controllers/mbeditor/git_controller.rb +11 -0
  19. data/app/services/mbeditor/exclusion_matcher.rb +105 -3
  20. data/app/services/mbeditor/file_tree_service.rb +1 -1
  21. data/app/services/mbeditor/git_line_diff_service.rb +99 -0
  22. data/app/services/mbeditor/git_service.rb +3 -10
  23. data/app/services/mbeditor/ruby_definition_service.rb +1 -1
  24. data/app/services/mbeditor/safe_path.rb +57 -0
  25. data/app/services/mbeditor/search_replace_service.rb +2 -2
  26. data/lib/mbeditor/configuration.rb +2 -1
  27. data/lib/mbeditor/engine.rb +3 -0
  28. data/lib/mbeditor/file_watcher.rb +136 -0
  29. data/lib/mbeditor/route_map.rb +1 -0
  30. data/lib/mbeditor/version.rb +1 -1
  31. metadata +8 -2
@@ -355,6 +355,41 @@
355
355
  * or exceed that specificity to win the cascade. :root[data-theme] equals
356
356
  * (0,2,0) and loads AFTER pico.classless.css, so it wins on cascade order.
357
357
  * ─────────────────────────────────────────────────────────────────────────── */
358
+ /* Rails log drawer palette.
359
+ *
360
+ * Deliberately its own set rather than the --ide-* semantic vars: those only
361
+ * carry four hues, and several themes alias them (Dracula's --ide-accent-fg
362
+ * and --ide-success are the same green), which would render "Started GET" and
363
+ * "Completed 200" identically. A log needs its categories to separate from
364
+ * each other more than it needs to match the editor chrome — the same reason
365
+ * terminals keep a fixed palette. Light themes get darkened variants so the
366
+ * text stays legible on white. */
367
+ :root[data-theme],
368
+ :root,
369
+ [data-theme] {
370
+ --ide-log-request: #d7dae0;
371
+ --ide-log-controller: #c792ea;
372
+ --ide-log-sql: #56b6c2;
373
+ --ide-log-render: #82aaff;
374
+ --ide-log-success: #7ec699;
375
+ --ide-log-warn: #e5c07b;
376
+ --ide-log-error: #ef6b73;
377
+ --ide-log-muted: #7f8596;
378
+ }
379
+
380
+ :root[data-theme="vs"],
381
+ :root[data-theme="hc-light"],
382
+ :root[data-theme="github-light"] {
383
+ --ide-log-request: #1f2328;
384
+ --ide-log-controller: #6f42c1;
385
+ --ide-log-sql: #0b7285;
386
+ --ide-log-render: #0550ae;
387
+ --ide-log-success: #116329;
388
+ --ide-log-warn: #8a6100;
389
+ --ide-log-error: #b42318;
390
+ --ide-log-muted: #656d76;
391
+ }
392
+
358
393
  :root[data-theme],
359
394
  :root,
360
395
  [data-theme] {
@@ -32,23 +32,17 @@ module Mbeditor
32
32
  end
33
33
 
34
34
  # Expand path and confirm it's inside workspace_root.
35
- # Resolves symlinks on the nearest existing ancestor of the target so that
36
- # a symlink inside the workspace cannot escape the sandbox regardless of
37
- # whether the target itself exists yet (create / rename paths).
35
+ # SafePath follows every symlink on the way including dangling ones, which
36
+ # an existence walk would skip past so a symlink inside the workspace
37
+ # cannot escape the sandbox regardless of whether the target exists yet
38
+ # (create / rename paths).
38
39
  def resolve_path(raw)
39
40
  return nil if raw.blank?
40
41
 
41
42
  root = workspace_root.to_s
42
43
  full = File.expand_path(raw.to_s, root)
43
44
  return nil unless full.start_with?("#{root}/") || full == root
44
-
45
- # Walk up to the nearest existing ancestor (could be the path itself,
46
- # its parent directory, or ultimately the workspace root).
47
- check = full
48
- check = File.dirname(check) until File.exist?(check)
49
- real_root = File.realpath(root)
50
- real = File.realpath(check)
51
- return nil unless real.start_with?("#{real_root}/") || real == real_root
45
+ return nil unless SafePath.within?(root, full)
52
46
 
53
47
  full
54
48
  rescue Errno::EACCES
@@ -1098,7 +1098,7 @@ module Mbeditor
1098
1098
  rel = relative_path(full_path)
1099
1099
  return true if rel.blank?
1100
1100
 
1101
- ExclusionMatcher.new(Mbeditor.configuration.excluded_paths).excluded?(rel)
1101
+ ExclusionMatcher.new(Mbeditor.configuration.excluded_paths, root: workspace_root).excluded?(rel)
1102
1102
  end
1103
1103
 
1104
1104
  def ruby_def_include_dirs
@@ -1144,10 +1144,39 @@ module Mbeditor
1144
1144
  contents = result.is_a?(Hash) ? result["contents"] : nil
1145
1145
  return nil if contents.nil?
1146
1146
 
1147
- case contents
1148
- when Hash then contents["value"].to_s
1149
- when Array then contents.map { |c| c.is_a?(Hash) ? c["value"].to_s : c.to_s }.join("\n\n")
1150
- else contents.to_s
1147
+ markdown =
1148
+ case contents
1149
+ when Hash then contents["value"].to_s
1150
+ when Array then contents.map { |c| c.is_a?(Hash) ? c["value"].to_s : c.to_s }.join("\n\n")
1151
+ else contents.to_s
1152
+ end
1153
+
1154
+ rewrite_lsp_hover_links(markdown)
1155
+ end
1156
+
1157
+ # ruby-lsp renders its "Definitions" line as VS Code file links, e.g.
1158
+ # `[user.rb](file:///abs/path/user.rb#L3,1-9,4)`. Monaco renders those as
1159
+ # links but clicking one does nothing, since nothing can open a file:// URI
1160
+ # here. Point in-workspace links at the `mbeditor.openDefinition` Monaco
1161
+ # command (registered in editor_plugins.js) and demote gem/stdlib links —
1162
+ # which the editor cannot open at all — to plain code spans.
1163
+ LSP_HOVER_FILE_LINK = %r{\[([^\]\n]+)\]\(file://([^)\s#]+)(?:\#L(\d+),\d+(?:-\d+,\d+)?)?\)}
1164
+
1165
+ def rewrite_lsp_hover_links(markdown)
1166
+ prefix = "#{workspace_root}/"
1167
+ markdown.gsub(LSP_HOVER_FILE_LINK) do
1168
+ label, raw_path, line = Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3).to_i
1169
+ # Percent-decode by hand: URI's unescape helpers are deprecated on new
1170
+ # Rubies and their replacements are missing on the old ones we support.
1171
+ # (Must come after reading the other captures — gsub resets last_match.)
1172
+ path = raw_path.gsub(/%\h\h/) { |esc| esc[1..].hex.chr }.force_encoding(Encoding::UTF_8)
1173
+
1174
+ if path.start_with?(prefix)
1175
+ args = [path.delete_prefix(prefix), line.positive? ? line : 1]
1176
+ "[#{label}](command:mbeditor.openDefinition?#{ERB::Util.url_encode(args.to_json)})"
1177
+ else
1178
+ "`#{label}`"
1179
+ end
1151
1180
  end
1152
1181
  end
1153
1182
 
@@ -8,6 +8,7 @@ module Mbeditor
8
8
  # ---------
9
9
  # GET /mbeditor/git/diff ?file=<path>[&base=<sha>&head=<sha>]
10
10
  # GET /mbeditor/git/blame ?file=<path>
11
+ # GET /mbeditor/git/line_diff ?file=<path>
11
12
  # GET /mbeditor/git/file_history ?file=<path>
12
13
  # GET /mbeditor/git/commit_graph
13
14
  # GET /mbeditor/redmine/issue/:id
@@ -55,6 +56,16 @@ module Mbeditor
55
56
  render json: { error: e.message }, status: :unprocessable_content
56
57
  end
57
58
 
59
+ # GET /mbeditor/git/line_diff?file=<path>
60
+ def line_diff
61
+ file = require_file_param
62
+ return unless file
63
+
64
+ render json: GitLineDiffService.new(repo_path: workspace_root, file_path: file).call
65
+ rescue StandardError => e
66
+ render json: { error: e.message }, status: :unprocessable_content
67
+ end
68
+
58
69
  # GET /mbeditor/git/file_history?file=<path>
59
70
  def file_history
60
71
  file = require_file_param
@@ -1,13 +1,98 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
+ # Decides whether a workspace-relative path falls under a configured
5
+ # exclusion (`Mbeditor.configuration.excluded_paths`).
6
+ #
7
+ # Comparison is normalized rather than byte-for-byte, because the filesystem
8
+ # the workspace lives on may resolve two different spellings of a name to the
9
+ # same file:
10
+ #
11
+ # * Case. APFS/HFS+ (and NTFS) resolve "TMP/cache.txt" to the excluded
12
+ # "tmp/", and ".GIT/hooks/post-checkout" to the real ".git/hooks/" — the
13
+ # latter is arbitrary code execution on the next git operation. Folding is
14
+ # applied only when the filesystem backing the workspace really is
15
+ # case-insensitive, probed at runtime: a case-sensitive volume can be
16
+ # mounted on macOS, and folding there would hide legitimately distinct
17
+ # paths.
18
+ #
19
+ # * Unicode normalization. APFS stores one file for the NFC and NFD
20
+ # spellings of a name, so an NFD request path slips past an NFC pattern.
21
+ # NFC folding is applied unconditionally: filesystems that do keep the two
22
+ # spellings apart are rare enough, and the cost there is over-exclusion
23
+ # (hiding a path) rather than the write-through this class exists to
24
+ # prevent.
4
25
  class ExclusionMatcher
5
- def initialize(patterns)
6
- @patterns = patterns.map(&:to_s).reject(&:empty?)
26
+ MUTEX = Mutex.new
27
+ private_constant :MUTEX
28
+
29
+ # Roots are stable for the life of a process; the bound only keeps the test
30
+ # suite (a fresh tmpdir workspace per test) from growing the memo forever.
31
+ MAX_PROBE_CACHE = 256
32
+ private_constant :MAX_PROBE_CACHE
33
+
34
+ class << self
35
+ # Whether the filesystem backing +root+ resolves paths case-insensitively.
36
+ # Probed once per root, then memoized for the life of the process.
37
+ def case_insensitive_filesystem?(root)
38
+ key = File.expand_path(root.to_s)
39
+
40
+ MUTEX.synchronize do
41
+ @probe_cache ||= {}
42
+ return @probe_cache[key] if @probe_cache.key?(key)
43
+
44
+ @probe_cache = {} if @probe_cache.size >= MAX_PROBE_CACHE
45
+ @probe_cache[key] = probe_case_insensitive(key)
46
+ end
47
+ end
48
+
49
+ def reset_filesystem_probe!
50
+ MUTEX.synchronize { @probe_cache = {} }
51
+ end
52
+
53
+ private
54
+
55
+ # Asks the filesystem directly: look up a real directory under its own
56
+ # name and under that name with the case flipped, and compare device +
57
+ # inode (that is what File.identical? does). They only agree when the
58
+ # filesystem folded the two spellings onto one directory.
59
+ #
60
+ # Climbs to the nearest ancestor whose name actually contains a cased
61
+ # character, since a name like "42" flips to itself and proves nothing.
62
+ # When nothing can be determined — no cased ancestor, a root that does not
63
+ # exist, a permission error — it reports case-insensitive, which
64
+ # over-excludes rather than leaving the hole open.
65
+ def probe_case_insensitive(root)
66
+ return true unless File.directory?(root)
67
+
68
+ path = root
69
+ loop do
70
+ parent = File.dirname(path)
71
+ base = File.basename(path)
72
+ flipped = base.swapcase
73
+
74
+ return File.identical?(path, File.join(parent, flipped)) unless flipped == base
75
+ break if parent == path
76
+
77
+ path = parent
78
+ end
79
+
80
+ true
81
+ rescue SystemCallError
82
+ true
83
+ end
84
+ end
85
+
86
+ # +root+ is the workspace the paths are relative to; it decides only whether
87
+ # case is folded. Defaults to the resolved workspace root.
88
+ def initialize(patterns, root: nil)
89
+ @fold_case = self.class.case_insensitive_filesystem?(root || WorkspaceRootResolver.call)
90
+ @patterns = patterns.map { |pattern| normalize(pattern.to_s) }.reject(&:empty?)
7
91
  end
8
92
 
9
93
  def excluded?(relative_path)
10
- @patterns.any? { |pattern| matches?(pattern, relative_path) }
94
+ rel = normalize(relative_path.to_s)
95
+ @patterns.any? { |pattern| matches?(pattern, rel) }
11
96
  end
12
97
 
13
98
  private
@@ -19,5 +104,22 @@ module Mbeditor
19
104
  File.basename(rel) == pattern || rel.split("/").include?(pattern)
20
105
  end
21
106
  end
107
+
108
+ # "/" is ASCII and survives both steps, so the caller can normalize a whole
109
+ # path in one pass and split it afterwards.
110
+ def normalize(str)
111
+ # ASCII is already NFC, so only the case step can apply.
112
+ return @fold_case ? str.downcase : str if str.ascii_only?
113
+
114
+ utf8 = str.encoding == Encoding::UTF_8 ? str : str.dup.force_encoding(Encoding::UTF_8)
115
+ # Undecodable bytes become U+FFFD. This is a comparison key, never a path
116
+ # we open, and scrubbing keeps a malformed name from skipping the fold
117
+ # (and raising) on its way past an exclusion.
118
+ utf8 = utf8.scrub unless utf8.valid_encoding?
119
+ utf8 = utf8.unicode_normalize(:nfc)
120
+ @fold_case ? utf8.downcase : utf8
121
+ rescue ArgumentError, Encoding::CompatibilityError
122
+ str
123
+ end
22
124
  end
23
125
  end
@@ -15,7 +15,7 @@ module Mbeditor
15
15
  end
16
16
  end
17
17
 
18
- matcher = ExclusionMatcher.new(Mbeditor.configuration.excluded_paths)
18
+ matcher = ExclusionMatcher.new(Mbeditor.configuration.excluded_paths, root: root)
19
19
  data = traverse(root, root, matcher)
20
20
 
21
21
  MUTEX.synchronize do
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Per-line git status for one file, for colouring the editor's line numbers.
5
+ #
6
+ # Wraps `git diff -U0` (working tree vs HEAD) and turns its hunk headers into
7
+ # line ranges in the *current* file:
8
+ #
9
+ # {
10
+ # "added" => [{ "start" => 12, "end" => 14 }, ...],
11
+ # "modified" => [{ "start" => 30, "end" => 30 }, ...],
12
+ # "deleted" => [{ "start" => 47, "end" => 47 }, ...],
13
+ # "tracked" => true
14
+ # }
15
+ #
16
+ # A deletion has no lines left to mark, so it is reported as the single line
17
+ # the removed content sat after — line 0 when the file's opening lines were
18
+ # the ones deleted, which the frontend renders above the first line.
19
+ #
20
+ # An untracked file has no HEAD side at all, so every line counts as added.
21
+ class GitLineDiffService
22
+ include GitService
23
+
24
+ # @@ -<old_start>[,<old_count>] +<new_start>[,<new_count>] @@
25
+ HUNK_HEADER = /\A@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
26
+
27
+ attr_reader :repo_path, :file_path
28
+
29
+ def initialize(repo_path:, file_path:)
30
+ @repo_path = repo_path.to_s
31
+ @file_path = file_path.to_s
32
+ end
33
+
34
+ def call
35
+ return untracked_result if untracked?
36
+
37
+ output, status = GitService.run_git(
38
+ repo_path, "diff", "--no-color", "--no-ext-diff", "-U0", "HEAD", "--", file_path
39
+ )
40
+ # A non-zero status here means the diff could not be produced at all (no
41
+ # HEAD yet, path outside the repo). Report a clean file rather than
42
+ # raising: line colouring is decoration, never a reason to fail a load.
43
+ return empty_result unless status.success?
44
+
45
+ parse(output)
46
+ end
47
+
48
+ private
49
+
50
+ def empty_result
51
+ { "added" => [], "modified" => [], "deleted" => [], "tracked" => true }
52
+ end
53
+
54
+ def untracked_result
55
+ line_count = count_lines
56
+ added = line_count.positive? ? [{ "start" => 1, "end" => line_count }] : []
57
+ { "added" => added, "modified" => [], "deleted" => [], "tracked" => false }
58
+ end
59
+
60
+ def untracked?
61
+ output, status = GitService.run_git(repo_path, "ls-files", "--error-unmatch", "--", file_path)
62
+ !(status.success? && output.present?)
63
+ end
64
+
65
+ def count_lines
66
+ absolute = File.join(repo_path, file_path)
67
+ return 0 unless File.file?(absolute)
68
+
69
+ File.foreach(absolute).count
70
+ rescue SystemCallError
71
+ 0
72
+ end
73
+
74
+ def parse(output)
75
+ result = empty_result
76
+
77
+ output.each_line do |line|
78
+ match = HUNK_HEADER.match(line)
79
+ next unless match
80
+
81
+ old_count = match[2] ? match[2].to_i : 1
82
+ new_start = match[3].to_i
83
+ new_count = match[4] ? match[4].to_i : 1
84
+
85
+ if new_count.zero?
86
+ # Pure deletion. git reports the line the removed block followed, so
87
+ # new_start is already that line (and 0 when it was the file's head).
88
+ result["deleted"] << { "start" => new_start, "end" => new_start }
89
+ elsif old_count.zero?
90
+ result["added"] << { "start" => new_start, "end" => new_start + new_count - 1 }
91
+ else
92
+ result["modified"] << { "start" => new_start, "end" => new_start + new_count - 1 }
93
+ end
94
+ end
95
+
96
+ result
97
+ end
98
+ end
99
+ end
@@ -139,8 +139,8 @@ module Mbeditor
139
139
  # Resolve a file path safely within repo_path. Returns full path string or
140
140
  # nil if the path escapes the root.
141
141
  #
142
- # Resolves symlinks on the nearest existing ancestor of the target so that a
143
- # symlink inside the repo cannot escape it, mirroring
142
+ # Follows symlinks (via SafePath, including dangling ones) so that a symlink
143
+ # inside the repo cannot escape it, mirroring
144
144
  # ApplicationController#resolve_path. When repo_path is not a real directory
145
145
  # (e.g. unit tests with synthetic roots) the symlink check is skipped, since
146
146
  # there is nothing on disk to resolve and repo_path is server-controlled.
@@ -151,14 +151,7 @@ module Mbeditor
151
151
  full = File.expand_path(relative.to_s, root)
152
152
  return nil unless full.start_with?("#{root}/") || full == root
153
153
  return full unless File.directory?(root)
154
-
155
- # Walk up to the nearest existing ancestor, then realpath both it and the
156
- # root and confirm the resolved target is still inside the resolved root.
157
- check = full
158
- check = File.dirname(check) until File.exist?(check)
159
- real_root = File.realpath(root)
160
- real = File.realpath(check)
161
- return nil unless real.start_with?("#{real_root}/") || real == real_root
154
+ return nil unless SafePath.within?(root, full)
162
155
 
163
156
  full
164
157
  rescue Errno::EACCES
@@ -181,7 +181,7 @@ module Mbeditor
181
181
  @symbol = symbol
182
182
  @excluded_paths = Array(excluded_paths)
183
183
  @included_dirs = Array(included_dirs)
184
- @exclusion_matcher = ExclusionMatcher.new(@excluded_paths)
184
+ @exclusion_matcher = ExclusionMatcher.new(@excluded_paths, root: @workspace_root)
185
185
  @shared_cache = self.class.send(:file_cache)
186
186
  @shared_mutex = self.class.send(:mutex)
187
187
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Containment check for paths that may not exist yet.
5
+ #
6
+ # +File.realpath+ refuses to resolve a path whose final component is missing,
7
+ # so callers that create files have to reason about the nearest existing
8
+ # ancestor instead. Doing that with +File.exist?+ is unsafe: it *follows*
9
+ # symlinks, so a symlink pointing at a missing target reports false and an
10
+ # existence walk steps straight past it, back to the safely-contained parent.
11
+ # A subsequent +File.open+ follows the link and writes outside the root.
12
+ #
13
+ # SafePath resolves the link chain itself with +File.symlink?+/+File.readlink+
14
+ # (both lstat-based, neither follows) so a dangling symlink is judged by where
15
+ # it points, not by whether that target happens to exist yet.
16
+ module SafePath
17
+ # Guards against symlink cycles, which surface as a path that neither
18
+ # exists nor terminates.
19
+ MAX_SYMLINK_HOPS = 32
20
+
21
+ module_function
22
+
23
+ # True when +path+ resolves to a location inside +root+, following every
24
+ # symlink component including dangling ones. +root+ must exist.
25
+ def within?(root, path)
26
+ real_root = File.realpath(root.to_s)
27
+ target = canonical(path.to_s)
28
+ return false unless target
29
+
30
+ target == real_root || target.start_with?("#{real_root}/")
31
+ rescue SystemCallError
32
+ false
33
+ end
34
+
35
+ # The filesystem location a write to +path+ would actually land on, with
36
+ # all symlinks expanded. Returns nil when the link chain cycles or exceeds
37
+ # MAX_SYMLINK_HOPS.
38
+ def canonical(path, hops = 0)
39
+ return nil if hops > MAX_SYMLINK_HOPS
40
+ # Anything that exists (including a symlink with a live target) resolves
41
+ # normally; realpath expands the whole chain for us.
42
+ return File.realpath(path) if File.exist?(path)
43
+
44
+ parent = File.dirname(path)
45
+ return nil if parent == path # ran off the top without finding anything
46
+
47
+ real_parent = canonical(parent, hops)
48
+ return nil unless real_parent
49
+
50
+ if File.symlink?(path)
51
+ canonical(File.absolute_path(File.readlink(path), real_parent), hops + 1)
52
+ else
53
+ File.join(real_parent, File.basename(path))
54
+ end
55
+ end
56
+ end
57
+ end
@@ -100,7 +100,7 @@ module Mbeditor
100
100
  end
101
101
 
102
102
  pattern = build_pattern(query, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
103
- matcher = ExclusionMatcher.new(excluded_paths)
103
+ matcher = ExclusionMatcher.new(excluded_paths, root: workspace_root)
104
104
  replaced_count = 0
105
105
  files_affected = []
106
106
  errors = []
@@ -182,7 +182,7 @@ module Mbeditor
182
182
  tier = pick_tier(root)
183
183
  env, args = build_command(tier, root, query, use_regex: use_regex, match_case: match_case,
184
184
  whole_word: whole_word, excluded_paths: excluded_paths, paths: paths)
185
- matcher = ExclusionMatcher.new(excluded_paths)
185
+ matcher = ExclusionMatcher.new(excluded_paths, root: root)
186
186
  results = []
187
187
  timed_out = false
188
188
  timeout_secs = Mbeditor.configuration.search_timeout
@@ -11,7 +11,7 @@ module Mbeditor
11
11
  :mount_path, :resilient_routing, :js_global_identifiers,
12
12
  :js_syntax_check, :babel_standalone_path,
13
13
  :ruby_lsp, :ruby_lsp_command, :ruby_lsp_timeout,
14
- :search_respect_gitignore
14
+ :search_respect_gitignore, :watch_files
15
15
 
16
16
  def initialize
17
17
  @allowed_environments = [:development]
@@ -42,6 +42,7 @@ module Mbeditor
42
42
  @ruby_lsp_timeout = 3 # seconds per LSP request before falling back to the built-in services
43
43
  @mount_path = nil # explicit URL prefix override; nil falls through to detection/"/mbeditor"
44
44
  @resilient_routing = true # serve /mbeditor from middleware so the editor survives a broken host routes.rb; false is the escape hatch
45
+ @watch_files = :auto # watch the workspace for changes made outside the editor when the host has the `listen` gem; false disables
45
46
  end
46
47
  end
47
48
  end
@@ -4,6 +4,7 @@ require "mbeditor/rack/silence_ping_request"
4
4
  require "mbeditor/rack/handle_pending_migrations"
5
5
  require "mbeditor/rack/resilient_router"
6
6
  require "mbeditor/cable_log_filter"
7
+ require "mbeditor/file_watcher"
7
8
 
8
9
  module Mbeditor
9
10
  class Engine < ::Rails::Engine
@@ -81,6 +82,8 @@ module Mbeditor
81
82
  raise ArgumentError, "[mbeditor] config.workspace_root is set to '#{cfg.workspace_root}' but that path is not a directory"
82
83
  end
83
84
 
85
+ Mbeditor::FileWatcher.start_if_enabled
86
+
84
87
  if cfg.redmine_enabled
85
88
  require "uri"
86
89
  if cfg.redmine_url.blank?
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Watches the workspace for changes made outside the editor — a terminal
5
+ # `git checkout`, a rebase, another editor, a generator — and broadcasts the
6
+ # same `files_changed` payload the mutation endpoints send. Clients refresh
7
+ # the file tree, git line-number tinting and cached globals from it.
8
+ #
9
+ # The `listen` gem is an optional host dependency. Without it the editor
10
+ # behaves exactly as before: only changes made *through* mbeditor announce
11
+ # themselves. Nothing warns loudly about the missing gem — it is opt-in.
12
+ #
13
+ # Only one watcher runs per process. It is deliberately not started in test
14
+ # or in non-server processes (rake, console, the Rails runner), where a
15
+ # background listener thread is pure overhead.
16
+ module FileWatcher
17
+ # Coalesce bursts: a branch switch touches hundreds of files, and each one
18
+ # would otherwise be its own broadcast.
19
+ DEBOUNCE_SECONDS = 0.3
20
+
21
+ class << self
22
+ def available?
23
+ return @available if defined?(@available)
24
+
25
+ @available = begin
26
+ require "listen"
27
+ true
28
+ rescue LoadError
29
+ false
30
+ end
31
+ end
32
+
33
+ def running?
34
+ !@listener.nil?
35
+ end
36
+
37
+ # Boot entry point. Confined to the environments the editor is allowed in
38
+ # and to processes that actually serve requests — a rake task or console
39
+ # has no client to broadcast to, and a listener thread there would only
40
+ # burn file handles. MBEDITOR_FORCE_WATCH overrides the process check for
41
+ # unusual servers and for tests.
42
+ def start_if_enabled
43
+ cfg = Mbeditor.configuration
44
+ return false if cfg.watch_files == false
45
+ return false unless cfg.allowed_environments.map(&:to_s).include?(Rails.env.to_s)
46
+ return false unless serving_requests?
47
+
48
+ start(cfg.workspace_root.presence || Rails.root.to_s)
49
+ end
50
+
51
+ # Returns true when a watcher was started, false for every reason not to
52
+ # (gem absent, already running, no workspace, disabled by config).
53
+ def start(root)
54
+ return false unless available?
55
+ return false if running?
56
+
57
+ root = root.to_s
58
+ return false if root.empty? || !File.directory?(root)
59
+
60
+ ignores = ignore_patterns(root)
61
+ @listener = ::Listen.to(root, ignore: ignores, latency: DEBOUNCE_SECONDS) do |modified, added, removed|
62
+ broadcast(root, modified + added + removed)
63
+ end
64
+ @listener.start
65
+ Rails.logger.info("[mbeditor] watching #{root} for external changes")
66
+ true
67
+ rescue StandardError => e
68
+ # A watcher that cannot start must never take the host app down with it:
69
+ # inotify limits on Linux, permission issues, an unreadable root.
70
+ Rails.logger.warn("[mbeditor] file watcher failed to start: #{e.class}: #{e.message}")
71
+ @listener = nil
72
+ false
73
+ end
74
+
75
+ def stop
76
+ @listener&.stop
77
+ rescue StandardError
78
+ nil
79
+ ensure
80
+ @listener = nil
81
+ end
82
+
83
+ private
84
+
85
+ def serving_requests?
86
+ return true if ENV["MBEDITOR_FORCE_WATCH"]
87
+
88
+ defined?(Rails::Server) || defined?(Puma::Server) || defined?(Unicorn) || defined?(Passenger)
89
+ end
90
+
91
+ # `listen` matches ignores against paths relative to the watched root, so
92
+ # the configured exclusions become anchored regexps. Escaping matters:
93
+ # entries like "vendor/bundle" and "public/assets" contain separators, and
94
+ # a stray metacharacter in host config should not build a bogus pattern.
95
+ def ignore_patterns(root)
96
+ Array(Mbeditor.configuration.excluded_paths).map(&:to_s).reject(&:empty?).map do |path|
97
+ %r{\A#{Regexp.escape(path.delete_prefix("/").delete_suffix("/"))}(/|\z)}
98
+ end
99
+ end
100
+
101
+ # Paths arrive absolute. Anything that does not sit under the workspace
102
+ # is dropped rather than sent raw: the client keys everything by
103
+ # workspace-relative path, and an absolute one would leak host layout.
104
+ def relative_paths(root, paths)
105
+ paths.filter_map do |path|
106
+ rel = path.to_s.delete_prefix("#{root}/")
107
+ rel unless rel.empty? || rel == path.to_s
108
+ end
109
+ end
110
+
111
+ def broadcast(root, paths)
112
+ relative = relative_paths(root, paths)
113
+
114
+ invalidate_caches(root)
115
+ return unless defined?(ActionCable.server)
116
+
117
+ payload = { type: "files_changed" }
118
+ payload[:paths] = relative.first(200) if relative.any?
119
+ ActionCable.server.broadcast("mbeditor_editor", payload)
120
+ rescue StandardError => e
121
+ Rails.logger.warn("[mbeditor] file watcher broadcast failed: #{e.class}: #{e.message}")
122
+ end
123
+
124
+ # Mirrors EditorsController#broadcast_files_changed: a change the editor
125
+ # did not make invalidates exactly the same caches as one it did.
126
+ def invalidate_caches(root)
127
+ FileTreeService.invalidate(root)
128
+ SearchReplaceService.invalidate_cache(root)
129
+ JsGlobalsService.invalidate(root)
130
+ GitInfoService.invalidate(root)
131
+ rescue StandardError => e
132
+ Rails.logger.warn("[mbeditor] file watcher cache invalidation failed: #{e.class}: #{e.message}")
133
+ end
134
+ end
135
+ end
136
+ end