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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -0
- data/README.md +31 -0
- data/app/assets/javascripts/mbeditor/application.js +3 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +281 -60
- data/app/assets/javascripts/mbeditor/components/LogPanel.js +50 -1
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +91 -16
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +217 -0
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +34 -3
- data/app/assets/javascripts/mbeditor/editor_plugins.js +112 -52
- data/app/assets/javascripts/mbeditor/git_service.js +8 -0
- data/app/assets/javascripts/mbeditor/js_outline.js +110 -0
- data/app/assets/javascripts/mbeditor/ruby_outline.js +427 -0
- data/app/assets/stylesheets/mbeditor/editor.css +224 -5
- data/app/assets/stylesheets/mbeditor/themes.css +35 -0
- data/app/controllers/mbeditor/application_controller.rb +5 -11
- data/app/controllers/mbeditor/editors_controller.rb +34 -5
- data/app/controllers/mbeditor/git_controller.rb +11 -0
- data/app/services/mbeditor/exclusion_matcher.rb +105 -3
- data/app/services/mbeditor/file_tree_service.rb +1 -1
- data/app/services/mbeditor/git_line_diff_service.rb +99 -0
- data/app/services/mbeditor/git_service.rb +3 -10
- data/app/services/mbeditor/ruby_definition_service.rb +1 -1
- data/app/services/mbeditor/safe_path.rb +57 -0
- data/app/services/mbeditor/search_replace_service.rb +2 -2
- data/lib/mbeditor/configuration.rb +2 -1
- data/lib/mbeditor/engine.rb +3 -0
- data/lib/mbeditor/file_watcher.rb +136 -0
- data/lib/mbeditor/route_map.rb +1 -0
- data/lib/mbeditor/version.rb +1 -1
- 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
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
#
|
|
143
|
-
#
|
|
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
|
data/lib/mbeditor/engine.rb
CHANGED
|
@@ -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
|