mbeditor 0.12.9 → 0.13.1
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 +216 -1
- data/app/assets/javascripts/mbeditor/application.js +6 -1
- data/app/assets/javascripts/mbeditor/collaboration_service.js +52 -1
- data/app/assets/javascripts/mbeditor/color_provider.js +5 -0
- data/app/assets/javascripts/mbeditor/components/CollapsibleSection.js +3 -1
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +104 -103
- data/app/assets/javascripts/mbeditor/components/FileTree.js +6 -1
- data/app/assets/javascripts/mbeditor/components/GitPanel.js +10 -1
- data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +15 -8
- data/app/assets/javascripts/mbeditor/components/ImportDialog.js +224 -0
- data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +704 -270
- data/app/assets/javascripts/mbeditor/components/ModelGraph.js +25 -6
- data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +193 -9
- data/app/assets/javascripts/mbeditor/components/QuickOpenDialog.js +105 -77
- data/app/assets/javascripts/mbeditor/components/TabBar.js +11 -2
- data/app/assets/javascripts/mbeditor/editor_plugins.js +434 -112
- data/app/assets/javascripts/mbeditor/file_import.js +78 -0
- data/app/assets/javascripts/mbeditor/file_service.js +112 -18
- data/app/assets/javascripts/mbeditor/git_service.js +130 -8
- data/app/assets/javascripts/mbeditor/history_service.js +33 -38
- data/app/assets/javascripts/mbeditor/log_service.js +4 -0
- data/app/assets/javascripts/mbeditor/search_service.js +50 -4
- data/app/assets/javascripts/mbeditor/tab_manager.js +66 -10
- data/app/assets/javascripts/mbeditor/websocket_service.js +88 -4
- data/app/assets/stylesheets/mbeditor/editor.css +389 -103
- data/app/channels/mbeditor/channel_authentication.rb +7 -0
- data/app/channels/mbeditor/collaboration_channel.rb +8 -2
- data/app/channels/mbeditor/editor_channel.rb +6 -3
- data/app/controllers/mbeditor/application_controller.rb +5 -0
- data/app/controllers/mbeditor/editors_controller.rb +155 -29
- data/app/controllers/mbeditor/git_controller.rb +3 -3
- data/app/controllers/mbeditor/logs_controller.rb +3 -1
- data/app/services/mbeditor/collaboration_doc_store.rb +49 -3
- data/app/services/mbeditor/duplicate_content_scanner.rb +105 -0
- data/app/services/mbeditor/editor_state_service.rb +36 -26
- data/app/services/mbeditor/exclusion_matcher.rb +12 -10
- data/app/services/mbeditor/file_operation_service.rb +38 -3
- data/app/services/mbeditor/file_tree_service.rb +30 -2
- data/app/services/mbeditor/git_combined_diff_service.rb +13 -3
- data/app/services/mbeditor/git_commit_detail_service.rb +20 -16
- data/app/services/mbeditor/git_diff_service.rb +5 -1
- data/app/services/mbeditor/git_info_service.rb +20 -8
- data/app/services/mbeditor/git_line_diff_service.rb +6 -2
- data/app/services/mbeditor/git_service.rb +65 -15
- data/app/services/mbeditor/js_definition_service.rb +3 -1
- data/app/services/mbeditor/js_globals_service.rb +18 -4
- data/app/services/mbeditor/js_members_service.rb +3 -2
- data/app/services/mbeditor/js_program_service.rb +15 -5
- data/app/services/mbeditor/js_syntax_check_service.rb +15 -4
- data/app/services/mbeditor/process_runner.rb +42 -12
- data/app/services/mbeditor/ri_definition_service.rb +8 -1
- data/app/services/mbeditor/route_service.rb +45 -8
- data/app/services/mbeditor/rubocop_run_service.rb +98 -0
- data/app/services/mbeditor/ruby_definition_service.rb +23 -4
- data/app/services/mbeditor/schema_service.rb +73 -66
- data/app/services/mbeditor/search_replace_service.rb +42 -7
- data/app/services/mbeditor/test_runner_service.rb +45 -10
- data/lib/mbeditor/cable_log_filter.rb +8 -2
- data/lib/mbeditor/configuration.rb +4 -1
- data/lib/mbeditor/editor_bootstrap.rb +18 -12
- data/lib/mbeditor/engine.rb +22 -3
- data/lib/mbeditor/exception_log.rb +2 -2
- data/lib/mbeditor/pending_migrations.rb +33 -0
- data/lib/mbeditor/rack/handle_pending_migrations.rb +25 -15
- data/lib/mbeditor/rack/pending_migration_bypass.rb +104 -0
- data/lib/mbeditor/rack/silence_ping_request.rb +10 -2
- data/lib/mbeditor/route_map.rb +1 -0
- data/lib/mbeditor/ruby_lsp_client.rb +71 -12
- data/lib/mbeditor/version.rb +1 -1
- data/lib/tasks/mbeditor.rake +23 -0
- metadata +8 -2
|
@@ -22,18 +22,13 @@ module Mbeditor
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def read_state
|
|
25
|
-
|
|
26
|
-
return {} unless File.exist?(path)
|
|
27
|
-
JSON.parse(File.read(path))
|
|
25
|
+
read_json(workspace_path)
|
|
28
26
|
rescue JSON::ParserError, Errno::ENOENT
|
|
29
27
|
{}
|
|
30
28
|
end
|
|
31
29
|
|
|
32
30
|
def read_branch_state(branch)
|
|
33
|
-
|
|
34
|
-
return {} unless File.exist?(path)
|
|
35
|
-
all = JSON.parse(File.read(path))
|
|
36
|
-
all[branch] || {}
|
|
31
|
+
read_json(branch_states_path)[branch] || {}
|
|
37
32
|
rescue JSON::ParserError, Errno::ENOENT
|
|
38
33
|
{}
|
|
39
34
|
end
|
|
@@ -44,17 +39,14 @@ module Mbeditor
|
|
|
44
39
|
raise PayloadTooLargeError, "State payload too large" if payload_json.bytesize > STATE_MAX_BYTES
|
|
45
40
|
path = branch_states_path
|
|
46
41
|
FileUtils.mkdir_p(path.dirname)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
existing = f.size > 0 ? JSON.parse(f.read) : {}
|
|
42
|
+
with_lock(path) do
|
|
43
|
+
existing = read_json(path)
|
|
50
44
|
# Auto-save fires on a timer even with no changes; skip the full-file
|
|
51
45
|
# rewrite when this branch's entry is already identical.
|
|
52
|
-
|
|
46
|
+
next if existing[branch] == JSON.parse(payload_json)
|
|
53
47
|
|
|
54
48
|
existing[branch] = state
|
|
55
|
-
|
|
56
|
-
f.rewind
|
|
57
|
-
f.write(existing.to_json)
|
|
49
|
+
atomic_write(path, existing.to_json)
|
|
58
50
|
end
|
|
59
51
|
nil
|
|
60
52
|
end
|
|
@@ -63,10 +55,9 @@ module Mbeditor
|
|
|
63
55
|
path = branch_states_path
|
|
64
56
|
return [] unless File.exist?(path)
|
|
65
57
|
pruned = []
|
|
66
|
-
|
|
67
|
-
lock_exclusive!(f)
|
|
58
|
+
with_lock(path) do
|
|
68
59
|
all = begin
|
|
69
|
-
|
|
60
|
+
read_json(path)
|
|
70
61
|
rescue JSON::ParserError => e
|
|
71
62
|
Rails.logger.error("[mbeditor] EditorStateService#prune_branch_states: discarding corrupt branch_states JSON at #{path}: #{e.message}")
|
|
72
63
|
{}
|
|
@@ -74,9 +65,7 @@ module Mbeditor
|
|
|
74
65
|
pruned = all.keys - active_branches
|
|
75
66
|
if pruned.any?
|
|
76
67
|
pruned.each { |b| all.delete(b) }
|
|
77
|
-
|
|
78
|
-
f.rewind
|
|
79
|
-
f.write(all.to_json)
|
|
68
|
+
atomic_write(path, all.to_json)
|
|
80
69
|
end
|
|
81
70
|
end
|
|
82
71
|
pruned
|
|
@@ -87,17 +76,38 @@ module Mbeditor
|
|
|
87
76
|
raise PayloadTooLargeError, "State payload too large" if payload.bytesize > STATE_MAX_BYTES
|
|
88
77
|
path = workspace_path
|
|
89
78
|
FileUtils.mkdir_p(path.dirname)
|
|
90
|
-
|
|
91
|
-
lock_exclusive!(f)
|
|
92
|
-
f.truncate(0)
|
|
93
|
-
f.rewind
|
|
94
|
-
f.write(payload)
|
|
95
|
-
end
|
|
79
|
+
with_lock(path) { atomic_write(path, payload) }
|
|
96
80
|
nil
|
|
97
81
|
end
|
|
98
82
|
|
|
99
83
|
private
|
|
100
84
|
|
|
85
|
+
# Readers take no lock at all: every write lands by rename, so a read sees
|
|
86
|
+
# either the whole previous file or the whole new one — never the empty
|
|
87
|
+
# window a truncate-then-write leaves, which readers swallowed as {}.
|
|
88
|
+
def read_json(path)
|
|
89
|
+
return {} unless File.exist?(path)
|
|
90
|
+
|
|
91
|
+
raw = File.read(path)
|
|
92
|
+
raw.empty? ? {} : JSON.parse(raw)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def atomic_write(path, payload)
|
|
96
|
+
tmp = "#{path}.tmp"
|
|
97
|
+
File.write(tmp, payload)
|
|
98
|
+
File.rename(tmp, path)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The lock sits on a sidecar file, not on the state file: the state file is
|
|
102
|
+
# replaced by rename, so a lock held on the inode it had before the write
|
|
103
|
+
# would exclude nobody afterwards.
|
|
104
|
+
def with_lock(path)
|
|
105
|
+
File.open("#{path}.lock", File::RDWR | File::CREAT) do |f|
|
|
106
|
+
lock_exclusive!(f)
|
|
107
|
+
yield
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
101
111
|
# Acquire an exclusive lock without blocking forever. Retries the
|
|
102
112
|
# non-blocking flock until @lock_timeout elapses, then raises so the caller
|
|
103
113
|
# fails fast instead of pinning a worker on a stuck holder.
|
|
@@ -87,24 +87,26 @@ module Mbeditor
|
|
|
87
87
|
# case is folded. Defaults to the resolved workspace root.
|
|
88
88
|
def initialize(patterns, root: nil)
|
|
89
89
|
@fold_case = self.class.case_insensitive_filesystem?(root || WorkspaceRootResolver.call)
|
|
90
|
-
|
|
90
|
+
normalized = patterns.map { |pattern| normalize(pattern.to_s) }.reject(&:empty?)
|
|
91
|
+
# Split by shape once: a path pattern is compared against the whole
|
|
92
|
+
# relative path, a bare name against its segments. This runs per file of
|
|
93
|
+
# a workspace walk, so neither the split nor the basename belongs in the
|
|
94
|
+
# per-pattern loop.
|
|
95
|
+
@path_patterns, @name_patterns = normalized.partition { |pattern| pattern.include?("/") }
|
|
91
96
|
end
|
|
92
97
|
|
|
93
98
|
def excluded?(relative_path)
|
|
94
99
|
rel = normalize(relative_path.to_s)
|
|
95
|
-
@
|
|
100
|
+
return true if @path_patterns.any? { |pattern| rel == pattern || rel.start_with?("#{pattern}/") }
|
|
101
|
+
return false if @name_patterns.empty?
|
|
102
|
+
|
|
103
|
+
segments = rel.split("/")
|
|
104
|
+
basename = File.basename(rel)
|
|
105
|
+
@name_patterns.any? { |pattern| basename == pattern || segments.include?(pattern) }
|
|
96
106
|
end
|
|
97
107
|
|
|
98
108
|
private
|
|
99
109
|
|
|
100
|
-
def matches?(pattern, rel)
|
|
101
|
-
if pattern.include?("/")
|
|
102
|
-
rel == pattern || rel.start_with?("#{pattern}/")
|
|
103
|
-
else
|
|
104
|
-
File.basename(rel) == pattern || rel.split("/").include?(pattern)
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
110
|
# "/" is ASCII and survives both steps, so the caller can normalize a whole
|
|
109
111
|
# path in one pass and split it afterwards.
|
|
110
112
|
def normalize(str)
|
|
@@ -18,7 +18,7 @@ module Mbeditor
|
|
|
18
18
|
def save(path, content)
|
|
19
19
|
raise FileTooLargeError if content.bytesize > MAX_FILE_SIZE_BYTES
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
atomic_write(path, content)
|
|
22
22
|
{ ok: true, path: relative_path(path) }
|
|
23
23
|
end
|
|
24
24
|
|
|
@@ -36,10 +36,15 @@ module Mbeditor
|
|
|
36
36
|
|
|
37
37
|
def rename(old_path, new_path)
|
|
38
38
|
raise PathNotFoundError unless File.exist?(old_path)
|
|
39
|
-
|
|
39
|
+
# A case-only rename on a case-insensitive filesystem (macOS) reports the
|
|
40
|
+
# target as existing because it IS the source.
|
|
41
|
+
same_file = File.identical?(old_path, new_path)
|
|
42
|
+
raise TargetExistsError if File.exist?(new_path) && !same_file
|
|
40
43
|
|
|
41
44
|
FileUtils.mkdir_p(File.dirname(new_path))
|
|
42
|
-
FileUtils.mv
|
|
45
|
+
# FileUtils.mv refuses a "same file" move, so the case-only rename goes
|
|
46
|
+
# straight to rename(2); everything else keeps mv's cross-device fallback.
|
|
47
|
+
same_file ? File.rename(old_path, new_path) : FileUtils.mv(old_path, new_path)
|
|
43
48
|
{ ok: true, oldPath: relative_path(old_path), path: relative_path(new_path), name: File.basename(new_path) }
|
|
44
49
|
end
|
|
45
50
|
|
|
@@ -61,6 +66,36 @@ module Mbeditor
|
|
|
61
66
|
|
|
62
67
|
private
|
|
63
68
|
|
|
69
|
+
# Write through a temp file in the same directory and rename over the
|
|
70
|
+
# target: a truncate-then-write loses the file outright if the process dies
|
|
71
|
+
# mid-save. The symlink is resolved first — renaming onto the link itself
|
|
72
|
+
# would replace it with a regular file, and linked-in files are supported.
|
|
73
|
+
def atomic_write(path, content)
|
|
74
|
+
target = link_target(path)
|
|
75
|
+
tmp = File.join(File.dirname(target), ".#{File.basename(target)}.mbeditor-tmp")
|
|
76
|
+
mode = File.stat(target).mode & 0o7777 if File.exist?(target)
|
|
77
|
+
File.write(tmp, content)
|
|
78
|
+
File.chmod(mode, tmp) if mode
|
|
79
|
+
File.rename(tmp, target)
|
|
80
|
+
rescue StandardError
|
|
81
|
+
FileUtils.rm_f(tmp) if tmp
|
|
82
|
+
raise
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def link_target(path)
|
|
86
|
+
return path.to_s unless File.symlink?(path)
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
File.realpath(path)
|
|
90
|
+
rescue SystemCallError
|
|
91
|
+
# realpath refuses a dangling link, but a save through one still has to
|
|
92
|
+
# create the file it names — the same thing File.write did.
|
|
93
|
+
File.expand_path(File.readlink(path), File.dirname(path))
|
|
94
|
+
end
|
|
95
|
+
rescue SystemCallError
|
|
96
|
+
path.to_s
|
|
97
|
+
end
|
|
98
|
+
|
|
64
99
|
def relative_path(path)
|
|
65
100
|
Pathname(path).relative_path_from(@workspace_root).to_s
|
|
66
101
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
module Mbeditor
|
|
4
6
|
class FileTreeService
|
|
5
7
|
MUTEX = Mutex.new
|
|
@@ -26,6 +28,26 @@ module Mbeditor
|
|
|
26
28
|
data
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# The tree already serialized, plus a digest of that body, memoized inside
|
|
32
|
+
# the same cache entry. /files is polled every 10s and the payload is ~1 MB,
|
|
33
|
+
# so the digest is what turns an unchanged poll into a 304.
|
|
34
|
+
def self.cached_json(workspace_root)
|
|
35
|
+
root = workspace_root.to_s
|
|
36
|
+
data = build(root)
|
|
37
|
+
|
|
38
|
+
MUTEX.synchronize do
|
|
39
|
+
entry = (@cache ||= {})[root]
|
|
40
|
+
return entry[:json] if entry && entry[:json] && entry[:data].equal?(data)
|
|
41
|
+
|
|
42
|
+
# to_json, not JSON.generate: `render json:` used ActiveSupport's
|
|
43
|
+
# encoder, and the body must stay byte-identical.
|
|
44
|
+
body = data.to_json
|
|
45
|
+
payload = { body: body, digest: Digest::SHA1.hexdigest(body) }
|
|
46
|
+
entry[:json] = payload if entry
|
|
47
|
+
payload
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
29
51
|
def self.invalidate(workspace_root)
|
|
30
52
|
MUTEX.synchronize do
|
|
31
53
|
@cache ||= {}
|
|
@@ -49,14 +71,20 @@ module Mbeditor
|
|
|
49
71
|
rel = "" if rel == workspace_root
|
|
50
72
|
is_excl = matcher.excluded?(rel)
|
|
51
73
|
|
|
52
|
-
|
|
74
|
+
# One lstat instead of directory?/symlink?/size — three stats per entry
|
|
75
|
+
# over a whole tree. lstat does not follow, so a symlink is never a
|
|
76
|
+
# folder here, exactly as the directory?-and-not-symlink? test intended;
|
|
77
|
+
# a symlinked file still reports its *target's* size, as it always did.
|
|
78
|
+
st = (File.lstat(full) rescue nil)
|
|
79
|
+
|
|
80
|
+
if st&.directory?
|
|
53
81
|
children = is_excl ? [] : traverse(full, workspace_root, matcher, max_depth: max_depth, depth: depth + 1)
|
|
54
82
|
node = { name: name, type: "folder", path: rel, children: children }
|
|
55
83
|
node[:excluded] = true if is_excl
|
|
56
84
|
node
|
|
57
85
|
else
|
|
58
86
|
node = { name: name, type: "file", path: rel }
|
|
59
|
-
node[:size] = (File.size(full) rescue nil) unless is_excl
|
|
87
|
+
node[:size] = ((st&.symlink? ? File.size(full) : st&.size) rescue nil) unless is_excl
|
|
60
88
|
node[:excluded] = true if is_excl
|
|
61
89
|
node
|
|
62
90
|
end
|
|
@@ -33,17 +33,27 @@ module Mbeditor
|
|
|
33
33
|
private
|
|
34
34
|
|
|
35
35
|
def local_diff
|
|
36
|
-
out, status =
|
|
36
|
+
out, status = git_diff("HEAD")
|
|
37
37
|
status.success? ? cap_diff(out) : ""
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# --no-color/--no-ext-diff on every invocation: a host gitconfig with
|
|
41
|
+
# color.ui = always, or a diff.external driver, otherwise feeds escape
|
|
42
|
+
# codes or foreign output into the viewer's parser. max_bytes stops a
|
|
43
|
+
# pathological diff being fully materialised just for cap_diff to throw
|
|
44
|
+
# most of it away.
|
|
45
|
+
def git_diff(*revs)
|
|
46
|
+
GitService.run_git(repo_path, "diff", "--no-color", "--no-ext-diff", *revs,
|
|
47
|
+
max_bytes: MAX_DIFF_BYTES)
|
|
48
|
+
end
|
|
49
|
+
|
|
40
50
|
def branch_diff
|
|
41
51
|
branch = GitService.current_branch(repo_path)
|
|
42
52
|
base_sha, ref = GitService.find_branch_base(repo_path, branch)
|
|
43
53
|
|
|
44
54
|
if base_sha.present?
|
|
45
55
|
@base_ref = ref
|
|
46
|
-
out, status =
|
|
56
|
+
out, status = git_diff("#{base_sha}..HEAD")
|
|
47
57
|
return status.success? ? cap_diff(out) : ""
|
|
48
58
|
end
|
|
49
59
|
|
|
@@ -56,7 +66,7 @@ module Mbeditor
|
|
|
56
66
|
upstream = GitService.upstream_branch(repo_path)
|
|
57
67
|
if GitService.base_branch?(branch) && upstream.present?
|
|
58
68
|
@base_ref = upstream
|
|
59
|
-
out, status =
|
|
69
|
+
out, status = git_diff("#{upstream}..HEAD")
|
|
60
70
|
return status.success? ? cap_diff(out) : ""
|
|
61
71
|
end
|
|
62
72
|
|
|
@@ -11,21 +11,33 @@ module Mbeditor
|
|
|
11
11
|
@sha = sha.to_s
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
EMPTY = { "title" => "", "author" => "", "date" => "", "files" => [] }.freeze
|
|
15
|
+
private_constant :EMPTY
|
|
16
|
+
|
|
17
|
+
# Two spawns per commit click, not three: `git show --name-status` prints
|
|
18
|
+
# the pretty header then the raw name-status body. The numstat still needs
|
|
19
|
+
# its own call — asking for --name-status and --numstat together makes git
|
|
20
|
+
# emit only the former. --no-renames keeps a rename as the D+A pair the
|
|
21
|
+
# two-field parse below expects, rather than a three-field R100 record.
|
|
14
22
|
def call
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
out, status = GitService.run_git(repo_path, "show", "--no-color", "--no-renames", "--name-status",
|
|
24
|
+
"--pretty=format:%s%x1f%an%x1f%aI", "--end-of-options", sha)
|
|
25
|
+
return EMPTY.merge("sha" => sha) unless status.success?
|
|
26
|
+
|
|
27
|
+
header, body = out.split("\n", 2)
|
|
28
|
+
fields = header.to_s.split("\x1f", 3)
|
|
29
|
+
{ "sha" => sha, "title" => fields[0].to_s, "author" => fields[1].to_s, "date" => fields[2].to_s,
|
|
30
|
+
"files" => parse_files(body.to_s) }
|
|
17
31
|
end
|
|
18
32
|
|
|
19
33
|
private
|
|
20
34
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
num_out, num_st = GitService.run_git(repo_path, "diff-tree", "--no-commit-id", "-r", "--numstat", sha)
|
|
35
|
+
def parse_files(body)
|
|
36
|
+
num_out, num_st = GitService.run_git(repo_path, "show", "--no-color", "--no-renames", "--numstat",
|
|
37
|
+
"--pretty=format:", "--end-of-options", sha)
|
|
26
38
|
numstat = num_st.success? ? GitService.parse_numstat(num_out) : {}
|
|
27
39
|
|
|
28
|
-
|
|
40
|
+
body.lines.filter_map do |line|
|
|
29
41
|
parts = line.strip.split("\t", 2)
|
|
30
42
|
next if parts.length < 2
|
|
31
43
|
|
|
@@ -34,13 +46,5 @@ module Mbeditor
|
|
|
34
46
|
{ "status" => parts[0].strip, "path" => path, "added" => stats[:added], "removed" => stats[:removed] }
|
|
35
47
|
end
|
|
36
48
|
end
|
|
37
|
-
|
|
38
|
-
def fetch_meta
|
|
39
|
-
out, status = GitService.run_git(repo_path, "log", "-1", "--pretty=format:%s%x1f%an%x1f%aI", sha)
|
|
40
|
-
return { "title" => "", "author" => "", "date" => "" } unless status.success?
|
|
41
|
-
|
|
42
|
-
fields = out.strip.split("\x1f", 3)
|
|
43
|
-
{ "title" => fields[0].to_s, "author" => fields[1].to_s, "date" => fields[2].to_s }
|
|
44
|
-
end
|
|
45
49
|
end
|
|
46
50
|
end
|
|
@@ -53,8 +53,12 @@ module Mbeditor
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
# Return the file content at a given git ref, or "" if it doesn't exist.
|
|
56
|
+
#
|
|
57
|
+
# --end-of-options (git >= 2.24, safely assumable for dev tooling) stops the
|
|
58
|
+
# rev being read as a command-line option, e.g. `--output=/tmp/x`, on top of
|
|
59
|
+
# the caller-side ref validation.
|
|
56
60
|
def file_at_ref(ref, path)
|
|
57
|
-
out, status = GitService.run_git(repo_path, "show", "#{ref}:#{path}")
|
|
61
|
+
out, status = GitService.run_git(repo_path, "show", "--end-of-options", "#{ref}:#{path}")
|
|
58
62
|
return "" unless status.success?
|
|
59
63
|
|
|
60
64
|
out
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
3
5
|
module Mbeditor
|
|
4
6
|
module GitInfoService
|
|
5
7
|
module_function
|
|
@@ -16,19 +18,26 @@ module Mbeditor
|
|
|
16
18
|
cached = cached_git_info(repo_path)
|
|
17
19
|
return cached if cached
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
flight = nil
|
|
20
22
|
GIT_INFO_MUTEX.synchronize do
|
|
21
23
|
@git_info_flights ||= {}
|
|
22
|
-
|
|
23
|
-
@git_info_flights[repo_path] =
|
|
24
|
+
flight = @git_info_flights[repo_path]
|
|
25
|
+
@git_info_flights[repo_path] = Queue.new unless flight
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
if
|
|
28
|
+
if flight
|
|
27
29
|
# Another thread is already running the fan-out; don't duplicate it.
|
|
28
30
|
stale = stale_git_info(repo_path)
|
|
29
31
|
return stale if stale
|
|
30
32
|
|
|
31
|
-
owner
|
|
33
|
+
# The owner closes this queue when it is done, which releases every
|
|
34
|
+
# waiter at once. Waiting on the owner *thread* was the old mechanism,
|
|
35
|
+
# but a Puma pool thread never exits, so join always ran to CACHE_TTL.
|
|
36
|
+
begin
|
|
37
|
+
Timeout.timeout(CACHE_TTL) { flight.pop }
|
|
38
|
+
rescue Timeout::Error
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
32
41
|
return cached_git_info(repo_path) || stale_git_info(repo_path) ||
|
|
33
42
|
{ ok: false, error: "git info computation in progress" }
|
|
34
43
|
end
|
|
@@ -36,7 +45,8 @@ module Mbeditor
|
|
|
36
45
|
begin
|
|
37
46
|
compute(repo_path)
|
|
38
47
|
ensure
|
|
39
|
-
GIT_INFO_MUTEX.synchronize { @git_info_flights.delete(repo_path) }
|
|
48
|
+
done = GIT_INFO_MUTEX.synchronize { @git_info_flights.delete(repo_path) }
|
|
49
|
+
done&.close
|
|
40
50
|
end
|
|
41
51
|
end
|
|
42
52
|
|
|
@@ -47,8 +57,10 @@ module Mbeditor
|
|
|
47
57
|
end
|
|
48
58
|
|
|
49
59
|
# Wave 1: all independent git reads run in parallel
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
# -z on both: it is the only form that leaves paths with spaces or
|
|
61
|
+
# non-ASCII bytes unquoted, and the two lists are paired by path below.
|
|
62
|
+
status_t = Thread.new { safe_git(repo_path, "status", "--porcelain", "-z") }
|
|
63
|
+
numstat_t = Thread.new { safe_git(repo_path, "diff", "--numstat", "-z", "HEAD") }
|
|
52
64
|
upstream_t = Thread.new { safe_git(repo_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") }
|
|
53
65
|
base_t = Thread.new { GitService.find_branch_base(repo_path, branch) }
|
|
54
66
|
|
|
@@ -32,11 +32,15 @@ module Mbeditor
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def call
|
|
35
|
-
return untracked_result if untracked?
|
|
36
|
-
|
|
37
35
|
output, status = GitService.run_git(
|
|
38
36
|
repo_path, "diff", "--no-color", "--no-ext-diff", "-U0", "HEAD", "--", file_path
|
|
39
37
|
)
|
|
38
|
+
|
|
39
|
+
# ls-files only distinguishes "no changes" from "never tracked", so it
|
|
40
|
+
# runs solely when the diff produced nothing. Asking first doubled the
|
|
41
|
+
# subprocess count on a path the editor polls every 10s.
|
|
42
|
+
return untracked_result if (!status.success? || output.empty?) && untracked?
|
|
43
|
+
|
|
40
44
|
# A non-zero status here means the diff could not be produced at all (no
|
|
41
45
|
# HEAD yet, path outside the repo). Report a clean file rather than
|
|
42
46
|
# raising: line colouring is decoration, never a reason to fail a load.
|
|
@@ -10,17 +10,19 @@ module Mbeditor
|
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
12
|
# Safe pattern for git ref names (branch, remote/branch, tag).
|
|
13
|
-
# Excludes @ to prevent reflog syntax like @{-1} or @{u}
|
|
14
|
-
|
|
13
|
+
# Excludes @ to prevent reflog syntax like @{-1} or @{u}, and a leading -
|
|
14
|
+
# so a ref can never be read as a command-line option by the git it is
|
|
15
|
+
# interpolated into.
|
|
16
|
+
SAFE_GIT_REF = %r{\A(?!-)[\w./-]+\z}
|
|
15
17
|
|
|
16
18
|
# Run an arbitrary git command inside +repo_path+.
|
|
17
19
|
# Returns [stdout, Process::Status]. stderr is discarded to prevent git
|
|
18
20
|
# diagnostic messages from leaking into the Rails server log.
|
|
19
21
|
# Honors config.git_timeout (seconds) when set.
|
|
20
|
-
def run_git(repo_path, *args)
|
|
22
|
+
def run_git(repo_path, *args, max_bytes: nil)
|
|
21
23
|
timeout_secs = Mbeditor.configuration.git_timeout&.to_i
|
|
22
24
|
timeout = timeout_secs && timeout_secs > 0 ? timeout_secs : nil
|
|
23
|
-
result = ProcessRunner.call(["git", "-C", repo_path, *args], timeout: timeout)
|
|
25
|
+
result = ProcessRunner.call(["git", "-C", repo_path, *args], timeout: timeout, max_bytes: max_bytes)
|
|
24
26
|
[result[:stdout], result[:exit_status]]
|
|
25
27
|
rescue ProcessRunner::TimeoutError
|
|
26
28
|
raise Timeout::Error, "git timed out after #{timeout_secs}s"
|
|
@@ -70,12 +72,18 @@ module Mbeditor
|
|
|
70
72
|
def find_branch_base(repo_path, current_branch, candidates: nil)
|
|
71
73
|
candidates ||= Mbeditor.configuration.base_branch_candidates
|
|
72
74
|
|
|
75
|
+
# One listing instead of a rev-parse per candidate: with six defaults
|
|
76
|
+
# that was up to twelve sequential spawns on a path the git panel polls.
|
|
77
|
+
refs_out, refs_st = run_git(repo_path, "for-each-ref", "--format=%(refname:short)",
|
|
78
|
+
"refs/heads", "refs/remotes")
|
|
79
|
+
return [nil, nil] unless refs_st.success?
|
|
80
|
+
|
|
81
|
+
existing = refs_out.split("\n").map(&:strip)
|
|
82
|
+
|
|
73
83
|
candidates.each do |ref|
|
|
74
84
|
short = ref.delete_prefix("origin/")
|
|
75
85
|
next if short == current_branch || ref == current_branch
|
|
76
|
-
|
|
77
|
-
_o, st = run_git(repo_path, "rev-parse", "--verify", "--quiet", ref)
|
|
78
|
-
next unless st.success?
|
|
86
|
+
next unless existing.include?(ref)
|
|
79
87
|
|
|
80
88
|
base_out, base_st = run_git(repo_path, "merge-base", "HEAD", ref)
|
|
81
89
|
next unless base_st.success?
|
|
@@ -101,17 +109,34 @@ module Mbeditor
|
|
|
101
109
|
candidates.any? { |ref| ref == current_branch || ref.delete_prefix("origin/") == current_branch }
|
|
102
110
|
end
|
|
103
111
|
|
|
104
|
-
# Parse `git status --porcelain` output
|
|
112
|
+
# Parse `git status --porcelain` output, in either the newline form or the
|
|
113
|
+
# NUL-delimited `-z` form.
|
|
114
|
+
#
|
|
115
|
+
# Only `-z` gives usable paths: without it git quotes anything containing a
|
|
116
|
+
# space or a non-ASCII byte (`"caf\303\251.rb"`) and writes a rename as
|
|
117
|
+
# `old -> new`, so the path came back unopenable and never matched numstat.
|
|
118
|
+
# In `-z` a rename/copy spans two records and the NEW name comes first.
|
|
119
|
+
#
|
|
105
120
|
# Returns Array of { status: String, path: String }.
|
|
106
121
|
def parse_porcelain_status(output)
|
|
107
|
-
output.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
nul = output.include?("\0")
|
|
123
|
+
records = nul ? output.split("\0") : output.lines.map(&:chomp)
|
|
124
|
+
|
|
125
|
+
result = []
|
|
126
|
+
i = 0
|
|
127
|
+
while i < records.length
|
|
128
|
+
record = records[i]
|
|
129
|
+
i += 1
|
|
130
|
+
next if record.length < 4
|
|
131
|
+
|
|
132
|
+
status = record[0..1].strip
|
|
133
|
+
i += 1 if nul && status.start_with?("R", "C") # skip the old name
|
|
134
|
+
path = nul ? record[3..].to_s : record[3..].to_s.strip
|
|
111
135
|
next if path.blank?
|
|
112
136
|
|
|
113
|
-
{ status:
|
|
137
|
+
result << { status: status, path: path }
|
|
114
138
|
end
|
|
139
|
+
result
|
|
115
140
|
end
|
|
116
141
|
|
|
117
142
|
# Parse `git diff --name-status` output.
|
|
@@ -129,10 +154,15 @@ module Mbeditor
|
|
|
129
154
|
end
|
|
130
155
|
end
|
|
131
156
|
|
|
132
|
-
# Parse `git diff --numstat` output
|
|
157
|
+
# Parse `git diff --numstat` output, in either the newline form or the
|
|
158
|
+
# NUL-delimited `-z` form (which is what pairs with `status --porcelain -z`,
|
|
159
|
+
# since only it leaves non-ASCII paths unquoted).
|
|
133
160
|
# Returns Hash of path => { added: Integer, removed: Integer }.
|
|
134
161
|
def parse_numstat(output)
|
|
135
|
-
(output || "")
|
|
162
|
+
out = (output || "")
|
|
163
|
+
return parse_numstat_z(out) if out.include?("\0")
|
|
164
|
+
|
|
165
|
+
out.lines.each_with_object({}) do |line, map|
|
|
136
166
|
parts = line.strip.split("\t", 3)
|
|
137
167
|
next if parts.length < 3 || parts[0] == "-"
|
|
138
168
|
|
|
@@ -140,6 +170,26 @@ module Mbeditor
|
|
|
140
170
|
end
|
|
141
171
|
end
|
|
142
172
|
|
|
173
|
+
# In `-z` form a rename leaves the path field empty and follows the record
|
|
174
|
+
# with the old name then the new name, each NUL-terminated.
|
|
175
|
+
def parse_numstat_z(output)
|
|
176
|
+
records = output.split("\0")
|
|
177
|
+
map = {}
|
|
178
|
+
i = 0
|
|
179
|
+
while i < records.length
|
|
180
|
+
added, removed, path = records[i].split("\t", 3)
|
|
181
|
+
i += 1
|
|
182
|
+
if path.to_s.empty?
|
|
183
|
+
path = records[i + 1]
|
|
184
|
+
i += 2
|
|
185
|
+
end
|
|
186
|
+
next if path.nil? || added == "-"
|
|
187
|
+
|
|
188
|
+
map[path] = { added: added.to_i, removed: removed.to_i }
|
|
189
|
+
end
|
|
190
|
+
map
|
|
191
|
+
end
|
|
192
|
+
|
|
143
193
|
# Parse compact `git log --pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%aI%x1e` output.
|
|
144
194
|
# Returns Array of hashes with string keys.
|
|
145
195
|
def self.parse_git_log_with_parents(raw_output)
|
|
@@ -27,6 +27,8 @@ module Mbeditor
|
|
|
27
27
|
@symbol = symbol.to_s
|
|
28
28
|
@workspace_root = workspace_root.to_s.chomp("/")
|
|
29
29
|
@parent = parent.to_s.empty? ? nil : parent.to_s
|
|
30
|
+
# Compiled once: top_level? runs on every parsed result line.
|
|
31
|
+
@window_assign = /\Awindow\.#{Regexp.escape(@symbol)}\s*=/
|
|
30
32
|
end
|
|
31
33
|
|
|
32
34
|
def call
|
|
@@ -126,7 +128,7 @@ module Mbeditor
|
|
|
126
128
|
# top-level by grammar.
|
|
127
129
|
def top_level?(indent, snippet)
|
|
128
130
|
return true if indent.empty?
|
|
129
|
-
return true if snippet.match?(
|
|
131
|
+
return true if snippet.match?(@window_assign)
|
|
130
132
|
return true if snippet.start_with?("export ")
|
|
131
133
|
|
|
132
134
|
false
|
|
@@ -22,6 +22,17 @@ module Mbeditor
|
|
|
22
22
|
|
|
23
23
|
IDENTIFIER = /[A-Za-z_$][A-Za-z0-9_$]*/
|
|
24
24
|
|
|
25
|
+
# Runtime globals no static scan can reach, for the same reason React and
|
|
26
|
+
# lodash can't be reached: a UMD bundle assigns its name through a closure
|
|
27
|
+
# parameter. react-rails ships `root["ReactRailsUJS"] = factory()` in the
|
|
28
|
+
# *gem's* lib/assets/javascripts/react_ujs.js — outside the workspace, so
|
|
29
|
+
# it is never scanned, and bracket-assigned to a parameter, so widening
|
|
30
|
+
# PATTERN to `IDENT["Str"] =` would not help and would declare thousands
|
|
31
|
+
# of junk names out of every minified bundle. Seeded like
|
|
32
|
+
# js_global_identifiers, so both consumers (the TS worker's ambient
|
|
33
|
+
# declarations and the babel scope lint's whitelist) get them.
|
|
34
|
+
KNOWN_RUNTIME_GLOBALS = %w[ReactRailsUJS].freeze
|
|
35
|
+
|
|
25
36
|
# Minified bundles are the reason for both guards below.
|
|
26
37
|
#
|
|
27
38
|
# A minified file is one enormous line, and it usually opens with a
|
|
@@ -67,6 +78,13 @@ module Mbeditor
|
|
|
67
78
|
symbols = {}
|
|
68
79
|
truncated = false
|
|
69
80
|
|
|
81
|
+
# Seeded first so the cap can never drop them: they are the names the
|
|
82
|
+
# host app declared it needs, and appending them after the scan meant
|
|
83
|
+
# first(MAX_SYMBOLS) silently discarded every one on a big workspace.
|
|
84
|
+
(KNOWN_RUNTIME_GLOBALS + configured_identifiers).each do |name|
|
|
85
|
+
symbols[name] = { name: name, file: nil, line: nil, kind: "configured" }
|
|
86
|
+
end
|
|
87
|
+
|
|
70
88
|
CodeSearchService.call(PATTERN, root).each do |raw|
|
|
71
89
|
if symbols.length >= MAX_SYMBOLS
|
|
72
90
|
truncated = true
|
|
@@ -90,10 +108,6 @@ module Mbeditor
|
|
|
90
108
|
end
|
|
91
109
|
end
|
|
92
110
|
|
|
93
|
-
configured_identifiers.each do |name|
|
|
94
|
-
symbols[name] ||= { name: name, file: nil, line: nil, kind: "configured" }
|
|
95
|
-
end
|
|
96
|
-
|
|
97
111
|
{
|
|
98
112
|
ok: true,
|
|
99
113
|
generatedAt: Time.now.to_i,
|