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:
210
app/views/layouts/whetrails/application.html.erb
Normal file
210
app/views/layouts/whetrails/application.html.erb
Normal 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>
|
||||
5
app/views/whetrails/models/index.html.erb
Normal file
5
app/views/whetrails/models/index.html.erb
Normal 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 %>
|
||||
76
app/views/whetrails/models/show.html.erb
Normal file
76
app/views/whetrails/models/show.html.erb
Normal 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 %>
|
||||
Reference in New Issue
Block a user