rubymonolith 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -4
  3. data/Rakefile +2 -0
  4. data/app/assets/images/monolith/logo.svg +6 -0
  5. data/app/assets/stylesheets/monolith/application.tailwind.css +14 -0
  6. data/app/components/monolith/base.rb +6 -0
  7. data/app/components/monolith/table.rb +37 -0
  8. data/app/controllers/monolith/application_controller.rb +68 -2
  9. data/app/controllers/monolith/emails_controller.rb +61 -0
  10. data/app/controllers/monolith/exceptions_controller.rb +370 -0
  11. data/app/controllers/monolith/gems_controller.rb +230 -0
  12. data/app/controllers/monolith/generators_controller.rb +377 -0
  13. data/app/controllers/monolith/home_controller.rb +10 -0
  14. data/app/controllers/monolith/models_controller.rb +319 -0
  15. data/app/controllers/monolith/routes_controller.rb +157 -0
  16. data/app/controllers/monolith/tables_controller.rb +168 -0
  17. data/app/views/monolith/base.rb +3 -0
  18. data/app/views/monolith/layouts/base.rb +23 -0
  19. data/config/routes.rb +14 -0
  20. data/lib/generators/monolith/content/USAGE +8 -0
  21. data/lib/generators/monolith/content/content_generator.rb +25 -0
  22. data/lib/generators/monolith/generators/base.rb +38 -0
  23. data/lib/generators/monolith/install/install_generator.rb +14 -95
  24. data/lib/generators/monolith/view/USAGE +8 -0
  25. data/lib/generators/monolith/view/view_generator.rb +20 -0
  26. data/lib/monolith/cli/template.rb +65 -5
  27. data/lib/monolith/cli.rb +22 -3
  28. data/lib/monolith/engine.rb +31 -0
  29. data/lib/monolith/version.rb +1 -1
  30. data/lib/tasks/monolith_tasks.rake +13 -4
  31. metadata +66 -10
  32. data/app/assets/stylesheets/monolith/application.css +0 -15
  33. data/app/views/layouts/monolith/application.html.erb +0 -15
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a47f3d7e88749e8ef4a3121ebf295745308596ceeacc43adca80ab847eb38fc6
4
- data.tar.gz: 753a4d5312cf016c706047b6405bf63b170c8c3e16fe5f446edcd7c58943dfe1
3
+ metadata.gz: 99a4d823bd217e148a5bdc6499edccf29f945189d1953e9a6315f63f8fba1230
4
+ data.tar.gz: 380581ef6ce997e7136caa3bd26277bd98108db2f02617ebd446c86a7afd14d3
5
5
  SHA512:
6
- metadata.gz: 90da3406e2644fc578074d6ab106c8ce7bbf158b62fcf0902c5b68069794fdee8b70e53bde82ab15542978e93b8161312bb3dc8185dbd809a6168210e016cdae
7
- data.tar.gz: 030e4f0c69b6164a9cdc01d94dbf0b62c5e3e89ca55873a39790b292aef62c203552542f84b79a715cfc2296fbb84f8e9d3e79e0fa3803afb33201bb8fcee764
6
+ metadata.gz: 86ff18a6821057a36b6d6748300c5b3b84cafca7dd31f1e756b408bd4d8898d9df912602ed2f3e43c3412a1aa96ee1db7c0a1bff95c6863de60ee98e47fc6ed5
7
+ data.tar.gz: a27bce71a7e623800667e41d936e21ddf3e088af7b07f748e51294e78c1437b875bb529ea513c4beac863070e74ef074f77bccf5d96fe9f3b737c9147777355a
data/README.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  A quick way to spin up a [Monlithic Rails](https://rubymonolith.org) application. [Rocketship](https://rocketship.io/) uses Monolith when building new SaaS applications.
4
4
 
5
+ Monolith includes a Rails engine with development tools for inspecting your application:
6
+ - Email previews
7
+ - Database table browser
8
+ - Installed gems viewer
9
+ - Route inspector
10
+ - Model inspector
11
+ - Rails generators interface
12
+
13
+ The engine automatically mounts at `http://localhost:3000/monolith` in development mode.
14
+
5
15
  ## Installation
6
16
 
7
17
  Install the gem and add to the application's Gemfile by executing:
@@ -18,18 +28,34 @@ Monolith creates a new Rails project with the dependencies needed to be producti
18
28
 
19
29
  ## Existing Rails applications
20
30
 
21
- The gem may also be installed for existing Rails applications by executing:
31
+ Add to your Gemfile:
22
32
 
23
- $ bundle add rubymonolith
33
+ ```ruby
34
+ gem 'rubymonolith'
35
+ ```
24
36
 
25
- Then run the following to see the available tasks:
37
+ Run `bundle install` and the engine automatically mounts at `http://localhost:3000/monolith` in development.
26
38
 
27
- $ rails generate --help
39
+ To see available generators, run `rails generate --help`.
28
40
 
29
41
  ## Development
30
42
 
31
43
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
32
44
 
45
+ ### Tailwind CSS Development
46
+
47
+ The engine includes a pre-compiled Tailwind CSS file with daisyUI committed to the repo. When developing the gem:
48
+
49
+ ```bash
50
+ npm install # Install daisyUI
51
+ bin/build # Build CSS once
52
+ rake monolith:tailwind:watch # Watch and rebuild on changes
53
+ ```
54
+
55
+ The engine uses Tailwind v4 with CSS-based configuration and daisyUI for components and dark mode support. The compiled CSS is committed so users don't need Tailwind or npm installed.
56
+
57
+ ### Releasing
58
+
33
59
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
34
60
 
35
61
  ## Contributing
data/Rakefile CHANGED
@@ -6,3 +6,5 @@ load "rails/tasks/engine.rake"
6
6
  load "rails/tasks/statistics.rake"
7
7
 
8
8
  require "bundler/gem_tasks"
9
+
10
+ task default: "app:monolith:tailwind:watch"
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!-- Generated by Pixelmator Pro 3.1.1 -->
3
+ <svg width="632" height="1206" viewBox="0 0 632 1206" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
4
+ <path id="Rounded-Rectangle" fill="#000000" fill-rule="evenodd" stroke="none" d="M 12 1021 L 620 1021 L 620 0 L 12 0 Z"/>
5
+ <text id="MONOLITH" xml:space="preserve" x="2" y="1164" font-family="Helvetica Neue" font-size="147" font-stretch="condensed" font-weight="700" fill="#000000">MONOLITH</text>
6
+ </svg>
@@ -0,0 +1,14 @@
1
+ @import "tailwindcss";
2
+
3
+ @plugin "@tailwindcss/forms";
4
+ @plugin "daisyui" {
5
+ themes: light --default, dark --prefersdark;
6
+ }
7
+
8
+ @theme {
9
+ --font-family-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
10
+ }
11
+
12
+ @source "../../views";
13
+ @source "../../helpers";
14
+ @source "../../controllers";
@@ -0,0 +1,6 @@
1
+ class Monolith::Components::Base < Phlex::HTML
2
+ include Phlex::Rails::Helpers::URLFor
3
+ include Phlex::Rails::Helpers::FormAuthenticityToken
4
+ include Phlex::Rails::Helpers::AssetPath
5
+ include Phlex::Rails::Helpers::TurboFrameTag
6
+ end
@@ -0,0 +1,37 @@
1
+ class Monolith::Components::Table < Monolith::Components::Base
2
+ def initialize(collection)
3
+ @collection = collection
4
+ @columns = []
5
+ end
6
+
7
+ def row(header, &row)
8
+ @columns << [ header, row ]
9
+ end
10
+
11
+ def view_template(&)
12
+ vanish(&)
13
+
14
+ headers, rows = @columns.transpose
15
+
16
+ div(class: "overflow-x-auto") do
17
+ table class: "table" do
18
+ thead do
19
+ tr do
20
+ headers.each do |header|
21
+ th { header }
22
+ end
23
+ end
24
+ end
25
+ tbody do
26
+ @collection.each do |item|
27
+ tr do
28
+ rows.each do |cell|
29
+ td { cell.call item }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,4 +1,70 @@
1
- module Monolith
2
- class ApplicationController < ActionController::Base
1
+ require 'superview'
2
+
3
+ class Monolith::ApplicationController < ActionController::Base
4
+ layout false
5
+
6
+ include Superview::Actions
7
+ # # include Superform::Rails::StrongParameters
8
+ # include ExceptionHandler
9
+
10
+ private
11
+
12
+ def unprocessable(view)
13
+ render component(view), status: :unprocessable_entity
14
+ end
15
+
16
+ def processed(model, form: self.class::Form)
17
+ save form.new model
18
+ end
19
+
20
+ # =======================
21
+ # View with Navigation
22
+ # =======================
23
+ class View < Monolith::Views::Layouts::Base
24
+ def around_template
25
+ super do
26
+ div(class: "flex min-h-screen") do
27
+ render_sidebar
28
+ div(class: "flex-1 p-6 overflow-hidden") do
29
+ yield self if block_given?
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ def render_sidebar
36
+ aside(class: "w-64 shrink-0 bg-base-200 p-4") do
37
+ div(class: "mb-6") do
38
+ a(href: url_for(controller: "/monolith/home", action: :show)) do
39
+ img(src: asset_path("monolith/logo.svg"), alt: "Monolith", class: "dark:invert h-24 w-auto")
40
+ end
41
+ end
42
+
43
+ ul(class: "menu bg-base-200 rounded-box w-full") do
44
+ # li { nav_link "Emails", controller: "/monolith/emails", action: :index }
45
+ li { nav_link "Gems", controller: "/monolith/gems", action: :index }
46
+ li { nav_link "Routes", controller: "/monolith/routes", action: :index }
47
+ li { nav_link "Generators", controller: "/monolith/generators", action: :index }
48
+ li do
49
+ details(open: true) do
50
+ summary { "Data" }
51
+ ul do
52
+ li { nav_link "Models", controller: "/monolith/models", action: :index }
53
+ li { nav_link "Tables", controller: "/monolith/tables", action: :index }
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def nav_link(text, **to)
62
+ a(href: url_for(to)) { text }
63
+ end
64
+
65
+ def ext_link(href, text = nil)
66
+ return em { "—" } if href.nil?
67
+ a(href:, target: "_blank", rel: "noopener", class: "link") { text || href }
68
+ end
3
69
  end
4
70
  end
@@ -0,0 +1,61 @@
1
+ module Monolith
2
+ class EmailsController < ApplicationController
3
+ before_action do
4
+ Rails.application.eager_load!
5
+ # @emails = ApplicationEmail.descendants
6
+
7
+ if params.key? :id
8
+ @email = @emails.find { it.to_s == params.fetch(:id) }
9
+ end
10
+ end
11
+
12
+ class Index < View
13
+ attr_writer :emails
14
+
15
+ def view_template
16
+ div(class: "p-6 space-y-4") do
17
+ h1(class: "text-2xl font-bold") { "Emails" }
18
+ ul(class: "list-disc pl-6 space-y-1") {
19
+ @emails.each do |email|
20
+ li {
21
+ if email.respond_to? :preview
22
+ nav_link email.to_s, action: :show, id: email.to_s
23
+ else
24
+ plain email.to_s
25
+ end
26
+ }
27
+ end
28
+ }
29
+ end
30
+ end
31
+ end
32
+
33
+ class Show < View
34
+ attr_writer :email
35
+
36
+ def view_template
37
+ div(class: "p-6 space-y-4") do
38
+ h1(class: "text-2xl font-bold") { @email.to_s }
39
+ dl(class: "grid grid-cols-1 gap-y-2") {
40
+ dt(class: "font-semibold") { "To:" }
41
+ dd(class: "mb-3") { preview.to }
42
+
43
+ dt(class: "font-semibold") { "From:" }
44
+ dd(class: "mb-3") { preview.from }
45
+
46
+ dt(class: "font-semibold") { "Subject:" }
47
+ dd(class: "mb-3") { preview.subject }
48
+
49
+ dt(class: "font-semibold") { "Body:" }
50
+ dd(class: "whitespace-pre-wrap") { preview.body }
51
+ }
52
+ div(class: "pt-4") { nav_link "← All emails", controller: "/monolith/emails", action: :index }
53
+ end
54
+ end
55
+
56
+ def preview
57
+ @email.preview
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,370 @@
1
+ # app/controllers/monolith/exceptions_controller.rb
2
+ module Monolith
3
+ class ExceptionsController < Monolith::ApplicationController
4
+ skip_before_action :verify_authenticity_token, raise: false
5
+
6
+ def show
7
+ exception = request.env['action_dispatch.exception']
8
+
9
+ if exception.nil?
10
+ return render plain: "No exception found", status: :not_found
11
+ end
12
+
13
+ render Show.new.tap { |v| v.exception = ExceptionInfo.new(exception) }
14
+ end
15
+
16
+ def source
17
+ file = params[:file]
18
+ line = params[:line].to_i
19
+
20
+ if file.blank? || line <= 0
21
+ Rails.logger.error "Invalid parameters for source: file=#{file}, line=#{line}"
22
+ return render plain: "Invalid parameters", status: :bad_request
23
+ end
24
+
25
+ # Security: ensure file exists and is a real file (not a directory traversal)
26
+ unless File.exist?(file) && File.file?(file)
27
+ Rails.logger.error "File not found: #{file}"
28
+ return render plain: "File not found", status: :not_found
29
+ end
30
+
31
+ # Security: resolve path and ensure it's not trying to escape via symlinks
32
+ real_path = File.realpath(file)
33
+
34
+ # Allow files in Rails.root or gem directories
35
+ allowed = real_path.start_with?(Rails.root.to_s) ||
36
+ real_path.include?('/gems/') ||
37
+ real_path.include?('/.rbenv/') ||
38
+ real_path.include?('/.rvm/') ||
39
+ real_path.include?('/ruby/')
40
+
41
+ unless allowed
42
+ Rails.logger.error "Access denied for file: #{real_path}"
43
+ return render plain: "Access denied", status: :forbidden
44
+ end
45
+
46
+ extract = ExceptionInfo.new(nil).send(:extract_source, file, line)
47
+
48
+ if extract
49
+ render SourceExtract.new.tap { |v| v.extract = extract }, layout: false
50
+ else
51
+ Rails.logger.error "Source not available for file: #{file}, line: #{line}"
52
+ render plain: "Source not available", status: :not_found
53
+ end
54
+ rescue => e
55
+ Rails.logger.error "Exception in source action: #{e.class.name}: #{e.message}\n#{e.backtrace.join("\n")}"
56
+ render plain: "Error: #{e.message}", status: :internal_server_error
57
+ end
58
+
59
+ # =======================
60
+ # Inline ActiveModel-like object
61
+ # =======================
62
+ class ExceptionInfo
63
+ attr_reader :exception
64
+
65
+ def initialize(exception)
66
+ @exception = exception
67
+ end
68
+
69
+ def class_name
70
+ exception.class.name
71
+ end
72
+
73
+ def message
74
+ exception.message
75
+ end
76
+
77
+ def backtrace
78
+ exception.backtrace || []
79
+ end
80
+
81
+ def grouped_trace
82
+ @grouped_trace ||= backtrace.map do |frame|
83
+ file, line, method = parse_frame(frame)
84
+ group = categorize_frame(frame)
85
+ {
86
+ frame: frame,
87
+ file: file,
88
+ line: line,
89
+ method: method,
90
+ group: group
91
+ }
92
+ end
93
+ end
94
+
95
+ def first_application_frame
96
+ # Try Application first, then any other group
97
+ grouped_trace.find { |f| f[:group] == 'Application' } || grouped_trace.first
98
+ end
99
+
100
+ def source_extract
101
+ frame = first_application_frame
102
+ return nil unless frame
103
+
104
+ file = frame[:file]
105
+ line = frame[:line]
106
+ return nil unless file && line
107
+ return nil unless File.exist?(file)
108
+
109
+ extract_source(file, line)
110
+ end
111
+
112
+ private
113
+
114
+ def categorize_frame(frame)
115
+ if frame.start_with?(Rails.root.to_s)
116
+ if frame.include?('/app/')
117
+ 'Application'
118
+ elsif frame.include?('/lib/')
119
+ 'Library'
120
+ elsif frame.include?('/config/')
121
+ 'Configuration'
122
+ else
123
+ 'Application'
124
+ end
125
+ elsif frame.include?('/.gem/') || frame.include?('/gems/')
126
+ 'Gems'
127
+ elsif frame.include?('/ruby/')
128
+ 'Ruby'
129
+ else
130
+ 'Framework'
131
+ end
132
+ end
133
+
134
+ def parse_frame(frame)
135
+ # Parse "path/to/file.rb:123:in `method_name'" format
136
+ # Also handle "path/to/file.rb:123" format without method
137
+ if match = frame.match(/^(.+?):(\d+)(?::in [`'](.+?)['"])?/)
138
+ file = match[1]
139
+ line = match[2].to_i
140
+ method_name = match[3]
141
+ [file, line, method_name]
142
+ else
143
+ [nil, nil, nil]
144
+ end
145
+ end
146
+
147
+ def extract_source(file, line_number, context = nil)
148
+ return nil unless file
149
+ return nil unless File.exist?(file)
150
+
151
+ source_code = File.read(file)
152
+ lines = source_code.split("\n")
153
+ return nil if lines.empty?
154
+
155
+ # Load entire file
156
+ {
157
+ file: file.gsub(Rails.root.to_s + '/', ''),
158
+ line_number: line_number,
159
+ start_line: 1,
160
+ end_line: lines.size,
161
+ total_lines: lines.size,
162
+ lines: lines.map.with_index do |content, idx|
163
+ {
164
+ number: idx + 1,
165
+ content: content,
166
+ highlighted: (idx + 1) == line_number
167
+ }
168
+ end
169
+ }
170
+ rescue => e
171
+ # Debug: log the error
172
+ Rails.logger.error("Error extracting source from #{file}: #{e.message}")
173
+ nil
174
+ end
175
+ end
176
+
177
+ # =======================
178
+ # Phlex views
179
+ # =======================
180
+ class Show < View
181
+ attr_writer :exception
182
+
183
+ def view_template
184
+ e = @exception
185
+
186
+ # Full-screen dark background with code editor aesthetic
187
+ div(class: "relative h-screen overflow-hidden bg-gray-900") do
188
+ # Main scrollable code area (fills entire screen) - wrapped in turbo frame
189
+ turbo_frame_tag "source-view", class: "h-full overflow-y-auto block" do
190
+ extract = e.source_extract
191
+ if extract
192
+ render_source_extract(extract)
193
+ else
194
+ div(class: "flex items-center justify-center h-full text-gray-500 font-mono text-sm") do
195
+ plain "Click a stack frame to view source"
196
+ end
197
+ end
198
+ end
199
+
200
+ # Floating HUD on the right side
201
+ div(class: "fixed top-20 right-8 w-96 max-h-[calc(100vh-6rem)] flex flex-col bg-gray-800/80 backdrop-blur-md rounded-xl shadow-2xl border border-gray-700 overflow-hidden") do
202
+ # Exception header (fixed at top of HUD)
203
+ div(class: "bg-gray-700 text-white p-4 border-b border-gray-600") do
204
+ h1(class: "text-lg font-bold mb-1 text-red-400") { e.class_name }
205
+ p(class: "text-sm text-gray-300 leading-snug") { e.message }
206
+ end
207
+
208
+ # Scrollable stack trace
209
+ div(class: "overflow-y-auto flex-1") do
210
+ render_grouped_trace(e.grouped_trace)
211
+ end
212
+ end
213
+ end
214
+ end
215
+
216
+ def render_source_extract(extract)
217
+ return unless extract
218
+
219
+ div(class: "min-h-full bg-gray-900") do
220
+ # File header (sticky at top)
221
+ div(class: "sticky top-0 bg-gray-800 text-gray-300 px-6 py-3 font-mono text-xs border-b border-gray-700 z-10") do
222
+ span(class: "text-gray-500") { "📄 " }
223
+ span(class: "text-green-400") { extract[:file] }
224
+ end
225
+
226
+ # Code lines - text editor style
227
+ div(class: "p-6") do
228
+ div(class: "font-mono text-sm") do
229
+ extract[:lines].each do |line_data|
230
+ div(
231
+ id: "L#{line_data[:number]}",
232
+ class: line_data[:highlighted] ? "bg-red-900/30 border-l-4 border-red-500" : ""
233
+ ) do
234
+ div(class: "flex") do
235
+ # Line number (left side, text editor style)
236
+ div(class: "px-4 py-1 text-right select-none text-gray-600 min-w-[4rem]") do
237
+ if line_data[:highlighted]
238
+ span(class: "text-red-400 font-bold") { line_data[:number].to_s }
239
+ else
240
+ plain line_data[:number].to_s
241
+ end
242
+ end
243
+ # Code content
244
+ div(class: "px-4 py-1 flex-1 whitespace-pre") do
245
+ code(class: line_data[:highlighted] ? "text-red-300" : "text-gray-300") do
246
+ plain line_data[:content]
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
254
+
255
+ # Auto-scroll to highlighted line on load
256
+ if extract[:line_number]
257
+ script do
258
+ raw safe("setTimeout(() => { const frame = document.getElementById('source-view'); const el = document.getElementById('L#{extract[:line_number]}'); if (frame && el) { el.scrollIntoView({ block: 'center', behavior: 'instant' }); } }, 10);")
259
+ end
260
+ end
261
+ end
262
+ end
263
+
264
+ def render_grouped_trace(grouped_trace)
265
+ # Group frames by category
266
+ current_group = nil
267
+
268
+ grouped_trace.each_with_index do |frame_data, idx|
269
+ # If we're starting a new group, render the group header
270
+ if current_group != frame_data[:group]
271
+ current_group = frame_data[:group]
272
+
273
+ # Group header with subtle color coding
274
+ group_color = case current_group
275
+ when 'Application' then 'bg-blue-900/50'
276
+ when 'Gems' then 'bg-purple-900/50'
277
+ when 'Framework' then 'bg-green-900/50'
278
+ when 'Ruby' then 'bg-red-900/50'
279
+ else 'bg-gray-700'
280
+ end
281
+
282
+ div(class: "#{group_color} text-gray-300 px-4 py-2 text-xs font-bold uppercase tracking-wide") do
283
+ plain current_group
284
+ end
285
+ end
286
+
287
+ # Render the frame (clickable link with Turbo Frame target)
288
+ if frame_data[:file] && frame_data[:line]
289
+ a(
290
+ href: url_for(controller: 'monolith/exceptions', action: 'source', file: frame_data[:file], line: frame_data[:line]),
291
+ class: "block border-b border-gray-700 px-4 py-3 font-mono text-xs hover:bg-gray-700/50 transition-colors no-underline",
292
+ data_turbo_frame: "source-view"
293
+ ) do
294
+ div(class: "truncate text-gray-200 mb-1 font-medium") {
295
+ plain frame_data[:file]
296
+ }
297
+ div(class: "text-gray-400 text-xs") do
298
+ span(class: "text-cyan-400") { "Line #{frame_data[:line]}" }
299
+ if frame_data[:method]
300
+ plain " • "
301
+ code(class: "text-yellow-300") { frame_data[:method] }
302
+ end
303
+ end
304
+ end
305
+ else
306
+ div(class: "border-b border-gray-700 px-4 py-3 font-mono text-xs") do
307
+ code(class: "text-xs text-gray-400") { frame_data[:frame] }
308
+ end
309
+ end
310
+ end
311
+ end
312
+ end
313
+
314
+ class SourceExtract < View
315
+ attr_writer :extract
316
+
317
+ def view_template
318
+ return unless @extract
319
+
320
+ turbo_frame_tag "source-view", class: "h-full overflow-y-auto block" do
321
+ div(class: "min-h-full bg-gray-900") do
322
+ # File header (sticky at top)
323
+ div(class: "sticky top-0 bg-gray-800 text-gray-300 px-6 py-3 font-mono text-xs border-b border-gray-700 z-10") do
324
+ span(class: "text-gray-500") { "📄 " }
325
+ span(class: "text-green-400") { @extract[:file] }
326
+ end
327
+
328
+ # Code lines - text editor style
329
+ div(class: "p-6") do
330
+ div(class: "font-mono text-sm") do
331
+ @extract[:lines].each do |line_data|
332
+ div(
333
+ id: "L#{line_data[:number]}",
334
+ class: line_data[:highlighted] ? "bg-red-900/30 border-l-4 border-red-500" : ""
335
+ ) do
336
+ div(class: "flex") do
337
+ # Line number (left side, text editor style)
338
+ div(class: "px-4 py-1 text-right select-none text-gray-600 min-w-[4rem]") do
339
+ if line_data[:highlighted]
340
+ span(class: "text-red-400 font-bold") { line_data[:number].to_s }
341
+ else
342
+ plain line_data[:number].to_s
343
+ end
344
+ end
345
+ # Code content
346
+ div(class: "px-4 py-1 flex-1 whitespace-pre") do
347
+ code(class: line_data[:highlighted] ? "text-red-300" : "text-gray-300") do
348
+ plain line_data[:content]
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end
356
+
357
+ # Auto-scroll to highlighted line on load
358
+ if @extract[:line_number]
359
+ script do
360
+ raw safe("document.addEventListener('turbo:frame-load', function(e) { if (e.target.id === 'source-view') { setTimeout(() => { const el = document.getElementById('L#{@extract[:line_number]}'); if (el) { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } }, 50); } });")
361
+ end
362
+ end
363
+ end
364
+ end
365
+ end
366
+ end
367
+
368
+
369
+ end
370
+ end