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 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
@@ -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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plumbo
4
+ VERSION = "0.1.0"
5
+ 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: []