rails-activetree 0.1.0.pre1
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +61 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +50 -0
- data/LICENSE.txt +21 -0
- data/README.md +259 -0
- data/Rakefile +12 -0
- data/exe/activetree +29 -0
- data/lib/activetree/association_group_node.rb +64 -0
- data/lib/activetree/char_reader.rb +31 -0
- data/lib/activetree/cli.rb +138 -0
- data/lib/activetree/configuration/dsl.rb +29 -0
- data/lib/activetree/configuration/model/child.rb +21 -0
- data/lib/activetree/configuration/model/field.rb +20 -0
- data/lib/activetree/configuration/model.rb +73 -0
- data/lib/activetree/configuration/model_dsl.rb +31 -0
- data/lib/activetree/configuration.rb +35 -0
- data/lib/activetree/dialog.rb +61 -0
- data/lib/activetree/dialog_field.rb +35 -0
- data/lib/activetree/dialog_input_handler.rb +41 -0
- data/lib/activetree/dialog_renderer.rb +82 -0
- data/lib/activetree/input_handler.rb +36 -0
- data/lib/activetree/list_node.rb +87 -0
- data/lib/activetree/load_more_node.rb +28 -0
- data/lib/activetree/model.rb +36 -0
- data/lib/activetree/query_dialog.rb +19 -0
- data/lib/activetree/query_results_node.rb +26 -0
- data/lib/activetree/railtie.rb +24 -0
- data/lib/activetree/record_node.rb +102 -0
- data/lib/activetree/renderer.rb +236 -0
- data/lib/activetree/root_query.rb +80 -0
- data/lib/activetree/tree_node.rb +52 -0
- data/lib/activetree/tree_state.rb +209 -0
- data/lib/activetree/version.rb +5 -0
- data/lib/activetree.rb +43 -0
- data/sig/activetree.rbs +4 -0
- metadata +164 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bd1bf8f791ab4c7267e442ce325f4714694ea63089d089cc6aaa5094148d0acc
|
|
4
|
+
data.tar.gz: 03a9e3643d4f63f8788ad555d6b5ba38f28d9dea36517bd727c4b245fcccde01
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d8a8eca4520e9029783567d54aa3977f339b8ae78c81937d479b126d5abe926074d27c4aeee1773edfaded161f775884aa19019033c57746f0afcd3c14a2a6ce
|
|
7
|
+
data.tar.gz: b9d90553114dbc31b29cfefac6abae86749bb7a9f4d3ca38e6d9c8514018499c90e5815f56466f217afe962ccf65380b95dbbbb2ddcbf2d5bf60ff8b6d3e1642
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 3.1
|
|
3
|
+
NewCops: enable
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
|
|
6
|
+
Style/StringLiterals:
|
|
7
|
+
EnforcedStyle: double_quotes
|
|
8
|
+
|
|
9
|
+
Style/StringLiteralsInInterpolation:
|
|
10
|
+
EnforcedStyle: double_quotes
|
|
11
|
+
|
|
12
|
+
Style/Documentation:
|
|
13
|
+
Enabled: false
|
|
14
|
+
|
|
15
|
+
Metrics/BlockLength:
|
|
16
|
+
Exclude:
|
|
17
|
+
- "spec/**/*"
|
|
18
|
+
- "*.gemspec"
|
|
19
|
+
|
|
20
|
+
Metrics/ClassLength:
|
|
21
|
+
Exclude:
|
|
22
|
+
- "lib/activetree/renderer.rb"
|
|
23
|
+
- "lib/activetree/tree_state.rb"
|
|
24
|
+
- "lib/activetree/cli.rb"
|
|
25
|
+
|
|
26
|
+
Metrics/MethodLength:
|
|
27
|
+
Exclude:
|
|
28
|
+
- "lib/activetree/renderer.rb"
|
|
29
|
+
- "lib/activetree/cli.rb"
|
|
30
|
+
- "lib/activetree/tree_state.rb"
|
|
31
|
+
- "lib/activetree/dialog_input_handler.rb"
|
|
32
|
+
- "lib/activetree/dialog_renderer.rb"
|
|
33
|
+
- "lib/activetree/root_query.rb"
|
|
34
|
+
|
|
35
|
+
Metrics/AbcSize:
|
|
36
|
+
Exclude:
|
|
37
|
+
- "lib/activetree/renderer.rb"
|
|
38
|
+
- "lib/activetree/tree_state.rb"
|
|
39
|
+
- "lib/activetree/cli.rb"
|
|
40
|
+
- "lib/activetree/dialog_renderer.rb"
|
|
41
|
+
|
|
42
|
+
Metrics/CyclomaticComplexity:
|
|
43
|
+
Exclude:
|
|
44
|
+
- "lib/activetree/renderer.rb"
|
|
45
|
+
- "lib/activetree/tree_state.rb"
|
|
46
|
+
- "lib/activetree/cli.rb"
|
|
47
|
+
- "lib/activetree/dialog_input_handler.rb"
|
|
48
|
+
|
|
49
|
+
Metrics/PerceivedComplexity:
|
|
50
|
+
Exclude:
|
|
51
|
+
- "lib/activetree/renderer.rb"
|
|
52
|
+
- "lib/activetree/tree_state.rb"
|
|
53
|
+
|
|
54
|
+
Metrics/ParameterLists:
|
|
55
|
+
Exclude:
|
|
56
|
+
- "lib/activetree/renderer.rb"
|
|
57
|
+
- "lib/activetree/association_group_node.rb"
|
|
58
|
+
|
|
59
|
+
Naming/AccessorMethodName:
|
|
60
|
+
Exclude:
|
|
61
|
+
- "lib/activetree/tree_state.rb"
|
data/CHANGELOG.md
ADDED
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# ActiveTree
|
|
2
|
+
|
|
3
|
+
A tree-based admin interface for ActiveRecord, built as a Ruby gem. Currently a split-pane TUI rendered with `tty-box`, `tty-screen`, and `pastel`; planned evolution into a mountable Rails Engine.
|
|
4
|
+
|
|
5
|
+
## Naming
|
|
6
|
+
|
|
7
|
+
- **Module:** `ActiveTree` (capital T) — matches Rails conventions (`ActiveRecord`, `ActiveSupport`)
|
|
8
|
+
- **Gem name / file paths:** `activetree` (lowercase, no separator) — per RubyGems convention
|
|
9
|
+
- Never use `Activetree` (that's what `bundle gem` scaffolds, and we renamed it)
|
|
10
|
+
|
|
11
|
+
## Architecture
|
|
12
|
+
|
|
13
|
+
**Runtime architecture:** CLI → TreeState → Renderer loop. TreeState owns a tree of nodes (`TreeNode` base → `RecordNode`, `AssociationGroupNode`, `LoadMoreNode`, `QueryResultsNode`/`ListNode`). The `ActiveTree::Model` concern provides per-model DSL (`tree_fields`, `tree_children`, `tree_label`). InputHandler reads raw keypresses; Renderer composes a full-screen frame each tick using two side-by-side `TTY::Box.frame` calls (tree pane + detail pane) with absolute positioning, plus a cursor-positioned footer. Screen is cleared (`\e[H\e[J`) before each frame to prevent stale content. The two panes share a focus model toggled by Tab — the focused pane gets a magenta border while the unfocused pane dims to bright-black. Each pane scrolls independently (`scroll_offset` for tree, `detail_scroll_offset` for detail) and renders a `░`/`█` scrollbar when content overflows.
|
|
14
|
+
|
|
15
|
+
**Query mode:** Pressing `q` (or launching with no args) opens a dialog overlay. `Dialog` is the base class (manages fields, focus, submission); `QueryDialog` specializes it with model/query fields. `DialogField` handles text input with cursor navigation; `DialogInputHandler` maps keystrokes to dialog actions; `DialogRenderer` draws the centered overlay box. `RootQuery` validates input, executes the query (numeric ID via `find` or DSL expression via `instance_eval` on the model relation), and determines cardinality — single results become a `RecordNode` root, multiple results become a `QueryResultsNode` (extends `ListNode` with pagination).
|
|
16
|
+
|
|
17
|
+
**Engine upgrade path:** The Railtie is designed to swap its superclass to `Rails::Engine`, add `isolate_namespace`, and gain `config/routes.rb` + `app/` directories. Existing initializer and rake blocks transfer unchanged.
|
|
18
|
+
|
|
19
|
+
## Code style
|
|
20
|
+
|
|
21
|
+
- Ruby >= 3.1, double-quoted strings everywhere
|
|
22
|
+
- RuboCop with `NewCops: enable`, `Style/Documentation` disabled, `Metrics/*` cops excluded for `renderer.rb` and `tree_state.rb` (most cops), `cli.rb` (`MethodLength` only) — intentional, these files are inherently complex TUI code
|
|
23
|
+
- `frozen_string_literal: true` in every `.rb` file
|
|
24
|
+
- RSpec for tests (`bundle exec rspec`), RuboCop for linting (`bundle exec rubocop`)
|
|
25
|
+
|
|
26
|
+
## Key conventions
|
|
27
|
+
|
|
28
|
+
- `spec.files` uses `git ls-files` — new files must be git-tracked before `gem build` will include them
|
|
29
|
+
- Model discovery uses `config.after_initialize` (not `initializer`) because models aren't fully loaded during Rails initialization in development
|
|
30
|
+
- `Gemfile.lock` is committed (Bundler recommends tracking it for gems and apps alike)
|
|
31
|
+
- All runtime dependencies are explicitly declared in the gemspec — unused tty-* gems (`tty-table`, `tty-tree`, `tty-prompt`, `tty-cursor`) have been removed, and `pastel` + `strings-ansi` are now direct dependencies
|
|
32
|
+
|
|
33
|
+
## Dependencies
|
|
34
|
+
|
|
35
|
+
Runtime: `activerecord >= 7.0`, `railties >= 7.0`, `tty-box`, `tty-screen`, `pastel`, `strings-ansi`
|
|
36
|
+
Dev: `rspec`, `rubocop`, `rake`, `irb`
|
|
37
|
+
|
|
38
|
+
## Commands
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bin/setup # Install dependencies
|
|
42
|
+
bundle exec rspec # Run tests
|
|
43
|
+
bundle exec rubocop # Lint
|
|
44
|
+
ruby -Ilib exe/activetree # Run TUI standalone (outside bundler)
|
|
45
|
+
bundle exec activetree # Run TUI via bundler
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Repository
|
|
49
|
+
|
|
50
|
+
GitHub org is `babylist` — https://github.com/babylist/activetree
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Baby List, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# ActiveTree
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your application's Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "activetree"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
### Launching the TUI
|
|
22
|
+
|
|
23
|
+
ActiveTree browses a single root record and its association tree. Pass a model name and a query:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Within a Rails app — browse a specific record by ID
|
|
27
|
+
bin/rails "activetree:tree[User,42]"
|
|
28
|
+
bundle exec activetree User 42
|
|
29
|
+
|
|
30
|
+
# Query with ActiveRecord DSL expressions
|
|
31
|
+
bundle exec activetree User "where(active: true)"
|
|
32
|
+
bundle exec activetree Order "where(status: 'pending').limit(10)"
|
|
33
|
+
|
|
34
|
+
# No arguments — opens an interactive query dialog
|
|
35
|
+
bundle exec activetree
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The TUI opens a full-screen split-pane interface:
|
|
39
|
+
- **Left pane** — navigable tree with expand/collapse for associations
|
|
40
|
+
- **Right pane** — field/value detail view for the selected record
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
Tree nodes use disclosure icons to communicate expand/collapse state and whether children have been loaded from the database:
|
|
45
|
+
|
|
46
|
+
| Icon | Meaning |
|
|
47
|
+
|------|---------|
|
|
48
|
+
| ▶ | Collapsed, children already loaded |
|
|
49
|
+
| ▼ | Expanded, children already loaded |
|
|
50
|
+
| ▷ | Collapsed, children not yet loaded |
|
|
51
|
+
| ▽ | Expanded, children not yet loaded |
|
|
52
|
+
|
|
53
|
+
### Key Bindings
|
|
54
|
+
|
|
55
|
+
| Key | Action |
|
|
56
|
+
|-----|--------|
|
|
57
|
+
| `j` / `Down` | Move cursor down (tree) or scroll down (detail) |
|
|
58
|
+
| `k` / `Up` | Move cursor up (tree) or scroll up (detail) |
|
|
59
|
+
| `l` / `Right` | **Tree:** expand collapsed node → descend into expanded node → select leaf & switch to detail. **Detail:** no-op |
|
|
60
|
+
| `h` / `Left` | **Tree:** collapse expanded node → jump to parent. **Detail:** switch focus back to tree |
|
|
61
|
+
| `Tab` | Switch focus between tree and detail panes |
|
|
62
|
+
| `Space` | Expand / collapse node |
|
|
63
|
+
| `Enter` | Select record (show details in right pane) |
|
|
64
|
+
| `f` | Toggle field mode (configured fields vs. all columns) |
|
|
65
|
+
| `r` | Make selected record the new root |
|
|
66
|
+
| `q` | Open query dialog |
|
|
67
|
+
| `Ctrl-C` | Quit |
|
|
68
|
+
|
|
69
|
+
### Query Mode
|
|
70
|
+
|
|
71
|
+
Press `q` at any time (or launch with no arguments) to open the query dialog. The dialog presents two fields:
|
|
72
|
+
|
|
73
|
+
- **Model class** — the ActiveRecord model name (e.g. `User`, `Order`)
|
|
74
|
+
- **Query** — a numeric ID or an ActiveRecord DSL expression (e.g. `42`, `where(active: true)`, `where(status: 'pending').order(:created_at)`)
|
|
75
|
+
|
|
76
|
+
Use `Tab` to move between fields, `Enter` to submit, and `Esc` to cancel and return to the current tree.
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
If the model isn't found or the query returns no results, an error message appears in the dialog.
|
|
81
|
+
|
|
82
|
+
### Field Mode
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
|
|
88
|
+
Boolean fields are rendered with visual indicators: `true` displays as a green ✓ and `false` as a red ✗.
|
|
89
|
+
|
|
90
|
+
### Configuring Models
|
|
91
|
+
|
|
92
|
+
Include `ActiveTree::Model` in your AR models to control what appears in the TUI:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class User < ApplicationRecord
|
|
96
|
+
include ActiveTree::Model
|
|
97
|
+
|
|
98
|
+
tree_fields :id, :email, :name, :created_at
|
|
99
|
+
tree_children :orders, :profile
|
|
100
|
+
tree_label { |record| "#{record.name} (#{record.email})" }
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Singular forms accept keyword options:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
class Order < ApplicationRecord
|
|
108
|
+
include ActiveTree::Model
|
|
109
|
+
|
|
110
|
+
tree_field :id
|
|
111
|
+
tree_field :status, label: "Order Status"
|
|
112
|
+
tree_child :line_items, label: "Items"
|
|
113
|
+
tree_child :shipments
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The plural forms also accept inline option hashes to customize individual entries:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
class User < ApplicationRecord
|
|
121
|
+
include ActiveTree::Model
|
|
122
|
+
|
|
123
|
+
tree_fields :id, :email, { name: { label: "Full Name" } }, :created_at
|
|
124
|
+
tree_children :orders, { shipments: { label: "User Shipments" } }
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Scoping Child Relations
|
|
129
|
+
|
|
130
|
+
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:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
class User < ApplicationRecord
|
|
134
|
+
include ActiveTree::Model
|
|
135
|
+
|
|
136
|
+
tree_child :comments, -> { approved }, label: "Approved Comments"
|
|
137
|
+
tree_child :orders, -> { where(status: "active") }
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The `tree_children` hash form supports `scope:` as well:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
class User < ApplicationRecord
|
|
145
|
+
include ActiveTree::Model
|
|
146
|
+
|
|
147
|
+
tree_children :orders, { comments: { scope: -> { approved }, label: "Approved" } }
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
| Method | Default | Description |
|
|
154
|
+
|-----------|---------|-------------|
|
|
155
|
+
| `tree_fields` | `:id` only | Fields shown in the detail pane (batch) |
|
|
156
|
+
| `tree_field` | — | Add a single field with keyword options (`label:`) |
|
|
157
|
+
| `tree_children` | None | Associations expandable as tree children (batch) |
|
|
158
|
+
| `tree_child` | — | Add a single child with options (`label:`, positional scope proc) |
|
|
159
|
+
| `tree_label` | `-> (record) { "#{record.class.name} ##{record.id}" }` | Custom label block for tree nodes and detail pane |
|
|
160
|
+
|
|
161
|
+
Models **without** the mixin still appear in the tree if referenced as children of another model, using the defaults above.
|
|
162
|
+
|
|
163
|
+
### Centralized Configuration via DSL
|
|
164
|
+
|
|
165
|
+
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:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
# config/initializers/activetree.rb
|
|
169
|
+
ActiveTree.configure do
|
|
170
|
+
max_depth 5
|
|
171
|
+
default_limit 50
|
|
172
|
+
|
|
173
|
+
model "User" do
|
|
174
|
+
fields :id, :email, :name, :created_at
|
|
175
|
+
children :orders, :profile
|
|
176
|
+
label { |record| "#{record.name} (#{record.email})" }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
model "Order" do
|
|
180
|
+
field :id
|
|
181
|
+
field :status, label: "Order Status"
|
|
182
|
+
child :line_items, label: "Items"
|
|
183
|
+
child :shipments
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
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:
|
|
189
|
+
|
|
190
|
+
| DSL Method | Equivalent Concern Method | Description |
|
|
191
|
+
|-----------|--------------------------|-------------|
|
|
192
|
+
| `field :name, label: "..."` | `tree_field` | Add a single field |
|
|
193
|
+
| `fields :id, :email, ...` | `tree_fields` | Add multiple fields |
|
|
194
|
+
| `child :orders, scope, label: "..."` | `tree_child` | Add a single child (optional scope proc + label) |
|
|
195
|
+
| `children :orders, :shipments` | `tree_children` | Add multiple children |
|
|
196
|
+
| `label { \|r\| ... }` | `tree_label` | Custom label block |
|
|
197
|
+
|
|
198
|
+
#### Merging with the Model Concern
|
|
199
|
+
|
|
200
|
+
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:
|
|
201
|
+
|
|
202
|
+
### Global Options
|
|
203
|
+
|
|
204
|
+
| Option | Default | Description |
|
|
205
|
+
|--------|---------|-------------|
|
|
206
|
+
| `max_depth` | `3` | Maximum nesting depth for associations NOT YET IMPLEMENTED |
|
|
207
|
+
| `default_limit` | `25` | Max records loaded per has_many expansion (paginated) |
|
|
208
|
+
| `global_scope` | `nil` | A proc merged into every relation ActiveTree queries (see below) |
|
|
209
|
+
|
|
210
|
+
#### Global Scope
|
|
211
|
+
|
|
212
|
+
`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.
|
|
213
|
+
|
|
214
|
+
The proc is merged onto each relation via `ActiveRecord::Relation#merge`, so named scopes and query methods work naturally:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
# DSL style
|
|
218
|
+
ActiveTree.configure do
|
|
219
|
+
global_scope { where(organization_id: Current.organization_id) }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Direct assignment
|
|
223
|
+
ActiveTree.config.global_scope = -> { where(deleted_at: nil) }
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
When a child association also has its own scope, both are applied — global scope first, then the per-child scope:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
ActiveTree.configure do
|
|
230
|
+
global_scope { where(organization_id: Current.organization_id) }
|
|
231
|
+
|
|
232
|
+
model "User" do
|
|
233
|
+
# The final relation for orders will have both the org filter AND the status filter
|
|
234
|
+
child :orders, -> { where(status: "active") }, label: "Active Orders"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Pagination
|
|
240
|
+
|
|
241
|
+
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.
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
245
|
+
## Development
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
bin/setup # Install dependencies
|
|
249
|
+
bundle exec rspec # Run tests
|
|
250
|
+
bundle exec rubocop # Lint
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Contributing
|
|
254
|
+
|
|
255
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/babylist/activetree.
|
|
256
|
+
|
|
257
|
+
## License
|
|
258
|
+
|
|
259
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/exe/activetree
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Boot the host Rails application, then launch the ActiveTree TUI.
|
|
5
|
+
# Must be run from the root of a Rails app:
|
|
6
|
+
# bundle exec activetree User 42
|
|
7
|
+
|
|
8
|
+
env_file = File.expand_path("config/environment.rb", Dir.pwd)
|
|
9
|
+
|
|
10
|
+
unless File.exist?(env_file)
|
|
11
|
+
warn "Error: could not find config/environment.rb in #{Dir.pwd}"
|
|
12
|
+
warn ""
|
|
13
|
+
warn "activetree must be run from the root of a Rails application."
|
|
14
|
+
exit 1
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
puts "Loading Rails environment..."
|
|
19
|
+
require env_file
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
warn "Error: failed to load Rails environment"
|
|
22
|
+
warn " #{e.class}: #{e.message}"
|
|
23
|
+
exit 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
require "activetree"
|
|
27
|
+
require "activetree/cli"
|
|
28
|
+
|
|
29
|
+
ActiveTree::CLI.start(ARGV)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTree
|
|
4
|
+
class AssociationGroupNode < ListNode
|
|
5
|
+
attr_reader :record, :association_name, :reflection
|
|
6
|
+
|
|
7
|
+
def initialize(record:, association_name:, reflection:, tree_state: nil, depth: 0, parent: nil)
|
|
8
|
+
super(relation: nil, tree_state: tree_state, depth: depth, parent: parent)
|
|
9
|
+
@record = record
|
|
10
|
+
@association_name = association_name
|
|
11
|
+
@reflection = reflection
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def label
|
|
15
|
+
if singular?
|
|
16
|
+
association_configuration.label
|
|
17
|
+
else
|
|
18
|
+
"#{association_configuration.label}#{count_label}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def load_children!
|
|
23
|
+
if singular?
|
|
24
|
+
@children = []
|
|
25
|
+
@loaded = true
|
|
26
|
+
load_singular_association
|
|
27
|
+
else
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def base_relation
|
|
33
|
+
apply_scope(record.public_send(association_name))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def singular?
|
|
39
|
+
%i[has_one belongs_to].include?(reflection.macro)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def load_singular_association
|
|
43
|
+
associated = if association_configuration&.scope || ActiveTree.config.global_scope
|
|
44
|
+
apply_scope(record.association(association_name).scope).first
|
|
45
|
+
else
|
|
46
|
+
record.public_send(association_name)
|
|
47
|
+
end
|
|
48
|
+
return unless associated
|
|
49
|
+
|
|
50
|
+
@children << build_record_node(associated)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_scope(relation)
|
|
54
|
+
relation = relation.merge(ActiveTree.config.global_scope) if ActiveTree.config.global_scope
|
|
55
|
+
return relation unless association_configuration&.scope
|
|
56
|
+
|
|
57
|
+
relation.merge(association_configuration.scope)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def association_configuration
|
|
61
|
+
@association_configuration ||= ActiveTree.config.model_configuration(record.class).children[association_name]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTree
|
|
4
|
+
module CharReader
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def read_char
|
|
8
|
+
@input.raw(min: 1) do |io|
|
|
9
|
+
char = io.getc
|
|
10
|
+
return nil unless char
|
|
11
|
+
return read_escape_sequence(char, io) if char == "\e"
|
|
12
|
+
|
|
13
|
+
char
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def read_escape_sequence(char, io)
|
|
18
|
+
second = safe_read(io)
|
|
19
|
+
third = safe_read(io)
|
|
20
|
+
return "#{char}#{second}#{third}" if second
|
|
21
|
+
|
|
22
|
+
char
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def safe_read(io)
|
|
26
|
+
io.read_nonblock(1)
|
|
27
|
+
rescue StandardError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveTree
|
|
4
|
+
class CLI
|
|
5
|
+
DISPATCH = {
|
|
6
|
+
toggle_focus: :toggle_focus,
|
|
7
|
+
navigate_right: :navigate_right,
|
|
8
|
+
navigate_left: :navigate_left,
|
|
9
|
+
move_up: :cursor_up,
|
|
10
|
+
move_down: :cursor_down,
|
|
11
|
+
toggle_expand: :toggle_expand,
|
|
12
|
+
select: :select_current,
|
|
13
|
+
make_root: :make_selected_record_root,
|
|
14
|
+
toggle_field_mode: :toggle_field_mode
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def self.start(argv = [])
|
|
18
|
+
puts Pastel.new.magenta.bold("Starting ActiveTree v#{ActiveTree::VERSION}...")
|
|
19
|
+
new(argv).run
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(argv = [])
|
|
23
|
+
@argv = argv
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
state = TreeState.new
|
|
28
|
+
renderer = Renderer.new(state)
|
|
29
|
+
input = InputHandler.new
|
|
30
|
+
dialog_input = DialogInputHandler.new
|
|
31
|
+
|
|
32
|
+
# Try to resolve root from CLI args
|
|
33
|
+
apply_query_result(RootQuery.new(@argv[0], @argv[1]), state) if @argv.size >= 2
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
enter_alternate_screen
|
|
37
|
+
if state.empty?
|
|
38
|
+
# Fall through to query dialog if no root node was resolved from args
|
|
39
|
+
open_query_dialog(state, renderer, dialog_input)
|
|
40
|
+
return unless state.root
|
|
41
|
+
end
|
|
42
|
+
main_loop(state, renderer, input, dialog_input)
|
|
43
|
+
ensure
|
|
44
|
+
exit_alternate_screen
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def main_loop(state, renderer, input, dialog_input)
|
|
51
|
+
loop do
|
|
52
|
+
$stdout.print renderer.render
|
|
53
|
+
$stdout.flush
|
|
54
|
+
|
|
55
|
+
action = input.read_action
|
|
56
|
+
break if action == :quit
|
|
57
|
+
|
|
58
|
+
if action == :open_query_dialog
|
|
59
|
+
open_query_dialog(state, renderer, dialog_input)
|
|
60
|
+
else
|
|
61
|
+
dispatch(action, state)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def dispatch(action, state)
|
|
67
|
+
state.public_send(DISPATCH[action]) if DISPATCH[action]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def open_query_dialog(state, renderer, dialog_input)
|
|
71
|
+
dialog = QueryDialog.new
|
|
72
|
+
loop do
|
|
73
|
+
dialog_loop(dialog, renderer, dialog_input)
|
|
74
|
+
return if dialog.cancelled?
|
|
75
|
+
|
|
76
|
+
begin
|
|
77
|
+
apply_query_result(dialog.root_query, state)
|
|
78
|
+
return
|
|
79
|
+
rescue ArgumentError => e
|
|
80
|
+
dialog.error_message = e.message
|
|
81
|
+
reset_dialog_for_retry(dialog)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def dialog_loop(dialog, renderer, dialog_input)
|
|
87
|
+
loop do
|
|
88
|
+
$stdout.print renderer.render(dialog: dialog)
|
|
89
|
+
$stdout.flush
|
|
90
|
+
|
|
91
|
+
action = dialog_input.read_action
|
|
92
|
+
next unless action
|
|
93
|
+
|
|
94
|
+
case action
|
|
95
|
+
when :cancel
|
|
96
|
+
dialog.cancel!
|
|
97
|
+
when :submit
|
|
98
|
+
dialog.error_message = nil
|
|
99
|
+
dialog.submit!
|
|
100
|
+
when :next_field
|
|
101
|
+
dialog.next_field
|
|
102
|
+
when :backspace
|
|
103
|
+
dialog.backspace
|
|
104
|
+
when :cursor_left
|
|
105
|
+
dialog.cursor_left
|
|
106
|
+
when :cursor_right
|
|
107
|
+
dialog.cursor_right
|
|
108
|
+
when Array
|
|
109
|
+
dialog.insert_char(action[1]) if action[0] == :insert
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
break if dialog.resolved?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def apply_query_result(root_query, state)
|
|
117
|
+
state.set_root_node(root_query.as_tree_node)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def reset_dialog_for_retry(dialog)
|
|
121
|
+
# Reset submitted/cancelled state so dialog can be re-shown
|
|
122
|
+
dialog.instance_variable_set(:@submitted, false)
|
|
123
|
+
dialog.instance_variable_set(:@cancelled, false)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def enter_alternate_screen
|
|
127
|
+
$stdout.print "\e[?1049h" # alternate screen buffer
|
|
128
|
+
$stdout.print "\e[?25l" # hide cursor
|
|
129
|
+
$stdout.flush
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def exit_alternate_screen
|
|
133
|
+
$stdout.print "\e[?25h" # show cursor
|
|
134
|
+
$stdout.print "\e[?1049l" # restore screen
|
|
135
|
+
$stdout.flush
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|