No release in over 3 years
A tree-based admin interface for Rails applications
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

 Project Readme

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 install

Usage

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 activetree

The 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})" }
end

Singular 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
end

The 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" } }
end

Scoping 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") }
end

The tree_children hash form supports scope: as well:

class User < ApplicationRecord
  include ActiveTree::Model

  tree_children :orders, { comments: { scope: -> { approved }, label: "Approved" } }
end

Scopes 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
end

Model 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
end

Pagination

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  # Lint

Contributing

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.