ActiveTree
An interactive tree-based admin interface for ActiveRecord. ActiveTree renders a persistent split-pane TUI (terminal UI) for browsing records, their associations, and field values.
Installation
Add to your application's Gemfile:
gem "activetree"Then run:
bundle installUsage
Launching the TUI
ActiveTree browses a single root record and its association tree. Pass a model name and a query:
# Within a Rails app — browse a specific record by ID
bin/rails "activetree:tree[User,42]"
bundle exec activetree User 42
# Query with ActiveRecord DSL expressions
bundle exec activetree User "where(active: true)"
bundle exec activetree Order "where(status: 'pending').limit(10)"
# No arguments — opens an interactive query dialog
bundle exec activetreeThe TUI opens a full-screen split-pane interface:
- Left pane — navigable tree with expand/collapse for associations
- Right pane — field/value detail view for the selected record
Use Tab to switch focus between panes. The focused pane is highlighted with a magenta border. Both panes scroll independently and show a scrollbar when content overflows.
Tree nodes use disclosure icons to communicate expand/collapse state and whether children have been loaded from the database:
| Icon | Meaning |
|---|---|
| ▶ | Collapsed, children already loaded |
| ▼ | Expanded, children already loaded |
| ▷ | Collapsed, children not yet loaded |
| ▽ | Expanded, children not yet loaded |
Key Bindings
| Key | Action |
|---|---|
j / Down
|
Move cursor down (tree) or scroll down (detail) |
k / Up
|
Move cursor up (tree) or scroll up (detail) |
l / Right
|
Tree: expand collapsed node → descend into expanded node → select leaf & switch to detail. Detail: no-op |
h / Left
|
Tree: collapse expanded node → jump to parent. Detail: switch focus back to tree |
Tab |
Switch focus between tree and detail panes |
Space |
Expand / collapse node |
Enter |
Select record (show details in right pane) |
f |
Toggle field mode (configured fields vs. all columns) |
r |
Make selected record the new root |
q |
Open query dialog |
Ctrl-C |
Quit |
Query Mode
Press q at any time (or launch with no arguments) to open the query dialog. The dialog presents two fields:
-
Model class — the ActiveRecord model name (e.g.
User,Order) -
Query — a numeric ID or an ActiveRecord DSL expression (e.g.
42,where(active: true),where(status: 'pending').order(:created_at))
Use Tab to move between fields, Enter to submit, and Esc to cancel and return to the current tree.
The results of the query become the root of the tree. If multiple results are returned, they are paginated according to the default_limit configuration.
If the model isn't found or the query returns no results, an error message appears in the dialog.
Field Mode
By default the detail pane shows only the fields declared via tree_fields (or :id if none are configured). Press f to toggle field mode — this switches the detail pane to display every column in the model's database schema. Press f again to return to the configured view.
The current mode is shown at the top of the detail pane ("Field mode: configured" or "Field mode: all columns"). Field mode is tracked per model class, so toggling on a User record won't affect how Order records are displayed.
Boolean fields are rendered with visual indicators: true displays as a green ✓ and false as a red ✗.
Configuring Models
Include ActiveTree::Model in your AR models to control what appears in the TUI:
class User < ApplicationRecord
include ActiveTree::Model
tree_fields :id, :email, :name, :created_at
tree_children :orders, :profile
tree_label { |record| "#{record.name} (#{record.email})" }
endSingular forms accept keyword options:
class Order < ApplicationRecord
include ActiveTree::Model
tree_field :id
tree_field :status, label: "Order Status"
tree_child :line_items, label: "Items"
tree_child :shipments
endThe plural forms also accept inline option hashes to customize individual entries:
class User < ApplicationRecord
include ActiveTree::Model
tree_fields :id, :email, { name: { label: "Full Name" } }, :created_at
tree_children :orders, { shipments: { label: "User Shipments" } }
endScoping Child Relations
An ActiveRecord scope can be passed for children to filter which records appear in the tree. The scope proc is merged onto the association relation via ActiveRecord::Relation#merge, so named scopes and query methods work naturally:
class User < ApplicationRecord
include ActiveTree::Model
tree_child :comments, -> { approved }, label: "Approved Comments"
tree_child :orders, -> { where(status: "active") }
endThe tree_children hash form supports scope: as well:
class User < ApplicationRecord
include ActiveTree::Model
tree_children :orders, { comments: { scope: -> { approved }, label: "Approved" } }
endScopes work for both collection (has_many) and singular (has_one, belongs_to) associations. The scope is merged with the existing association relation — it never replaces it.
| Method | Default | Description |
|---|---|---|
tree_fields |
:id only |
Fields shown in the detail pane (batch) |
tree_field |
— | Add a single field with keyword options (label:) |
tree_children |
None | Associations expandable as tree children (batch) |
tree_child |
— | Add a single child with options (label:, positional scope proc) |
tree_label |
-> (record) { "#{record.class.name} ##{record.id}" } |
Custom label block for tree nodes and detail pane |
Models without the mixin still appear in the tree if referenced as children of another model, using the defaults above.
Centralized Configuration via DSL
ActiveTree can also be configured centrally with a DSL (e.g. in an initializer). This is especially useful for third-party models or keeping tree config separate from your models:
# config/initializers/activetree.rb
ActiveTree.configure do
max_depth 5
default_limit 50
model "User" do
fields :id, :email, :name, :created_at
children :orders, :profile
label { |record| "#{record.name} (#{record.email})" }
end
model "Order" do
field :id
field :status, label: "Order Status"
child :line_items, label: "Items"
child :shipments
end
endModel names are passed as strings because classes may not be loaded when the initializer runs. The DSL methods mirror the ActiveTree::Model concern without the tree_ prefix:
| DSL Method | Equivalent Concern Method | Description |
|---|---|---|
field :name, label: "..." |
tree_field |
Add a single field |
fields :id, :email, ... |
tree_fields |
Add multiple fields |
child :orders, scope, label: "..." |
tree_child |
Add a single child (optional scope proc + label) |
children :orders, :shipments |
tree_children |
Add multiple children |
label { |r| ... } |
tree_label |
Custom label block |
Merging with the Model Concern
Both configuration styles write to the same underlying config. If a model is configured in an initializer and includes ActiveTree::Model, the results merge — fields and children accumulate, and last-write-wins for any given name:
Global Options
| Option | Default | Description |
|---|---|---|
max_depth |
3 |
Maximum nesting depth for associations NOT YET IMPLEMENTED |
default_limit |
25 |
Max records loaded per has_many expansion (paginated) |
global_scope |
nil |
A proc merged into every relation ActiveTree queries (see below) |
Global Scope
global_scope applies a scope to every query ActiveTree makes — the root record lookup and all association loads (both collection and singular). This is useful for multi-tenancy, soft-delete filtering, or any cross-cutting constraint.
The proc is merged onto each relation via ActiveRecord::Relation#merge, so named scopes and query methods work naturally:
# DSL style
ActiveTree.configure do
global_scope { where(organization_id: Current.organization_id) }
end
# Direct assignment
ActiveTree.config.global_scope = -> { where(deleted_at: nil) }When a child association also has its own scope, both are applied — global scope first, then the per-child scope:
ActiveTree.configure do
global_scope { where(organization_id: Current.organization_id) }
model "User" do
# The final relation for orders will have both the org filter AND the status filter
child :orders, -> { where(status: "active") }, label: "Active Orders"
end
endPagination
Large has_many associations are loaded in pages of default_limit records. When more records exist, a [load more...] node appears at the bottom of the group. Activate it with Space to load the next page.
Once loaded, association groups show a record count in their label — e.g. orders [3] when all records are loaded, or orders [25+] when more pages remain. Singular associations (has_one, belongs_to) and unloaded groups show just the association name.
Development
bin/setup # Install dependencies
bundle exec rspec # Run tests
bundle exec rubocop # LintContributing
Bug reports and pull requests are welcome on GitHub at https://github.com/babylist/activetree.
License
The gem is available as open source under the terms of the MIT License.