Add Rails engine with browser UI at /whetrails

Mounts a Rails engine that lists app models in a sidebar and renders
the full callback/validator chain for any model. Source locations are
linked via vscode:// URIs. Condition badges and concern module tags
distinguish at-a-glance where each callback comes from.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 20:12:24 -06:00
parent 6dd78f2487
commit 286db51fcd
14 changed files with 379 additions and 2 deletions

View File

@@ -1,5 +1,7 @@
source "https://rubygems.org"
gem "whetrails", path: "."
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.3"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]

View File

@@ -1,3 +1,10 @@
PATH
remote: .
specs:
whetrails (0.1.0)
activerecord (>= 7.0)
railties (>= 7.0)
GEM
remote: https://rubygems.org/
specs:
@@ -420,6 +427,7 @@ DEPENDENCIES
turbo-rails
tzinfo-data
web-console
whetrails!
BUNDLED WITH
2.5.22

View File

@@ -0,0 +1,23 @@
module Whetrails
class ApplicationController < ActionController::Base
layout "whetrails/application"
protect_from_forgery with: :exception
before_action :load_all_models
private
def load_all_models
::Rails.application.eager_load!
app_root = ::Rails.root.to_s
@all_models = ::ActiveRecord::Base.descendants
.reject(&:abstract_class?)
.reject { |m| m.name.nil? }
.select { |m|
file = Object.const_source_location(m.name)&.first rescue nil
file&.start_with?(app_root)
}
.sort_by(&:name)
end
end
end

View File

@@ -0,0 +1,16 @@
module Whetrails
class ModelsController < ApplicationController
def index
if @all_models.any?
redirect_to model_path(@all_models.first.name)
end
end
def show
@model_class = params[:id].constantize
@chain = ::Whetrails::Inspector.inspect_model(@model_class)
rescue NameError
head :not_found
end
end
end

View File

@@ -0,0 +1,20 @@
module Whetrails
module ModelsHelper
def vscode_uri(file, line)
"vscode://file/#{file}:#{line}" if file
end
def short_path(file)
return nil unless file
file.sub("#{::Rails.root}/", "")
end
def condition_label(c)
c.is_a?(Symbol) ? ":#{c}" : "&lt;proc&gt;"
end
def validator_type_label(type)
type.split("::").last.sub("Validator", "").downcase
end
end
end

View File

@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html>
<head>
<title>Whetrails</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
background: #f5f5f7;
color: #1a1a2e;
}
.layout { display: flex; min-height: 100vh; }
/* Sidebar */
.sidebar {
width: 220px;
background: #1a1a2e;
color: #c8c8e0;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.sidebar-brand {
padding: 18px 16px 14px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #fff;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.sidebar-section {
padding: 10px 16px 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #5555aa;
}
.sidebar-model {
display: block;
padding: 6px 16px;
color: #a0a0cc;
text-decoration: none;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 13px;
border-left: 3px solid transparent;
}
.sidebar-model:hover { background: rgba(255,255,255,0.06); color: #fff; }
.sidebar-model.active {
background: rgba(100,100,220,0.15);
color: #fff;
border-left-color: #6666dd;
}
.sidebar-empty {
padding: 8px 16px;
color: #555577;
font-size: 12px;
font-style: italic;
}
/* Main */
.main { flex: 1; padding: 36px 40px; overflow: auto; }
h1.model-title {
font-size: 24px;
font-weight: 700;
font-family: "SF Mono", "Fira Code", monospace;
margin-bottom: 6px;
color: #1a1a2e;
}
.divider {
border: none;
border-top: 2px solid #e0e0e8;
margin: 16px 0 24px;
}
.section-heading {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #8888aa;
margin-bottom: 12px;
}
/* Callback chain */
.stage { margin-bottom: 20px; }
.stage-label {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
font-weight: 700;
color: #4444aa;
padding: 4px 0;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.stage-label::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #4444aa;
flex-shrink: 0;
}
.stage-label.stage-destroy { color: #aa3333; }
.stage-label.stage-destroy::before { background: #aa3333; }
.stage-label.stage-commit { color: #226622; }
.stage-label.stage-commit::before { background: #226622; }
.cb-list {
border-left: 2px solid #e0e0e8;
margin-left: 3px;
padding-left: 0;
}
.cb-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
padding: 5px 12px;
border-bottom: 1px solid #f0f0f4;
}
.cb-row:last-child { border-bottom: none; }
.cb-filter {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 13px;
font-weight: 600;
color: #1a1a2e;
min-width: 180px;
}
.cb-filter.is-proc { color: #888; font-style: italic; font-weight: 400; }
.cb-src {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 11px;
color: #999;
}
.cb-src a { color: #5555cc; text-decoration: none; }
.cb-src a:hover { text-decoration: underline; }
.badges { display: flex; gap: 4px; flex-wrap: wrap; }
.badge {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 11px;
padding: 1px 7px;
border-radius: 4px;
}
.badge-cond-if { background: #fff8e1; color: #7a5c00; border: 1px solid #ffe082; }
.badge-cond-unless { background: #fce4ec; color: #8b1a2f; border: 1px solid #f48fb1; }
.badge-module { background: #e8f4fd; color: #1a5276; border: 1px solid #aed6f1; }
/* Validators */
.validator-list { margin-bottom: 24px; }
.v-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid #f0f0f4;
}
.v-row:last-child { border-bottom: none; }
.v-type {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 13px;
font-weight: 600;
color: #1a1a2e;
min-width: 140px;
}
.v-attrs {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
color: #555;
min-width: 160px;
}
.empty-state { color: #aaa; font-style: italic; padding: 8px 0; font-size: 13px; }
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">Whetrails</div>
<% if @all_models.any? %>
<div class="sidebar-section">Models</div>
<% @all_models.each do |m| %>
<%= link_to m.name, model_path(m.name),
class: "sidebar-model #{'active' if defined?(@model_class) && @model_class == m}" %>
<% end %>
<% else %>
<div class="sidebar-empty">No models found</div>
<% end %>
</aside>
<main class="main">
<%= yield %>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,5 @@
<% if @all_models.empty? %>
<div class="empty-state">
No ActiveRecord models found. Make sure your app is loaded.
</div>
<% end %>

View File

@@ -0,0 +1,76 @@
<h1 class="model-title"><%= @chain.model_name %></h1>
<hr class="divider">
<% if @chain.callbacks.any? %>
<div class="section-heading">Lifecycle Callbacks</div>
<% @chain.callbacks.group_by(&:event).each do |event, callbacks| %>
<div class="stage">
<%
stage_class = case event.to_s
when /destroy/ then "stage-destroy"
when /commit|rollback/ then "stage-commit"
else ""
end
%>
<div class="stage-label <%= stage_class %>"><%= event %></div>
<div class="cb-list">
<% callbacks.each do |cb| %>
<div class="cb-row">
<span class="cb-filter <%= 'is-proc' if cb.filter == '<proc>' %>">:<%= cb.filter %></span>
<% if cb.source_file %>
<span class="cb-src">
<% uri = vscode_uri(cb.source_file, cb.source_line) %>
<% label = "#{short_path(cb.source_file)}:#{cb.source_line}" %>
<% if uri %>
<%= link_to label, uri %>
<% else %>
<%= label %>
<% end %>
</span>
<% end %>
<span class="badges">
<% if cb.source_module && cb.source_module != @chain.model_name %>
<span class="badge badge-module"><%= cb.source_module %></span>
<% end %>
<% Array(cb.conditions[:if]).each do |c| %>
<span class="badge badge-cond-if">if: <%= condition_label(c) %></span>
<% end %>
<% Array(cb.conditions[:unless]).each do |c| %>
<span class="badge badge-cond-unless">unless: <%= condition_label(c) %></span>
<% end %>
</span>
</div>
<% end %>
</div>
</div>
<% end %>
<% else %>
<p class="empty-state">No callbacks registered.</p>
<% end %>
<% if @chain.validators.any? %>
<div class="section-heading" style="margin-top: 32px;">Validators</div>
<div class="validator-list">
<% @chain.validators.each do |v| %>
<div class="v-row">
<span class="v-type"><%= validator_type_label(v.type) %></span>
<span class="v-attrs"><%= v.attributes.map { |a| ":#{a}" }.join(", ") %></span>
<span class="badges">
<% Array(v.conditions[:if]).each do |c| %>
<span class="badge badge-cond-if">if: <%= condition_label(c) %></span>
<% end %>
<% Array(v.conditions[:unless]).each do |c| %>
<span class="badge badge-cond-unless">unless: <%= condition_label(c) %></span>
<% end %>
</span>
</div>
<% end %>
</div>
<% else %>
<p class="empty-state">No validators registered.</p>
<% end %>

View File

@@ -14,7 +14,8 @@ module Whetrails
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# lib/whetrails/**/* uses manual requires (gem source), not Zeitwerk autoloading
config.autoload_lib(ignore: %w[assets tasks whetrails.rb whetrails])
# Configuration for the application, engines, and railties goes here.
#

View File

@@ -1,4 +1,6 @@
Rails.application.routes.draw do
mount Whetrails::Engine, at: "/whetrails"
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.

View File

@@ -1,6 +1,7 @@
require_relative "whetrails/version"
require_relative "whetrails/chain"
require_relative "whetrails/inspector"
require_relative "whetrails/engine" if defined?(Rails)
module Whetrails
def self.inspect_model(model_class)

9
lib/whetrails/engine.rb Normal file
View File

@@ -0,0 +1,9 @@
module Whetrails
class Engine < ::Rails::Engine
isolate_namespace Whetrails
# In the gem's dev setup, config/routes.rb belongs to the host app.
# Point the engine's routing path to a dedicated file.
config.paths.add "config/routes.rb", with: "lib/whetrails/engine_routes.rb"
end
end

View File

@@ -0,0 +1,4 @@
Whetrails::Engine.routes.draw do
resources :models, only: [:index, :show]
root to: "models#index"
end

View File

@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
spec.required_ruby_version = ">= 3.0"
spec.files = Dir["lib/**/*.rb", "exe/*", "LICENSE", "README.md"]
spec.files = Dir["lib/**/*.rb", "app/**/*.rb", "app/views/**/*.erb", "config/**/*.rb", "exe/*", "LICENSE", "README.md"]
spec.executables = ["whetrails"]
spec.add_dependency "railties", ">= 7.0"