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 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
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0.pre1] - 2026-03-06
4
+
5
+ - Initial development release
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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
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