mbeditor 0.5.3 → 0.7.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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +7 -0
  4. data/app/assets/javascripts/mbeditor/application.js +3 -0
  5. data/app/assets/javascripts/mbeditor/components/ChangelogView.js +145 -0
  6. data/app/assets/javascripts/mbeditor/components/DiffViewer.js +1 -1
  7. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +359 -31
  8. data/app/assets/javascripts/mbeditor/components/FileTree.js +177 -116
  9. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +952 -143
  10. data/app/assets/javascripts/mbeditor/components/TabBar.js +9 -0
  11. data/app/assets/javascripts/mbeditor/conflict_parser.js +48 -0
  12. data/app/assets/javascripts/mbeditor/editor_plugins.js +420 -67
  13. data/app/assets/javascripts/mbeditor/editor_store.js +1 -0
  14. data/app/assets/javascripts/mbeditor/file_service.js +34 -6
  15. data/app/assets/javascripts/mbeditor/git_service.js +2 -1
  16. data/app/assets/javascripts/mbeditor/history_service.js +177 -0
  17. data/app/assets/javascripts/mbeditor/search_service.js +1 -0
  18. data/app/assets/javascripts/mbeditor/tab_manager.js +8 -5
  19. data/app/assets/stylesheets/mbeditor/application.css +112 -0
  20. data/app/assets/stylesheets/mbeditor/editor.css +443 -78
  21. data/app/channels/mbeditor/editor_channel.rb +5 -41
  22. data/app/controllers/mbeditor/application_controller.rb +8 -1
  23. data/app/controllers/mbeditor/editors_controller.rb +276 -654
  24. data/app/controllers/mbeditor/git_controller.rb +2 -61
  25. data/app/services/mbeditor/availability_probe.rb +83 -0
  26. data/app/services/mbeditor/code_search_service.rb +42 -0
  27. data/app/services/mbeditor/editor_state_service.rb +91 -0
  28. data/app/services/mbeditor/exclusion_matcher.rb +23 -0
  29. data/app/services/mbeditor/file_operation_service.rb +68 -0
  30. data/app/services/mbeditor/file_tree_service.rb +69 -0
  31. data/app/services/mbeditor/git_combined_diff_service.rb +43 -0
  32. data/app/services/mbeditor/git_commit_detail_service.rb +46 -0
  33. data/app/services/mbeditor/git_info_service.rb +151 -0
  34. data/app/services/mbeditor/git_service.rb +36 -26
  35. data/app/services/mbeditor/js_definition_service.rb +59 -0
  36. data/app/services/mbeditor/js_members_service.rb +62 -0
  37. data/app/services/mbeditor/process_runner.rb +48 -0
  38. data/app/services/mbeditor/rails_related_files_service.rb +282 -0
  39. data/app/services/mbeditor/ruby_definition_service.rb +77 -101
  40. data/app/services/mbeditor/schema_service.rb +270 -0
  41. data/app/services/mbeditor/search_replace_service.rb +184 -0
  42. data/app/services/mbeditor/test_runner_service.rb +5 -27
  43. data/app/views/layouts/mbeditor/application.html.erb +2 -2
  44. data/config/routes.rb +8 -1
  45. data/lib/mbeditor/configuration.rb +4 -2
  46. data/lib/mbeditor/version.rb +1 -1
  47. data/public/monaco-editor/vs/language/css/cssMode.js +13 -0
  48. data/public/monaco-editor/vs/language/css/cssWorker.js +77 -0
  49. data/public/monaco-editor/vs/language/html/htmlMode.js +13 -0
  50. data/public/monaco-editor/vs/language/html/htmlWorker.js +454 -0
  51. data/public/monaco-editor/vs/language/json/jsonMode.js +19 -0
  52. data/public/monaco-editor/vs/language/json/jsonWorker.js +42 -0
  53. metadata +26 -3
  54. data/app/services/mbeditor/unused_methods_service.rb +0 -139
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
- require "timeout"
3
+ require_relative "process_runner"
5
4
 
6
5
  module Mbeditor
7
6
  # Shared helpers for running git CLI commands read-only inside a repo.
@@ -15,33 +14,16 @@ module Mbeditor
15
14
  SAFE_GIT_REF = %r{\A[\w./-]+\z}
16
15
 
17
16
  # Run an arbitrary git command inside +repo_path+.
18
- # Returns [stdout, Process::Status]. stderr is captured and discarded to
19
- # prevent git diagnostic messages from leaking into the Rails server log.
17
+ # Returns [stdout, Process::Status]. stderr is discarded to prevent git
18
+ # diagnostic messages from leaking into the Rails server log.
20
19
  # Honors config.git_timeout (seconds) when set.
21
20
  def run_git(repo_path, *args)
22
21
  timeout_secs = Mbeditor.configuration.git_timeout&.to_i
23
- out = +""; timed_out = false; exit_status = nil
24
-
25
- Open3.popen3("git", "-C", repo_path, *args, pgroup: true) do |stdin, stdout, _stderr, wait_thr|
26
- stdin.close
27
-
28
- timer = if timeout_secs && timeout_secs > 0
29
- Thread.new do
30
- sleep timeout_secs
31
- timed_out = true
32
- Process.kill("-KILL", wait_thr.pid)
33
- rescue Errno::ESRCH
34
- nil
35
- end
36
- end
37
-
38
- out = stdout.read
39
- exit_status = wait_thr.value
40
- timer&.kill
41
- end
42
-
43
- raise Timeout::Error, "git timed out after #{timeout_secs}s" if timed_out
44
- [out, exit_status]
22
+ timeout = timeout_secs && timeout_secs > 0 ? timeout_secs : nil
23
+ result = ProcessRunner.call(["git", "-C", repo_path, *args], timeout: timeout)
24
+ [result[:stdout], result[:exit_status]]
25
+ rescue ProcessRunner::TimeoutError
26
+ raise Timeout::Error, "git timed out after #{timeout_secs}s"
45
27
  end
46
28
 
47
29
  # Current branch name, or nil if not in a git repo.
@@ -103,6 +85,34 @@ module Mbeditor
103
85
  [nil, nil]
104
86
  end
105
87
 
88
+ # Parse `git status --porcelain` output.
89
+ # Returns Array of { status: String, path: String }.
90
+ def parse_porcelain_status(output)
91
+ output.lines.filter_map do |line|
92
+ next if line.length < 4
93
+
94
+ path = line[3..].to_s.strip
95
+ next if path.blank?
96
+
97
+ { status: line[0..1].strip, path: path }
98
+ end
99
+ end
100
+
101
+ # Parse `git diff --name-status` output.
102
+ # Returns Array of { status: String, path: String }.
103
+ def parse_name_status(output)
104
+ output.lines.filter_map do |line|
105
+ parts = line.strip.split("\t")
106
+ next if parts.empty?
107
+
108
+ status = parts[0].to_s.strip
109
+ path = parts.last.to_s.strip
110
+ next if path.blank?
111
+
112
+ { status: status, path: path }
113
+ end
114
+ end
115
+
106
116
  # Parse `git diff --numstat` output.
107
117
  # Returns Hash of path => { added: Integer, removed: Integer }.
108
118
  def parse_numstat(output)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Searches JS/JSX/TS/TSX files in the workspace for definitions of a named
5
+ # JavaScript global (variable, function, class, or window property assignment).
6
+ #
7
+ # Delegates subprocess execution to CodeSearchService (rg/grep).
8
+ #
9
+ # Returns an array of hashes:
10
+ # { file: String, line: Integer, snippet: String }
11
+ class JsDefinitionService
12
+ MAX_RESULTS = 20
13
+
14
+ def initialize(symbol, workspace_root)
15
+ @symbol = symbol.to_s
16
+ @workspace_root = workspace_root.to_s.chomp("/")
17
+ end
18
+
19
+ def call
20
+ return [] if @symbol.empty? || @workspace_root.empty?
21
+ return [] unless File.directory?(@workspace_root)
22
+
23
+ lines = CodeSearchService.call(build_pattern, @workspace_root)
24
+ parse_results(lines)
25
+ end
26
+
27
+ private
28
+
29
+ def build_pattern
30
+ s = Regexp.escape(@symbol)
31
+ # Matches the most common JS global-definition forms, anchored so we
32
+ # don't pick up every usage — only assignment / declaration lines.
33
+ # Note: [\s=;,] is avoided inside character classes because BSD grep
34
+ # (macOS) does not support \s inside [...]. Use explicit [ \t=;,] instead.
35
+ "(?:window\\.#{s}\\s*=|\\b(?:var|let|const)\\s+#{s}[ \\t=;,]|\\bfunction\\s+#{s}[ \\t({]|\\bclass\\s+#{s}\\b|\\bexport\\s+(?:default\\s+)?(?:var|let|const|function|class)\\s+#{s}\\b)"
36
+ end
37
+
38
+ def parse_results(lines)
39
+ results = []
40
+ lines.each do |raw|
41
+ raw = raw.chomp
42
+ # ripgrep/grep output: /abs/path/file.js:42: window.ReactWindow = ...
43
+ m = raw.match(/\A(.+?):(\d+):(.+)\z/)
44
+ next unless m
45
+
46
+ abs_path = m[1]
47
+ line_num = m[2].to_i
48
+ snippet = m[3].strip
49
+
50
+ next unless abs_path.start_with?(@workspace_root)
51
+
52
+ rel_path = abs_path.delete_prefix(@workspace_root).delete_prefix("/")
53
+ results << { file: rel_path, line: line_num, snippet: snippet }
54
+ break if results.length >= MAX_RESULTS
55
+ end
56
+ results
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Searches JS/JSX/TS/TSX files for properties/methods attached to a named
5
+ # global object (direct assignment and prototype assignment patterns).
6
+ #
7
+ # Examples matched for symbol "ReactWindow":
8
+ # ReactWindow.open = function() { ... }
9
+ # ReactWindow.prototype.close = function() { ... }
10
+ #
11
+ # Delegates subprocess execution to CodeSearchService (rg/grep).
12
+ #
13
+ # Returns an array of hashes:
14
+ # { name: String, snippet: String }
15
+ class JsMembersService
16
+ MAX_RESULTS = 50
17
+
18
+ def initialize(symbol, workspace_root)
19
+ @symbol = symbol.to_s
20
+ @workspace_root = workspace_root.to_s.chomp("/")
21
+ end
22
+
23
+ def call
24
+ return [] if @symbol.empty? || @workspace_root.empty?
25
+ return [] unless File.directory?(@workspace_root)
26
+
27
+ lines = CodeSearchService.call(build_pattern, @workspace_root)
28
+ parse_results(lines)
29
+ end
30
+
31
+ private
32
+
33
+ def build_pattern
34
+ s = Regexp.escape(@symbol)
35
+ "#{s}\\.(?:prototype\\.)?([a-zA-Z_$][a-zA-Z0-9_$]*)\\s*="
36
+ end
37
+
38
+ def parse_results(lines)
39
+ results = []
40
+ seen = {}
41
+ lines.each do |raw|
42
+ raw = raw.chomp
43
+ m = raw.match(/\A.+?:\d+:(.+)\z/)
44
+ next unless m
45
+
46
+ snippet = m[1].strip
47
+
48
+ s = Regexp.escape(@symbol)
49
+ member_match = snippet.match(/#{s}\.(?:prototype\.)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/)
50
+ next unless member_match
51
+
52
+ name = member_match[1]
53
+ next if seen[name]
54
+
55
+ seen[name] = true
56
+ results << { name: name, snippet: snippet }
57
+ break if results.length >= MAX_RESULTS
58
+ end
59
+ results
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Mbeditor
6
+ module ProcessRunner
7
+ module_function
8
+
9
+ class TimeoutError < StandardError; end
10
+
11
+ def call(cmd, timeout: nil, env: {}, stdin_data: nil, chdir: nil)
12
+ out = +""
13
+ err = +""
14
+ exit_status = nil
15
+ timed_out = false
16
+
17
+ opts = { pgroup: true }
18
+ opts[:chdir] = chdir if chdir
19
+
20
+ Open3.popen3(env, *cmd, **opts) do |stdin, stdout, stderr, wait_thr|
21
+ stdin.write(stdin_data) if stdin_data
22
+ stdin.close
23
+
24
+ timer = if timeout
25
+ Thread.new do
26
+ sleep timeout
27
+ timed_out = true
28
+ Process.kill("-KILL", wait_thr.pid)
29
+ rescue Errno::ESRCH
30
+ nil
31
+ end
32
+ end
33
+
34
+ out_thread = Thread.new { out = stdout.read }
35
+ err_thread = Thread.new { err = stderr.read }
36
+ out_thread.join
37
+ err_thread.join
38
+
39
+ exit_status = wait_thr.value
40
+ timer&.kill
41
+ end
42
+
43
+ raise TimeoutError, "process timed out after #{timeout}s" if timed_out
44
+
45
+ { stdout: out, stderr: err, exit_status: exit_status }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mbeditor
4
+ # Given a workspace-relative file path, finds related Rails files grouped by
5
+ # type: controller, model, views, helper, tests, and custom directories.
6
+ #
7
+ # Only groups with at least one existing file are included in the result.
8
+ # All returned paths are workspace-relative strings.
9
+ module RailsRelatedFilesService
10
+ module_function
11
+
12
+ # Returns a hash of groups. Example:
13
+ #
14
+ # {
15
+ # controller: [{path: "app/controllers/users_controller.rb", name: "users_controller.rb"}],
16
+ # model: [{path: "app/models/user.rb", name: "user.rb"}],
17
+ # views: [{path: "app/views/users/index.html.erb", name: "index.html.erb"}],
18
+ # helper: [{path: "app/helpers/users_helper.rb", name: "users_helper.rb"}],
19
+ # tests: [{path: "test/controllers/users_controller_test.rb", name: "users_controller_test.rb"}],
20
+ # custom: {"app/assets/javascripts/app" => [{path: "...", name: "..."}]}
21
+ # }
22
+ #
23
+ # Returns {} when the path does not match any known Rails convention.
24
+ def find(workspace_root, relative_path, custom_paths: [])
25
+ plural, singular = extract_resource_names(relative_path, custom_paths: custom_paths)
26
+ return {} unless plural && singular
27
+
28
+ result = {}
29
+
30
+ # ── controller ────────────────────────────────────────────────────────────
31
+ controller_path = "app/controllers/#{plural}_controller.rb"
32
+ if file_exists?(workspace_root, controller_path)
33
+ result[:controller] = [entry(controller_path, kind: 'Controller')]
34
+ end
35
+
36
+ # ── model ─────────────────────────────────────────────────────────────────
37
+ model_path = "app/models/#{singular}.rb"
38
+ if file_exists?(workspace_root, model_path)
39
+ result[:model] = [entry(model_path, kind: 'Model')]
40
+ end
41
+
42
+ # ── views ─────────────────────────────────────────────────────────────────
43
+ views_dir = File.join(workspace_root, "app", "views", plural)
44
+ if File.directory?(views_dir)
45
+ children = dir_children(workspace_root, "app/views/#{plural}", kind: 'View')
46
+ result[:views] = children unless children.empty?
47
+ end
48
+
49
+ # ── helper ────────────────────────────────────────────────────────────────
50
+ helper_path = "app/helpers/#{plural}_helper.rb"
51
+ if file_exists?(workspace_root, helper_path)
52
+ result[:helper] = [entry(helper_path, kind: 'Helper')]
53
+ end
54
+
55
+ # ── tests ─────────────────────────────────────────────────────────────────
56
+ test_candidates = [
57
+ "test/controllers/#{plural}_controller_test.rb",
58
+ "test/models/#{singular}_test.rb",
59
+ "spec/controllers/#{plural}_controller_spec.rb",
60
+ "spec/models/#{singular}_spec.rb"
61
+ ]
62
+ tests = test_candidates.select { |p| file_exists?(workspace_root, p) }.map { |p| entry(p, kind: 'Test') }
63
+ result[:tests] = tests unless tests.empty?
64
+
65
+ # ── concerns ──────────────────────────────────────────────────────────────
66
+ plain_plural = plural.split('/').last
67
+ plain_singular = singular.split('/').last
68
+ concern_dirs = ['app/models/concerns', 'app/controllers/concerns']
69
+ concern_files = []
70
+ concern_dirs.each do |dir|
71
+ children = dir_children(workspace_root, dir, kind: 'Concern')
72
+ matching = children.select { |c|
73
+ base = File.basename(c[:path], '.*')
74
+ base == plain_plural || base == plain_singular ||
75
+ base.start_with?("#{plain_plural}_") || base.start_with?("#{plain_singular}_") ||
76
+ base.end_with?("_#{plain_plural}") || base.end_with?("_#{plain_singular}")
77
+ }
78
+ concern_files.concat(matching)
79
+ end
80
+ result[:concerns] = concern_files.uniq { |c| c[:path] } unless concern_files.empty?
81
+
82
+ # ── custom paths ──────────────────────────────────────────────────────────
83
+ custom_result = {}
84
+ Array(custom_paths).each do |base|
85
+ base = base.to_s.strip
86
+ next if base.empty?
87
+
88
+ # Derive a human-readable kind from the last segment of the base path
89
+ custom_kind = base.split('/').last.to_s.tr('_', ' ').split.map(&:capitalize).join(' ')
90
+
91
+ [plural, singular].uniq.each do |name|
92
+ rel_dir = "#{base}/#{name}"
93
+ abs_dir = File.join(workspace_root, rel_dir)
94
+ next unless File.directory?(abs_dir)
95
+
96
+ begin
97
+ real_ws = File.realpath(workspace_root)
98
+ real_dir = File.realpath(abs_dir)
99
+ next unless real_dir.start_with?("#{real_ws}/") || real_dir == real_ws
100
+ rescue Errno::ENOENT, Errno::EACCES
101
+ next
102
+ end
103
+
104
+ children = dir_children(workspace_root, rel_dir, kind: custom_kind)
105
+ next if children.empty?
106
+
107
+ custom_result[base] ||= []
108
+ custom_result[base].concat(children)
109
+ end
110
+ end
111
+ result[:custom] = custom_result unless custom_result.empty?
112
+
113
+ result
114
+ end
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Helpers
118
+ # ---------------------------------------------------------------------------
119
+
120
+ # Returns [plural, singular] workspace-relative resource name strings
121
+ # (may include namespace prefix, e.g. "admin/users", "admin/user"),
122
+ # or nil when the path does not match a known Rails convention.
123
+ def extract_resource_names(relative_path, custom_paths: [])
124
+ parts = relative_path.to_s.split("/")
125
+ return nil unless parts.length >= 2
126
+
127
+ case parts[0]
128
+ when "app"
129
+ case parts[1]
130
+ when "controllers"
131
+ # app/controllers/[namespace/]name_controller.rb
132
+ # also handles app/controllers/concerns/name_concern.rb
133
+ if parts[2] == "concerns"
134
+ return nil unless parts.length >= 4 && parts.last.end_with?(".rb")
135
+ file_base = parts.last.delete_suffix(".rb")
136
+ singular = file_base.sub(/_concern$/, '').sub(/^concern_/, '')
137
+ plural = pluralize(singular)
138
+ return [plural, singular]
139
+ end
140
+
141
+ return nil unless parts.last.end_with?("_controller.rb")
142
+
143
+ ns_and_file = parts[2..]
144
+ file = ns_and_file.last
145
+ ns = ns_and_file[0..-2] # may be empty
146
+ base = file.delete_suffix("_controller.rb")
147
+ plural = (ns + [base]).join("/")
148
+ singular = (ns + [singularize(base)]).join("/")
149
+ [plural, singular]
150
+
151
+ when "models"
152
+ # app/models/[namespace/]name.rb
153
+ # also handles app/models/concerns/name_concern.rb
154
+ if parts[2] == "concerns"
155
+ return nil unless parts.length >= 4 && parts.last.end_with?(".rb")
156
+ file_base = parts.last.delete_suffix(".rb")
157
+ singular = file_base.sub(/_concern$/, '').sub(/^concern_/, '')
158
+ plural = pluralize(singular)
159
+ return [plural, singular]
160
+ end
161
+
162
+ return nil unless parts.last.end_with?(".rb")
163
+
164
+ ns_and_file = parts[2..]
165
+ file = ns_and_file.last.delete_suffix(".rb")
166
+ ns = ns_and_file[0..-2]
167
+ singular = (ns + [file]).join("/")
168
+ plural = (ns + [pluralize(file)]).join("/")
169
+ [plural, singular]
170
+
171
+ when "views"
172
+ # app/views/[namespace/]resource/anything
173
+ return nil unless parts.length >= 4
174
+
175
+ # All path segments between "views" and the view filename form the resource path.
176
+ # e.g. app/views/admin/users/index.html.erb → view_resource = ["admin","users"]
177
+ # e.g. app/views/users/index.html.erb → view_resource = ["users"]
178
+ view_resource = parts[2..parts.length - 2]
179
+ plural = view_resource.join("/")
180
+ singular = (view_resource[0..-2] + [singularize(view_resource.last)]).join("/")
181
+ [plural, singular]
182
+
183
+ when "helpers"
184
+ # app/helpers/[namespace/]name_helper.rb
185
+ return nil unless parts.last.end_with?("_helper.rb")
186
+
187
+ ns_and_file = parts[2..]
188
+ file = ns_and_file.last
189
+ ns = ns_and_file[0..-2]
190
+ base = file.delete_suffix("_helper.rb")
191
+ plural = (ns + [base]).join("/")
192
+ singular = (ns + [singularize(base)]).join("/")
193
+ [plural, singular]
194
+
195
+ else
196
+ nil
197
+ end
198
+
199
+ when "test", "spec"
200
+ framework = parts[0]
201
+ case parts[1]
202
+ when "controllers"
203
+ # test/controllers/[ns/]name_controller_test.rb
204
+ suffix = framework == "test" ? "_controller_test.rb" : "_controller_spec.rb"
205
+ return nil unless parts.last.end_with?(suffix)
206
+
207
+ ns_and_file = parts[2..]
208
+ file = ns_and_file.last
209
+ ns = ns_and_file[0..-2]
210
+ base = file.delete_suffix(suffix)
211
+ plural = (ns + [base]).join("/")
212
+ singular = (ns + [singularize(base)]).join("/")
213
+ [plural, singular]
214
+
215
+ when "models"
216
+ # test/models/[ns/]name_test.rb
217
+ suffix = framework == "test" ? "_test.rb" : "_spec.rb"
218
+ return nil unless parts.last.end_with?(suffix)
219
+
220
+ ns_and_file = parts[2..]
221
+ file = ns_and_file.last
222
+ ns = ns_and_file[0..-2]
223
+ base = file.delete_suffix(suffix)
224
+ singular = (ns + [base]).join("/")
225
+ plural = (ns + [pluralize(base)]).join("/")
226
+ [plural, singular]
227
+
228
+ else
229
+ nil
230
+ end
231
+
232
+ else
233
+ # Custom path fallback — must be last
234
+ Array(custom_paths).each do |base|
235
+ base = base.to_s.strip
236
+ next if base.empty?
237
+ next unless relative_path.start_with?("#{base}/")
238
+ rest = relative_path.delete_prefix("#{base}/")
239
+ resource = rest.split('/').first.to_s.sub(/\.[^.]+$/, '') # first path segment, no extension
240
+ next if resource.empty?
241
+ return [pluralize(resource), singularize(resource)]
242
+ end
243
+ nil
244
+ end
245
+ end
246
+
247
+ def entry(rel_path, kind: nil)
248
+ { path: rel_path, name: File.basename(rel_path), type: :file, kind: kind }
249
+ end
250
+
251
+ def file_exists?(workspace_root, rel_path)
252
+ File.exist?(File.join(workspace_root, rel_path))
253
+ end
254
+
255
+ # Returns direct file children (not subdirectories) of a workspace-relative
256
+ # directory, sorted by name, as {path:, name:, type: :file, kind:} hashes.
257
+ def dir_children(workspace_root, rel_dir, kind: nil)
258
+ abs_dir = File.join(workspace_root, rel_dir)
259
+ return [] unless File.directory?(abs_dir)
260
+
261
+ Dir.entries(abs_dir)
262
+ .reject { |n| n.start_with?(".") }
263
+ .sort
264
+ .filter_map do |name|
265
+ full = File.join(abs_dir, name)
266
+ next unless File.file?(full)
267
+
268
+ { path: "#{rel_dir}/#{name}", name: name, type: :file, kind: kind }
269
+ end
270
+ end
271
+
272
+ def singularize(word)
273
+ ActiveSupport::Inflector.singularize(word)
274
+ end
275
+
276
+ def pluralize(word)
277
+ ActiveSupport::Inflector.pluralize(word)
278
+ end
279
+
280
+ private_class_method :entry, :file_exists?, :dir_children, :singularize, :pluralize, :extract_resource_names
281
+ end
282
+ end