plumbo 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +75 -0
- data/lib/plumbo/collector.rb +126 -0
- data/lib/plumbo/configuration.rb +55 -0
- data/lib/plumbo/middleware.rb +102 -0
- data/lib/plumbo/panel.rb +396 -0
- data/lib/plumbo/railtie.rb +14 -0
- data/lib/plumbo/stimulus.rb +65 -0
- data/lib/plumbo/version.rb +5 -0
- data/lib/plumbo.rb +31 -0
- metadata +113 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e37ee057af091dc00bb62836449ce4cd80c903ca6153507c68825efbf589a4c4
|
|
4
|
+
data.tar.gz: 773e4b2b5def7b84992e3fa0a09517c619f39f1dd8548c3c1d587d8597289c50
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 86bd0dccd19d772f0c31e313d3858ed9a9b923f6fd66bf18908a9a6614d307c2adb3825d632bbb51168bd398cdcec275dce7abf00d6cd894d2c2f71503e082a2
|
|
7
|
+
data.tar.gz: eee1962f2bff1cb3f1a6a02921c4e2d4d1f1285b43e952dcde62218c8018c8b380ad2321a1a78c9ec9b7342bc057342082748a65b79f89f816957fd5ad376ecf
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt Sears
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Plumbo
|
|
2
|
+
|
|
3
|
+
A zero-config development panel that lists every file behind the page you're
|
|
4
|
+
looking at — controller, helper, layout, templates, partials, and the Stimulus
|
|
5
|
+
controllers in use — so you can copy their paths straight into an AI assistant.
|
|
6
|
+
|
|
7
|
+
Paths copy `@`-prefixed (e.g. `@app/views/posts/index.html.erb`), ready to paste
|
|
8
|
+
as file mentions. Plumbo is **self-contained**: a Rack middleware injects its own
|
|
9
|
+
HTML, CSS, and JavaScript — nothing is added to your asset pipeline, and there are
|
|
10
|
+
no view, layout, or bundler changes. Drop it into any Rails app.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
# Gemfile
|
|
16
|
+
group :development do
|
|
17
|
+
gem "plumbo"
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bundle install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That's it. Boot your app in development and a badge appears in the bottom-right
|
|
26
|
+
showing how many files rendered the current page.
|
|
27
|
+
|
|
28
|
+
## Using the panel
|
|
29
|
+
|
|
30
|
+
- **Click the badge** to open the list — a tree, in render order, with partials
|
|
31
|
+
and Stimulus controllers nested (and color-coded) under their parent.
|
|
32
|
+
- **Collapse or expand** a parent by clicking its row; **copy** a single path with
|
|
33
|
+
its copy icon, or **Copy All** for the whole (filtered) list.
|
|
34
|
+
- **Filter** by typing in the search box, or click a type chip — Controllers,
|
|
35
|
+
Views, Partials, Stimulus, … — to show just one kind.
|
|
36
|
+
- **Clear All** empties the list so you can watch fresh files appear as you click
|
|
37
|
+
around. The list keeps up with Turbo (Drive, Frames, and Streams) without a
|
|
38
|
+
full page reload.
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
Defaults are dev-only and need no setup. To override, add an initializer:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# config/initializers/plumbo.rb
|
|
46
|
+
Plumbo.configure do |c|
|
|
47
|
+
c.enabled = Rails.env.development? # default: true only in development
|
|
48
|
+
c.path_prefix = "@" # default "@"; set "" for bare paths
|
|
49
|
+
c.max_files = 500 # safety cap on listed files
|
|
50
|
+
c.include_stimulus = true # list Stimulus controllers
|
|
51
|
+
c.javascript_root = "app/javascript" # source dir Stimulus paths map into
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
A Railtie inserts a Rack middleware that subscribes to ActionView's render
|
|
58
|
+
notifications for each request, collecting every file under your app root in call
|
|
59
|
+
order. It also scans rendered templates for `data-controller` attributes and maps
|
|
60
|
+
each to its Stimulus source file. The panel is injected into full HTML pages, and
|
|
61
|
+
every response carries an `X-Plumbo-Files` header that the panel reads on each
|
|
62
|
+
`fetch` to stay current across Turbo navigations.
|
|
63
|
+
|
|
64
|
+
> Only Stimulus controllers written as `data-controller` in a rendered `.erb` are
|
|
65
|
+
> detected — those emitted by helpers or ViewComponents, and other JavaScript,
|
|
66
|
+
> aren't listed.
|
|
67
|
+
|
|
68
|
+
## Notes
|
|
69
|
+
|
|
70
|
+
- Production-safe: disabled outside development by default.
|
|
71
|
+
- Icons from [Lucide](https://lucide.dev) (ISC license).
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/isolated_execution_state"
|
|
4
|
+
require "active_support/notifications"
|
|
5
|
+
require "active_support/core_ext/string/inflections"
|
|
6
|
+
|
|
7
|
+
module Plumbo
|
|
8
|
+
# Subscribes to ActionView/ActionController notifications for the duration of a
|
|
9
|
+
# single request and gathers every project file that took part in rendering.
|
|
10
|
+
# Render events are recorded with the timestamp at which each render began, so
|
|
11
|
+
# #files can return them in true call order (a template before the partials it
|
|
12
|
+
# renders) rather than the inner-to-outer order in which the events fire on
|
|
13
|
+
# completion. Subscriptions are scoped to the calling thread so concurrent
|
|
14
|
+
# requests on a threaded dev server don't bleed into one another.
|
|
15
|
+
class Collector
|
|
16
|
+
RENDER_EVENTS = %w[
|
|
17
|
+
render_template.action_view
|
|
18
|
+
render_partial.action_view
|
|
19
|
+
render_layout.action_view
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
CONTROLLER_EVENT = "process_action.action_controller"
|
|
23
|
+
|
|
24
|
+
def initialize(config = Plumbo.config)
|
|
25
|
+
@config = config
|
|
26
|
+
@leading = [] # controller + helper, forced to the top of the list
|
|
27
|
+
@renders = [] # [start_time, prefixed_path] for each template/partial
|
|
28
|
+
@root_prefix = File.join(@config.root, "")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Runs the block with subscriptions active and returns whatever it returns
|
|
32
|
+
# (the downstream Rack response triple). Render and controller events go to
|
|
33
|
+
# separate handlers so neither has to branch on the event name.
|
|
34
|
+
def collect(&block)
|
|
35
|
+
@thread = Thread.current
|
|
36
|
+
with_subscriptions(RENDER_EVENTS, method(:on_render)) do
|
|
37
|
+
with_subscriptions([CONTROLLER_EVENT], method(:on_controller), &block)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Project files as [path, depth] pairs in call order: controller and helper
|
|
42
|
+
# first (depth 0), then every template/partial sorted by when its render
|
|
43
|
+
# began, so a template precedes the partials it renders. Each render's depth
|
|
44
|
+
# is how many other renders enclose it, giving a parent/child nesting.
|
|
45
|
+
# Deduped by path (first occurrence wins) and capped at max_files.
|
|
46
|
+
def files
|
|
47
|
+
entries = leading_entries + render_entries
|
|
48
|
+
entries.uniq { |path, _depth| path }.first(@config.max_files)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def leading_entries
|
|
54
|
+
@leading.map { |path| [path, 0] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Renders in call order (by start time), each tagged with its nesting depth.
|
|
58
|
+
def render_entries
|
|
59
|
+
@renders.sort_by { |start, _finish, _path| start }
|
|
60
|
+
.map { |start, finish, path| [path, depth_of(start, finish)] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Depth is the number of other render intervals that strictly enclose this
|
|
64
|
+
# one — renders nest cleanly because view rendering is synchronous.
|
|
65
|
+
# :reek:FeatureEnvy — an interval-containment comparison over the bounds.
|
|
66
|
+
def depth_of(start, finish)
|
|
67
|
+
@renders.count do |other_start, other_finish, _path|
|
|
68
|
+
other_start <= start && other_finish >= finish &&
|
|
69
|
+
(other_start < start || other_finish > finish)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Nest ActiveSupport::Notifications.subscribed blocks so the subscriptions
|
|
74
|
+
# are active only for the duration of the request, then torn down. Each
|
|
75
|
+
# handler takes a single Event (arity 1), which ActiveSupport supplies.
|
|
76
|
+
def with_subscriptions(events, handler, &block)
|
|
77
|
+
return block.call if events.empty?
|
|
78
|
+
|
|
79
|
+
event, *rest = events
|
|
80
|
+
ActiveSupport::Notifications.subscribed(handler, event) do
|
|
81
|
+
with_subscriptions(rest, handler, &block)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def on_render(event)
|
|
86
|
+
return unless current_thread?
|
|
87
|
+
|
|
88
|
+
identifier = event.payload[:identifier]
|
|
89
|
+
return unless identifier&.start_with?(@root_prefix)
|
|
90
|
+
|
|
91
|
+
@renders << [event.time, event.end, prefixed(identifier.delete_prefix(@root_prefix))]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def on_controller(event)
|
|
95
|
+
return unless current_thread?
|
|
96
|
+
|
|
97
|
+
record_controller(event.payload[:controller])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Subscriptions are thread-scoped so concurrent requests don't bleed in.
|
|
101
|
+
def current_thread?
|
|
102
|
+
Thread.current.equal?(@thread)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# process_action fires after rendering; build the leading pair controller-
|
|
106
|
+
# first by adding the helper, then unshifting the controller ahead of it.
|
|
107
|
+
def record_controller(controller_name)
|
|
108
|
+
return unless controller_name
|
|
109
|
+
|
|
110
|
+
path = controller_name.sub(/Controller\z/, "").underscore
|
|
111
|
+
add_leading("app/helpers/#{path}_helper.rb")
|
|
112
|
+
add_leading("app/controllers/#{path}_controller.rb")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def add_leading(relative)
|
|
116
|
+
return unless File.exist?(File.join(@config.root, relative))
|
|
117
|
+
|
|
118
|
+
entry = prefixed(relative)
|
|
119
|
+
@leading.unshift(entry) unless @leading.include?(entry)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def prefixed(relative)
|
|
123
|
+
"#{@config.path_prefix}#{relative}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plumbo
|
|
4
|
+
# Runtime configuration for the panel. Defaults are dev-only and safe; override
|
|
5
|
+
# in an initializer via Plumbo.configure { |c| ... }.
|
|
6
|
+
class Configuration
|
|
7
|
+
# Whether the panel is injected. Defaults to true only in Rails development.
|
|
8
|
+
attr_accessor :enabled
|
|
9
|
+
|
|
10
|
+
# Hard cap on the number of files listed (guards against runaway pages).
|
|
11
|
+
attr_accessor :max_files
|
|
12
|
+
|
|
13
|
+
# Prefix added to each listed path. "@" makes the list paste-ready as file
|
|
14
|
+
# mentions for an AI assistant; set to "" for bare paths.
|
|
15
|
+
attr_accessor :path_prefix
|
|
16
|
+
|
|
17
|
+
# Whether to list Stimulus controllers found via data-controller attributes
|
|
18
|
+
# in the rendered HTML. Defaults to true.
|
|
19
|
+
attr_accessor :include_stimulus
|
|
20
|
+
|
|
21
|
+
# Source directory Stimulus controllers are mapped into. Combined with the
|
|
22
|
+
# "controllers/" subdirectory and the Stimulus identifier to form the path.
|
|
23
|
+
attr_accessor :javascript_root
|
|
24
|
+
|
|
25
|
+
attr_writer :root
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@enabled = rails_development?
|
|
29
|
+
@max_files = 500
|
|
30
|
+
@path_prefix = "@"
|
|
31
|
+
@include_stimulus = true
|
|
32
|
+
@javascript_root = "app/javascript"
|
|
33
|
+
@root = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Project root, used to (a) filter render events down to app files and
|
|
37
|
+
# (b) make listed paths relative. Defaults to Rails.root, then the cwd.
|
|
38
|
+
def root
|
|
39
|
+
@root ||= rails_root || Dir.pwd
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def rails_development?
|
|
45
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rails_root
|
|
49
|
+
return unless defined?(Rails) && Rails.respond_to?(:root)
|
|
50
|
+
|
|
51
|
+
root = Rails.root
|
|
52
|
+
root&.to_s
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Plumbo
|
|
6
|
+
# Rack middleware that, when enabled (development by default), watches a
|
|
7
|
+
# request's render events and adds the Plumbo panel to the response.
|
|
8
|
+
#
|
|
9
|
+
# Every response that rendered app files carries an X-Plumbo-Files header (the
|
|
10
|
+
# file list, with Stimulus controllers nested in). Full HTML pages also get the
|
|
11
|
+
# panel injected before </body> for the initial render. The client reads the
|
|
12
|
+
# header on every fetch — so Turbo Drive, Frame, and Stream navigations, and
|
|
13
|
+
# even custom fetch-based panes, all refresh the panel without a full reload.
|
|
14
|
+
# Requests that rendered nothing pass through untouched. Whether/how to rewrite
|
|
15
|
+
# the body lives in Injection so its checks read request state.
|
|
16
|
+
class Middleware
|
|
17
|
+
HEADER = "X-Plumbo-Files"
|
|
18
|
+
|
|
19
|
+
def initialize(app, config = nil)
|
|
20
|
+
@app = app
|
|
21
|
+
@config = config
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# :reek:DuplicateMethodCall — the disabled passthrough and the instrumented
|
|
25
|
+
# path are intentionally distinct calls into the downstream app.
|
|
26
|
+
def call(env)
|
|
27
|
+
return @app.call(env) unless config.enabled
|
|
28
|
+
|
|
29
|
+
collector = Collector.new(config)
|
|
30
|
+
triple = collector.collect { @app.call(env) }
|
|
31
|
+
Injection.new(triple, panel_files(collector.files)).apply
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Read at call time so host config set in an initializer is honored.
|
|
37
|
+
def config
|
|
38
|
+
@config || Plumbo.config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The render files with each template/partial's Stimulus controllers nested
|
|
42
|
+
# underneath, deduped by path (first occurrence wins) and capped.
|
|
43
|
+
def panel_files(files)
|
|
44
|
+
conf = config
|
|
45
|
+
Stimulus.nest(files, conf).uniq { |path, _depth| path }.first(conf.max_files)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Adds the panel to a single response: an X-Plumbo-Files header on anything
|
|
49
|
+
# with files, plus the injected panel markup for full HTML pages. Holds the
|
|
50
|
+
# response triple and files as instance state.
|
|
51
|
+
class Injection
|
|
52
|
+
def initialize(triple, files)
|
|
53
|
+
@status, @headers, @response = triple
|
|
54
|
+
@files = files
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The final Rack triple, with the header and (for full HTML) the panel.
|
|
58
|
+
def apply
|
|
59
|
+
passthrough = [@status, @headers, @response]
|
|
60
|
+
return passthrough if @files.empty?
|
|
61
|
+
|
|
62
|
+
@headers[HEADER] = header_value
|
|
63
|
+
return passthrough unless html?
|
|
64
|
+
|
|
65
|
+
body = rewrite
|
|
66
|
+
@headers["Content-Length"] = body.bytesize.to_s
|
|
67
|
+
[@status, @headers, [body]]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# The file list as compact Base64-encoded JSON ([path, depth, category]
|
|
73
|
+
# triples) for the X-Plumbo-Files header the client reads to refresh.
|
|
74
|
+
def header_value
|
|
75
|
+
data = @files.map { |path, depth| [path, depth, Panel.category(path)] }
|
|
76
|
+
[JSON.generate(data)].pack("m0")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def html?
|
|
80
|
+
content_type.include?("text/html")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The body with the panel injected before </body>, or unchanged when there
|
|
84
|
+
# is no </body> (e.g. a Turbo Frame fragment — the client still refreshes
|
|
85
|
+
# from the header).
|
|
86
|
+
# :reek:ManualDispatch — Rack bodies only optionally respond to #close.
|
|
87
|
+
# :reek:FeatureEnvy — assembling the response body operates on the buffer.
|
|
88
|
+
def rewrite
|
|
89
|
+
body = +""
|
|
90
|
+
@response.each { |part| body << part }
|
|
91
|
+
@response.close if @response.respond_to?(:close)
|
|
92
|
+
|
|
93
|
+
marker = body.rindex("</body>")
|
|
94
|
+
marker ? body.dup.insert(marker, Panel.render(@files)) : body
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def content_type
|
|
98
|
+
(@headers["Content-Type"] || @headers["content-type"]).to_s
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/plumbo/panel.rb
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Plumbo
|
|
6
|
+
# Builds the self-contained panel markup injected into the page: a scoped
|
|
7
|
+
# <style> block, inline SVG icons, server-rendered file rows, and a vanilla-JS
|
|
8
|
+
# <script>. Everything is namespaced under #plumbo so it can't clash with or
|
|
9
|
+
# leak into the host app's CSS/JS. (Port of the original Tailwind partial +
|
|
10
|
+
# Stimulus controller.)
|
|
11
|
+
module Panel
|
|
12
|
+
# Inline SVGs from Lucide (https://lucide.dev), ISC-licensed. UI icons (:file,
|
|
13
|
+
# :x, :copy) plus per-type row icons keyed by the symbol #category returns.
|
|
14
|
+
# :file doubles as the catch-all row icon.
|
|
15
|
+
ICONS = {
|
|
16
|
+
file: <<~SVG,
|
|
17
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h14a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v4"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m9 18 3-3-3-3"/><path d="m5 12-3 3 3 3"/></svg>
|
|
18
|
+
SVG
|
|
19
|
+
x: <<~SVG,
|
|
20
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
21
|
+
SVG
|
|
22
|
+
copy: <<~SVG,
|
|
23
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
|
24
|
+
SVG
|
|
25
|
+
controller: <<~SVG,
|
|
26
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="19" r="3"/><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/></svg>
|
|
27
|
+
SVG
|
|
28
|
+
helper: <<~SVG,
|
|
29
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
|
30
|
+
SVG
|
|
31
|
+
layout: <<~SVG,
|
|
32
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
|
|
33
|
+
SVG
|
|
34
|
+
partial: <<~SVG,
|
|
35
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.5 11.5 19 8l-3.5-3.5"/><path d="M8.5 12.5 5 16l3.5 3.5"/><path d="m14 4-4 16"/></svg>
|
|
36
|
+
SVG
|
|
37
|
+
view: <<~SVG,
|
|
38
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
|
39
|
+
SVG
|
|
40
|
+
ruby: <<~SVG,
|
|
41
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/></svg>
|
|
42
|
+
SVG
|
|
43
|
+
javascript: <<~SVG,
|
|
44
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg>
|
|
45
|
+
SVG
|
|
46
|
+
copy_all: <<~SVG,
|
|
47
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
|
48
|
+
SVG
|
|
49
|
+
clear: <<~SVG,
|
|
50
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
|
|
51
|
+
SVG
|
|
52
|
+
check: <<~SVG,
|
|
53
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
|
54
|
+
SVG
|
|
55
|
+
chevron: <<~SVG
|
|
56
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
|
|
57
|
+
SVG
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
# Path patterns mapped to icon keys, matched in order. Partials are listed
|
|
61
|
+
# before layouts so a `_partial` living in app/views/layouts still reads as a
|
|
62
|
+
# partial. The first match wins; #category falls back to :file.
|
|
63
|
+
CATEGORY_RULES = [
|
|
64
|
+
[%r{/controllers/.*\.rb\z}, :controller],
|
|
65
|
+
[%r{/helpers/.*\.rb\z}, :helper],
|
|
66
|
+
[%r{/_[^/]+\.erb\z}, :partial],
|
|
67
|
+
[%r{/layouts/}, :layout],
|
|
68
|
+
[/\.erb\z/, :view],
|
|
69
|
+
[/\.js\z/, :javascript],
|
|
70
|
+
[/\.rb\z/, :ruby]
|
|
71
|
+
].freeze
|
|
72
|
+
|
|
73
|
+
CSS = <<~CSS
|
|
74
|
+
#plumbo{position:fixed;z-index:2147483000;bottom:1rem;right:1rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.4;text-align:left}
|
|
75
|
+
#plumbo *{box-sizing:border-box}
|
|
76
|
+
#plumbo .plumbo-toggle{display:flex;align-items:center;gap:6px;background:#111827;color:#d1d5db;border:1px solid rgba(255,255,255,.1);border-radius:9999px;padding:8px 12px;cursor:pointer;box-shadow:0 10px 15px -3px rgba(0,0,0,.35)}
|
|
77
|
+
#plumbo .plumbo-toggle:hover{background:#1f2937;color:#fff}
|
|
78
|
+
#plumbo .plumbo-panel{position:absolute;bottom:3rem;right:0;width:34rem;max-width:90vw;height:70vh;max-height:90vh;background:#111827;color:#d1d5db;border:1px solid rgba(255,255,255,.1);border-radius:8px;box-shadow:0 25px 50px -12px rgba(0,0,0,.5);display:flex;flex-direction:column;overflow:hidden}
|
|
79
|
+
#plumbo .plumbo-panel[hidden]{display:none}
|
|
80
|
+
#plumbo .plumbo-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid rgba(255,255,255,.1)}
|
|
81
|
+
#plumbo .plumbo-title{font-weight:600;color:#fff;font-size:13px}
|
|
82
|
+
#plumbo .plumbo-actions{display:flex;align-items:center;gap:8px}
|
|
83
|
+
#plumbo button{font:inherit;cursor:pointer;background:transparent;border:0;color:inherit;margin:0}
|
|
84
|
+
#plumbo .plumbo-action{display:flex;align-items:center;color:#9ca3af;padding:4px;border-radius:4px}
|
|
85
|
+
#plumbo .plumbo-action:hover{background:rgba(255,255,255,.1);color:#fff}
|
|
86
|
+
#plumbo .plumbo-close{color:#6b7280;display:flex;padding:2px}
|
|
87
|
+
#plumbo .plumbo-close:hover{color:#fff}
|
|
88
|
+
#plumbo .plumbo-filterbar{display:flex;flex-direction:column;gap:8px;padding:10px 16px;border-bottom:1px solid rgba(255,255,255,.1)}
|
|
89
|
+
#plumbo .plumbo-filter{width:100%;font:inherit;color:#e5e7eb;background:#0b1220;border:1px solid rgba(255,255,255,.1);border-radius:6px;padding:6px 10px}
|
|
90
|
+
#plumbo .plumbo-filter::placeholder{color:#6b7280}
|
|
91
|
+
#plumbo .plumbo-filter:focus{outline:none;border-color:#3b82f6}
|
|
92
|
+
#plumbo .plumbo-chips{display:flex;flex-wrap:nowrap;gap:5px;overflow-x:auto;scrollbar-width:none}
|
|
93
|
+
#plumbo .plumbo-chips::-webkit-scrollbar{display:none}
|
|
94
|
+
#plumbo .plumbo-chip{flex:none;white-space:nowrap;color:#9ca3af;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-radius:9999px;padding:2px 8px;font-size:10px}
|
|
95
|
+
#plumbo .plumbo-chip:hover{background:rgba(255,255,255,.1);color:#fff}
|
|
96
|
+
#plumbo .plumbo-chip[aria-pressed="true"]{background:#2563eb;border-color:#2563eb;color:#fff}
|
|
97
|
+
#plumbo .plumbo-list{flex:1;min-height:0;list-style:none;margin:0;padding:0;overflow-y:auto;background:#0b1220}
|
|
98
|
+
#plumbo .plumbo-row{--d:0;display:flex;align-items:center;gap:8px;width:100%;padding:7px 16px;padding-left:calc(16px + var(--d) * 14px);color:#d1d5db;text-align:left;cursor:default;background-image:repeating-linear-gradient(to right,rgba(255,255,255,.09) 0,rgba(255,255,255,.09) 1px,transparent 1px,transparent 14px);background-repeat:no-repeat;background-position:20px 0;background-size:calc(var(--d) * 14px) 100%}
|
|
99
|
+
#plumbo .plumbo-row:hover{background-color:rgba(255,255,255,.05);color:#fff}
|
|
100
|
+
#plumbo .plumbo-row.plumbo-parent{cursor:pointer}
|
|
101
|
+
#plumbo .plumbo-caret{flex:none;width:12px;display:flex;color:#6b7280}
|
|
102
|
+
#plumbo .plumbo-caret svg{visibility:hidden;transition:transform .12s}
|
|
103
|
+
#plumbo .plumbo-row.plumbo-parent .plumbo-caret svg{visibility:visible;transform:rotate(90deg)}
|
|
104
|
+
#plumbo .plumbo-row.plumbo-parent.plumbo-collapsed .plumbo-caret svg{transform:rotate(0)}
|
|
105
|
+
#plumbo .plumbo-row.plumbo-parent:hover .plumbo-caret{color:#fff}
|
|
106
|
+
#plumbo .plumbo-type{flex:none;display:flex;color:#6b7280}
|
|
107
|
+
#plumbo .plumbo-row:hover .plumbo-type{color:#9ca3af}
|
|
108
|
+
#plumbo .plumbo-path{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
109
|
+
#plumbo .plumbo-copy{margin-left:auto;flex:none;color:#4b5563;display:flex;cursor:pointer}
|
|
110
|
+
#plumbo .plumbo-row:hover .plumbo-copy{color:#9ca3af}
|
|
111
|
+
#plumbo .plumbo-row[data-depth="1"] .plumbo-path{color:#93c5fd}
|
|
112
|
+
#plumbo .plumbo-row[data-depth="1"] .plumbo-type{color:#60a5fa}
|
|
113
|
+
#plumbo .plumbo-row[data-depth="2"] .plumbo-path{color:#c4b5fd}
|
|
114
|
+
#plumbo .plumbo-row[data-depth="2"] .plumbo-type{color:#a78bfa}
|
|
115
|
+
#plumbo .plumbo-row[data-depth="3"] .plumbo-path{color:#6ee7b7}
|
|
116
|
+
#plumbo .plumbo-row[data-depth="3"] .plumbo-type{color:#34d399}
|
|
117
|
+
#plumbo .plumbo-row[data-depth="4"] .plumbo-path{color:#fcd34d}
|
|
118
|
+
#plumbo .plumbo-row[data-depth="4"] .plumbo-type{color:#fbbf24}
|
|
119
|
+
#plumbo .plumbo-row[data-depth="5"] .plumbo-path{color:#f9a8d4}
|
|
120
|
+
#plumbo .plumbo-row[data-depth="5"] .plumbo-type{color:#f472b6}
|
|
121
|
+
#plumbo .plumbo-flash{color:#4ade80}
|
|
122
|
+
#plumbo svg{display:block}
|
|
123
|
+
CSS
|
|
124
|
+
|
|
125
|
+
JS = <<~JS.freeze
|
|
126
|
+
(function(){
|
|
127
|
+
if (window.__plumboBound) return;
|
|
128
|
+
window.__plumboBound = true;
|
|
129
|
+
var query = "";
|
|
130
|
+
var activeCategory = null;
|
|
131
|
+
var LABELS = { controller:"Controllers", helper:"Helpers", layout:"Layouts", partial:"Partials", view:"Views", javascript:"JavaScript", ruby:"Ruby", file:"Other" };
|
|
132
|
+
var CHECK = '#{ICONS[:check].strip}';
|
|
133
|
+
function copy(text){ if (navigator.clipboard) { navigator.clipboard.writeText(text); } }
|
|
134
|
+
// Briefly swap an icon element's contents for a green check as feedback,
|
|
135
|
+
// leaving the surrounding row (filename, etc.) untouched.
|
|
136
|
+
function flashIcon(el){
|
|
137
|
+
if (!el) return;
|
|
138
|
+
var original = el.innerHTML;
|
|
139
|
+
el.innerHTML = CHECK;
|
|
140
|
+
el.classList.add("plumbo-flash");
|
|
141
|
+
setTimeout(function(){ el.innerHTML = original; el.classList.remove("plumbo-flash"); }, 1200);
|
|
142
|
+
}
|
|
143
|
+
document.addEventListener("click", function(event){
|
|
144
|
+
var root = document.getElementById("plumbo");
|
|
145
|
+
if (!root) return;
|
|
146
|
+
var hit = event.target.closest("[data-plumbo-toggle],[data-plumbo-close],[data-plumbo-collapse],[data-plumbo-copy],[data-plumbo-copy-all],[data-plumbo-clear],[data-plumbo-chip]");
|
|
147
|
+
if (!hit || !root.contains(hit)) return;
|
|
148
|
+
var panel = root.querySelector("[data-plumbo-panel]");
|
|
149
|
+
if (hit.hasAttribute("data-plumbo-toggle")) { panel.hidden = !panel.hidden; }
|
|
150
|
+
else if (hit.hasAttribute("data-plumbo-close")) { panel.hidden = true; }
|
|
151
|
+
else if (hit.hasAttribute("data-plumbo-collapse")) {
|
|
152
|
+
var parentRow = hit.closest(".plumbo-row");
|
|
153
|
+
if (parentRow && parentRow.classList.contains("plumbo-parent")) {
|
|
154
|
+
parentRow.classList.toggle("plumbo-collapsed"); applyFilter();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (hit.hasAttribute("data-plumbo-copy")) {
|
|
158
|
+
var copyRow = hit.closest(".plumbo-row");
|
|
159
|
+
if (copyRow) { copy(copyRow.getAttribute("data-path")); flashIcon(hit); }
|
|
160
|
+
}
|
|
161
|
+
else if (hit.hasAttribute("data-plumbo-copy-all")) {
|
|
162
|
+
copy(visiblePaths(root).join("\\n")); flashIcon(hit);
|
|
163
|
+
}
|
|
164
|
+
else if (hit.hasAttribute("data-plumbo-clear")) {
|
|
165
|
+
var emptied = root.querySelector("#plumbo-list");
|
|
166
|
+
if (emptied) { emptied.innerHTML = ""; refresh(); }
|
|
167
|
+
}
|
|
168
|
+
else if (hit.hasAttribute("data-plumbo-chip")) {
|
|
169
|
+
var cat = hit.getAttribute("data-category");
|
|
170
|
+
activeCategory = !cat ? null : (activeCategory === cat ? null : cat);
|
|
171
|
+
refresh();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
document.addEventListener("input", function(event){
|
|
175
|
+
var root = document.getElementById("plumbo");
|
|
176
|
+
var el = event.target;
|
|
177
|
+
if (!root || !el.hasAttribute || !el.hasAttribute("data-plumbo-filter") || !root.contains(el)) return;
|
|
178
|
+
query = el.value || "";
|
|
179
|
+
applyFilter();
|
|
180
|
+
});
|
|
181
|
+
// The data-paths of rows currently visible (after filtering), for Copy All.
|
|
182
|
+
function visiblePaths(root){
|
|
183
|
+
var paths = [];
|
|
184
|
+
var rows = root.querySelectorAll("#plumbo-list > li");
|
|
185
|
+
for (var i = 0; i < rows.length; i++){
|
|
186
|
+
if (rows[i].hidden) continue;
|
|
187
|
+
var button = rows[i].querySelector("[data-path]");
|
|
188
|
+
if (button) paths.push(button.getAttribute("data-path"));
|
|
189
|
+
}
|
|
190
|
+
return paths;
|
|
191
|
+
}
|
|
192
|
+
function depthOf(button){ return parseInt(button.getAttribute("data-depth") || "0", 10); }
|
|
193
|
+
// Flag rows whose next row is deeper as collapsible parents, and enable
|
|
194
|
+
// their caret; clear the flag (and any collapse) on rows without children.
|
|
195
|
+
function markParents(){
|
|
196
|
+
var root = document.getElementById("plumbo");
|
|
197
|
+
if (!root) return;
|
|
198
|
+
var rows = root.querySelectorAll("#plumbo-list .plumbo-row");
|
|
199
|
+
for (var i = 0; i < rows.length; i++){
|
|
200
|
+
var next = rows[i + 1];
|
|
201
|
+
var hasChildren = next && depthOf(next) > depthOf(rows[i]);
|
|
202
|
+
if (hasChildren) { rows[i].classList.add("plumbo-parent"); }
|
|
203
|
+
else { rows[i].classList.remove("plumbo-parent", "plumbo-collapsed"); }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Show only rows matching the text query AND the active type chip. While
|
|
207
|
+
// not filtering, also hide rows nested under a collapsed parent.
|
|
208
|
+
function applyFilter(){
|
|
209
|
+
var root = document.getElementById("plumbo");
|
|
210
|
+
if (!root) return;
|
|
211
|
+
var q = query.toLowerCase();
|
|
212
|
+
var filtering = q !== "" || activeCategory !== null;
|
|
213
|
+
var rows = root.querySelectorAll("#plumbo-list > li");
|
|
214
|
+
var hideBelow = Infinity;
|
|
215
|
+
for (var i = 0; i < rows.length; i++){
|
|
216
|
+
var button = rows[i].querySelector("[data-path]");
|
|
217
|
+
var depth = button ? depthOf(button) : 0;
|
|
218
|
+
var collapsed = false;
|
|
219
|
+
if (!filtering){
|
|
220
|
+
if (depth > hideBelow) { collapsed = true; }
|
|
221
|
+
else { hideBelow = (button && button.classList.contains("plumbo-collapsed")) ? depth : Infinity; }
|
|
222
|
+
}
|
|
223
|
+
var path = button ? button.getAttribute("data-path").toLowerCase() : "";
|
|
224
|
+
var cat = button ? button.getAttribute("data-category") : "";
|
|
225
|
+
var matches = (!q || path.indexOf(q) !== -1) && (!activeCategory || cat === activeCategory);
|
|
226
|
+
rows[i].hidden = collapsed || !matches;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Rebuild the type chips (counts) from the rows currently in the list.
|
|
230
|
+
function buildChips(){
|
|
231
|
+
var root = document.getElementById("plumbo");
|
|
232
|
+
if (!root) return;
|
|
233
|
+
var container = root.querySelector("[data-plumbo-chips]");
|
|
234
|
+
if (!container) return;
|
|
235
|
+
var buttons = root.querySelectorAll("#plumbo-list [data-category]");
|
|
236
|
+
var order = [], counts = {};
|
|
237
|
+
for (var i = 0; i < buttons.length; i++){
|
|
238
|
+
var cat = buttons[i].getAttribute("data-category");
|
|
239
|
+
if (counts[cat] === undefined){ counts[cat] = 0; order.push(cat); }
|
|
240
|
+
counts[cat]++;
|
|
241
|
+
}
|
|
242
|
+
var html = chip(null, "All", buttons.length);
|
|
243
|
+
for (var j = 0; j < order.length; j++){ html += chip(order[j], LABELS[order[j]] || order[j], counts[order[j]]); }
|
|
244
|
+
container.innerHTML = html;
|
|
245
|
+
}
|
|
246
|
+
function chip(cat, label, count){
|
|
247
|
+
var pressed = (activeCategory === cat) ? "true" : "false";
|
|
248
|
+
var attr = (cat === null) ? "" : (' data-category="' + cat + '"');
|
|
249
|
+
return '<button type="button" class="plumbo-chip" data-plumbo-chip' + attr + ' aria-pressed="' + pressed + '">' + label + ' ' + count + '</button>';
|
|
250
|
+
}
|
|
251
|
+
function refresh(){ markParents(); buildChips(); applyFilter(); updateCount(); }
|
|
252
|
+
// Sync the count badges to the current number of rows.
|
|
253
|
+
function updateCount(){
|
|
254
|
+
var total = document.querySelectorAll("#plumbo-list .plumbo-row").length;
|
|
255
|
+
var counts = document.querySelectorAll("#plumbo .plumbo-count");
|
|
256
|
+
for (var j = 0; j < counts.length; j++){ counts[j].textContent = total; }
|
|
257
|
+
}
|
|
258
|
+
var ICONS = {#{ICONS.map { |key, svg| "#{key}:'#{svg.strip}'" }.join(',')}};
|
|
259
|
+
function escapeHtml(s){ return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); }
|
|
260
|
+
// Build a file row from [path, depth, category], mirroring the server.
|
|
261
|
+
function buildRow(path, depth, category){
|
|
262
|
+
var safe = escapeHtml(path);
|
|
263
|
+
var icon = ICONS[category] || ICONS.file;
|
|
264
|
+
return '<li><button type="button" class="plumbo-row" data-plumbo-collapse data-path="' + safe + '" data-category="' + escapeHtml(category) + '" data-depth="' + depth + '" style="--d:' + depth + '">'
|
|
265
|
+
+ '<span class="plumbo-caret">' + ICONS.chevron + '</span>'
|
|
266
|
+
+ '<span class="plumbo-type">' + icon + '</span>'
|
|
267
|
+
+ '<span class="plumbo-path">' + safe + '</span>'
|
|
268
|
+
+ '<span class="plumbo-copy" data-plumbo-copy title="Copy path">' + ICONS.copy + '</span>'
|
|
269
|
+
+ '</button></li>';
|
|
270
|
+
}
|
|
271
|
+
// Merge the files from an X-Plumbo-Files header into the panel, skipping
|
|
272
|
+
// paths already listed, then renumber and rebuild chips/filter.
|
|
273
|
+
function mergeFiles(encoded){
|
|
274
|
+
var root = document.getElementById("plumbo");
|
|
275
|
+
if (!root) return;
|
|
276
|
+
var list = root.querySelector("#plumbo-list");
|
|
277
|
+
if (!list) return;
|
|
278
|
+
var data;
|
|
279
|
+
try { data = JSON.parse(atob(encoded)); } catch (e) { return; }
|
|
280
|
+
var seen = {};
|
|
281
|
+
var existing = list.querySelectorAll("[data-path]");
|
|
282
|
+
for (var i = 0; i < existing.length; i++){ seen[existing[i].getAttribute("data-path")] = true; }
|
|
283
|
+
var html = "";
|
|
284
|
+
for (var j = 0; j < data.length; j++){
|
|
285
|
+
var path = data[j][0];
|
|
286
|
+
if (seen[path]) continue;
|
|
287
|
+
seen[path] = true;
|
|
288
|
+
html += buildRow(path, data[j][1], data[j][2]);
|
|
289
|
+
}
|
|
290
|
+
if (html) { list.insertAdjacentHTML("beforeend", html); }
|
|
291
|
+
refresh();
|
|
292
|
+
}
|
|
293
|
+
refresh();
|
|
294
|
+
// Read the file list off every fetch response (Turbo Drive/Frame/Stream
|
|
295
|
+
// and custom fetch all go through fetch) so the panel keeps up without a
|
|
296
|
+
// full reload. Reading a header doesn't consume the response body.
|
|
297
|
+
if (window.fetch){
|
|
298
|
+
var plumboFetch = window.fetch;
|
|
299
|
+
window.fetch = function(){
|
|
300
|
+
return plumboFetch.apply(this, arguments).then(function(response){
|
|
301
|
+
try { var data = response.headers.get("X-Plumbo-Files"); if (data) mergeFiles(data); } catch (e) {}
|
|
302
|
+
return response;
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// A full Turbo Drive visit swaps in a fresh panel; reset the filter to
|
|
307
|
+
// match the new page, then rebuild.
|
|
308
|
+
document.addEventListener("turbo:load", function(){ query = ""; activeCategory = null; refresh(); });
|
|
309
|
+
})();
|
|
310
|
+
JS
|
|
311
|
+
|
|
312
|
+
module_function
|
|
313
|
+
|
|
314
|
+
# Returns the full panel HTML for the given list of file paths.
|
|
315
|
+
def render(files)
|
|
316
|
+
count = files.size
|
|
317
|
+
|
|
318
|
+
<<~HTML
|
|
319
|
+
<div id="plumbo">
|
|
320
|
+
<style>#{CSS}</style>
|
|
321
|
+
#{toggle(count)}
|
|
322
|
+
<div class="plumbo-panel" hidden data-plumbo-panel>
|
|
323
|
+
#{header(count)}
|
|
324
|
+
<div class="plumbo-filterbar">
|
|
325
|
+
<input type="text" class="plumbo-filter" data-plumbo-filter placeholder="Filter files…" aria-label="Filter files">
|
|
326
|
+
<div class="plumbo-chips" data-plumbo-chips></div>
|
|
327
|
+
</div>
|
|
328
|
+
<ol id="plumbo-list" class="plumbo-list" data-plumbo-list>#{rows(files)}</ol>
|
|
329
|
+
</div>
|
|
330
|
+
<script>#{JS}</script>
|
|
331
|
+
</div>
|
|
332
|
+
HTML
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# The always-visible pill that opens the panel and shows how many files
|
|
336
|
+
# rendered the current page.
|
|
337
|
+
def toggle(count)
|
|
338
|
+
<<~HTML
|
|
339
|
+
<button type="button" class="plumbo-toggle" data-plumbo-toggle title="#{count} files used to render this page">
|
|
340
|
+
#{ICONS[:file]}<span class="plumbo-count">#{count}</span>
|
|
341
|
+
</button>
|
|
342
|
+
HTML
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# The panel's title bar, carrying the count and the copy-all/close controls.
|
|
346
|
+
def header(count)
|
|
347
|
+
<<~HTML
|
|
348
|
+
<div class="plumbo-header">
|
|
349
|
+
<span class="plumbo-title">Plumbo (<span class="plumbo-count">#{count}</span>)</span>
|
|
350
|
+
<div class="plumbo-actions">
|
|
351
|
+
<button type="button" class="plumbo-action" data-plumbo-copy-all title="Copy all paths">#{ICONS[:copy_all]}</button>
|
|
352
|
+
<button type="button" class="plumbo-action" data-plumbo-clear title="Clear the list to watch new files as you navigate">#{ICONS[:clear]}</button>
|
|
353
|
+
<button type="button" class="plumbo-close" data-plumbo-close title="Close">#{ICONS[:x]}</button>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
HTML
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Builds the <li> rows for a list of files, in render order. Each entry is
|
|
360
|
+
# either a bare path or a [path, depth] pair; depth indents the row (with a
|
|
361
|
+
# guide line) to show the parent/child render nesting.
|
|
362
|
+
def rows(files)
|
|
363
|
+
files.map do |entry|
|
|
364
|
+
path, depth = Array(entry)
|
|
365
|
+
row(path, depth || 0)
|
|
366
|
+
end.join
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Renders a single file row: clicking it collapses/expands (when it has
|
|
370
|
+
# children), clicking the copy icon copies the path. Tagged with its
|
|
371
|
+
# category (for filtering) and depth (indent guide line + per-level color).
|
|
372
|
+
def row(path, depth = 0)
|
|
373
|
+
safe = ERB::Util.html_escape(path)
|
|
374
|
+
<<~HTML
|
|
375
|
+
<li><button type="button" class="plumbo-row" data-plumbo-collapse data-path="#{safe}" data-category="#{category(path)}" data-depth="#{depth}" style="--d:#{depth}">
|
|
376
|
+
<span class="plumbo-caret">#{ICONS[:chevron]}</span>
|
|
377
|
+
<span class="plumbo-type">#{file_icon(path)}</span>
|
|
378
|
+
<span class="plumbo-path">#{safe}</span>
|
|
379
|
+
<span class="plumbo-copy" data-plumbo-copy title="Copy path">#{ICONS[:copy]}</span>
|
|
380
|
+
</button></li>
|
|
381
|
+
HTML
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Looks up the type icon for a path, falling back to the generic file icon.
|
|
385
|
+
def file_icon(path)
|
|
386
|
+
ICONS.fetch(category(path), ICONS[:file])
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Classifies a file by its path so each row can show a matching icon,
|
|
390
|
+
# returning the icon key of the first matching rule or :file otherwise.
|
|
391
|
+
def category(path)
|
|
392
|
+
rule = CATEGORY_RULES.find { |pattern, _type| path.match?(pattern) }
|
|
393
|
+
rule ? rule.last : :file
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Plumbo
|
|
6
|
+
# Wires the middleware into the Rack stack. It's inserted unconditionally; the
|
|
7
|
+
# per-request `config.enabled` gate (development by default) makes it a no-op
|
|
8
|
+
# elsewhere and lets a host toggle it from an initializer.
|
|
9
|
+
class Railtie < Rails::Railtie
|
|
10
|
+
initializer "plumbo.middleware" do |app|
|
|
11
|
+
app.middleware.use Plumbo::Middleware
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plumbo
|
|
4
|
+
# Finds the Stimulus controllers referenced by a page and nests each one under
|
|
5
|
+
# the template/partial whose source declares it. Controllers are discovered by
|
|
6
|
+
# scanning a file's source for data-controller attributes, then mapping each
|
|
7
|
+
# identifier to its source file via Stimulus's naming convention. No asset
|
|
8
|
+
# pipeline or digest guessing is involved: the mapping is deterministic.
|
|
9
|
+
#
|
|
10
|
+
# data-controller="hello" -> app/javascript/controllers/hello_controller.js
|
|
11
|
+
# data-controller="date-picker" -> app/javascript/controllers/date_picker_controller.js
|
|
12
|
+
# data-controller="users--list-item" -> app/javascript/controllers/users/list_item_controller.js
|
|
13
|
+
#
|
|
14
|
+
# (A "--" in an identifier is a directory separator; a "-" is a word separator.)
|
|
15
|
+
module Stimulus
|
|
16
|
+
ATTRIBUTE = /data-controller\s*=\s*["']([^"']*)["']/i
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Given the ordered [path, depth] file list, returns a new list with each
|
|
21
|
+
# template/partial's Stimulus controllers inserted right after it, nested one
|
|
22
|
+
# level deeper — so a controller appears under the view/partial that
|
|
23
|
+
# references it. Files whose source can't be read (or non-.erb files)
|
|
24
|
+
# contribute no children.
|
|
25
|
+
def nest(files, config)
|
|
26
|
+
return files unless config.include_stimulus
|
|
27
|
+
|
|
28
|
+
files.flat_map { |path, depth| [[path, depth]] + children(path, depth, config) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def children(path, depth, config)
|
|
32
|
+
source = source_for(path, config)
|
|
33
|
+
return [] unless source
|
|
34
|
+
|
|
35
|
+
controllers(source, config).map { |controller| [controller, depth + 1] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Reads the on-disk source of a rendered template/partial (only .erb files),
|
|
39
|
+
# reconstructing the absolute path from the prefixed path and the root.
|
|
40
|
+
def source_for(path, config)
|
|
41
|
+
return unless path.end_with?(".erb")
|
|
42
|
+
|
|
43
|
+
absolute = File.join(config.root, path.delete_prefix(config.path_prefix))
|
|
44
|
+
File.file?(absolute) ? File.read(absolute) : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Source paths for every Stimulus controller referenced in +markup+, prefixed
|
|
48
|
+
# and deduped (first occurrence wins). Empty when disabled or none are found.
|
|
49
|
+
def controllers(markup, config)
|
|
50
|
+
return [] unless config.include_stimulus
|
|
51
|
+
|
|
52
|
+
identifiers(markup).map { |id| path_for(id, config) }.uniq
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Each whitespace-separated identifier across all data-controller attributes.
|
|
56
|
+
def identifiers(markup)
|
|
57
|
+
markup.scan(ATTRIBUTE).flatten.flat_map(&:split)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def path_for(identifier, config)
|
|
61
|
+
file = "#{identifier.gsub('--', '/').gsub('-', '_')}_controller.js"
|
|
62
|
+
"#{config.path_prefix}#{config.javascript_root}/controllers/#{file}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/plumbo.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "plumbo/version"
|
|
4
|
+
require "plumbo/configuration"
|
|
5
|
+
require "plumbo/collector"
|
|
6
|
+
require "plumbo/stimulus"
|
|
7
|
+
require "plumbo/panel"
|
|
8
|
+
require "plumbo/middleware"
|
|
9
|
+
|
|
10
|
+
# Plumbo — a development-only panel that lists every controller, view, and
|
|
11
|
+
# partial used to render the current page, with click-to-copy @paths for
|
|
12
|
+
# pasting into an AI assistant. Self-contained: it injects its own HTML, CSS,
|
|
13
|
+
# and JS, so the host app needs no Tailwind, Stimulus, or JS bundler.
|
|
14
|
+
module Plumbo
|
|
15
|
+
class << self
|
|
16
|
+
# Global configuration, lazily created with dev-only defaults.
|
|
17
|
+
def config
|
|
18
|
+
@config ||= Configuration.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Override defaults from an initializer: Plumbo.configure { |c| ... }
|
|
22
|
+
def configure
|
|
23
|
+
yield config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Exposed mainly so tests can swap in a fresh configuration.
|
|
27
|
+
attr_writer :config
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
require "plumbo/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: plumbo
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Matt Sears
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-06-23 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activesupport
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '6.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '6.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rack
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: minitest
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '5.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '5.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.0'
|
|
68
|
+
description: 'Plumbo injects a development-only panel into your Rails app that traces
|
|
69
|
+
every file (controller, helper, layout, templates, partials) used to render the
|
|
70
|
+
current page, with click-to-copy @paths for pasting into an AI assistant. Self-contained:
|
|
71
|
+
no Tailwind, Stimulus, or JS bundler required.'
|
|
72
|
+
email:
|
|
73
|
+
- matt@mattsears.com
|
|
74
|
+
executables: []
|
|
75
|
+
extensions: []
|
|
76
|
+
extra_rdoc_files: []
|
|
77
|
+
files:
|
|
78
|
+
- LICENSE.txt
|
|
79
|
+
- README.md
|
|
80
|
+
- lib/plumbo.rb
|
|
81
|
+
- lib/plumbo/collector.rb
|
|
82
|
+
- lib/plumbo/configuration.rb
|
|
83
|
+
- lib/plumbo/middleware.rb
|
|
84
|
+
- lib/plumbo/panel.rb
|
|
85
|
+
- lib/plumbo/railtie.rb
|
|
86
|
+
- lib/plumbo/stimulus.rb
|
|
87
|
+
- lib/plumbo/version.rb
|
|
88
|
+
homepage: https://github.com/mattsears/plumbo
|
|
89
|
+
licenses:
|
|
90
|
+
- MIT
|
|
91
|
+
metadata:
|
|
92
|
+
homepage_uri: https://github.com/mattsears/plumbo
|
|
93
|
+
source_code_uri: https://github.com/mattsears/plumbo
|
|
94
|
+
rubygems_mfa_required: 'true'
|
|
95
|
+
rdoc_options: []
|
|
96
|
+
require_paths:
|
|
97
|
+
- lib
|
|
98
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - ">="
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '3.0'
|
|
103
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
104
|
+
requirements:
|
|
105
|
+
- - ">="
|
|
106
|
+
- !ruby/object:Gem::Version
|
|
107
|
+
version: '0'
|
|
108
|
+
requirements: []
|
|
109
|
+
rubygems_version: 3.6.2
|
|
110
|
+
specification_version: 4
|
|
111
|
+
summary: A zero-config dev panel listing every controller, view, and partial behind
|
|
112
|
+
the current page.
|
|
113
|
+
test_files: []
|