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.
- checksums.yaml +4 -4
- data/README.md +30 -4
- data/Rakefile +2 -0
- data/app/assets/images/monolith/logo.svg +6 -0
- data/app/assets/stylesheets/monolith/application.tailwind.css +14 -0
- data/app/components/monolith/base.rb +6 -0
- data/app/components/monolith/table.rb +37 -0
- data/app/controllers/monolith/application_controller.rb +68 -2
- data/app/controllers/monolith/emails_controller.rb +61 -0
- data/app/controllers/monolith/exceptions_controller.rb +370 -0
- data/app/controllers/monolith/gems_controller.rb +230 -0
- data/app/controllers/monolith/generators_controller.rb +377 -0
- data/app/controllers/monolith/home_controller.rb +10 -0
- data/app/controllers/monolith/models_controller.rb +319 -0
- data/app/controllers/monolith/routes_controller.rb +157 -0
- data/app/controllers/monolith/tables_controller.rb +168 -0
- data/app/views/monolith/base.rb +3 -0
- data/app/views/monolith/layouts/base.rb +23 -0
- data/config/routes.rb +14 -0
- data/lib/generators/monolith/content/USAGE +8 -0
- data/lib/generators/monolith/content/content_generator.rb +25 -0
- data/lib/generators/monolith/generators/base.rb +38 -0
- data/lib/generators/monolith/install/install_generator.rb +14 -95
- data/lib/generators/monolith/view/USAGE +8 -0
- data/lib/generators/monolith/view/view_generator.rb +20 -0
- data/lib/monolith/cli/template.rb +65 -5
- data/lib/monolith/cli.rb +22 -3
- data/lib/monolith/engine.rb +31 -0
- data/lib/monolith/version.rb +1 -1
- data/lib/tasks/monolith_tasks.rake +13 -4
- metadata +66 -10
- data/app/assets/stylesheets/monolith/application.css +0 -15
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99a4d823bd217e148a5bdc6499edccf29f945189d1953e9a6315f63f8fba1230
|
|
4
|
+
data.tar.gz: 380581ef6ce997e7136caa3bd26277bd98108db2f02617ebd446c86a7afd14d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
31
|
+
Add to your Gemfile:
|
|
22
32
|
|
|
23
|
-
|
|
33
|
+
```ruby
|
|
34
|
+
gem 'rubymonolith'
|
|
35
|
+
```
|
|
24
36
|
|
|
25
|
-
|
|
37
|
+
Run `bundle install` and the engine automatically mounts at `http://localhost:3000/monolith` in development.
|
|
26
38
|
|
|
27
|
-
|
|
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
|
@@ -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,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
|
-
|
|
2
|
-
|
|
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
|