ratamin 0.1.0
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/.github/workflows/ci.yml +21 -0
- data/.gitmodules +3 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +109 -0
- data/Rakefile +8 -0
- data/examples/demo.rb +29 -0
- data/lib/ratamin/app.rb +158 -0
- data/lib/ratamin/array_data_source.rb +29 -0
- data/lib/ratamin/column.rb +9 -0
- data/lib/ratamin/data_source.rb +24 -0
- data/lib/ratamin/editor_bridge.rb +38 -0
- data/lib/ratamin/form_state.rb +153 -0
- data/lib/ratamin/form_view.rb +97 -0
- data/lib/ratamin/scope_data_source.rb +126 -0
- data/lib/ratamin/table_view.rb +113 -0
- data/lib/ratamin/update_result.rb +20 -0
- data/lib/ratamin/version.rb +5 -0
- data/lib/ratamin.rb +11 -0
- data/sig/ratamin.rbs +4 -0
- metadata +92 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 657e9468e290eff1deeeb657db061a98dca992bece11887421737f1094bbfa28
|
|
4
|
+
data.tar.gz: 2d0672f141d7cd9596ccd108e848a0fbd7418f77de46b3f67e6a2aa6a1efe7b9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 93d38f02ff57591d942ed0f463ed5098a46e084a1270b0aadbdf6b1f3496d23e7a4ec5a30c95f6c6fec23baa92dbe63ee82a5763991d4b4be7ae8cbf4d7dba98
|
|
7
|
+
data.tar.gz: 15cf1c96fd1193292b98ef73626ea02859ee34c5d6f565c612a93a3d3adddaa62e1b3796185dadb2caa185e7fff0eddd4c43eade57a3e49560163a42fa813acb
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
ruby: ["3.3", "3.4", "4.0"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: ruby/setup-ruby@v1
|
|
18
|
+
with:
|
|
19
|
+
ruby-version: ${{ matrix.ruby }}
|
|
20
|
+
bundler-cache: true
|
|
21
|
+
- run: bundle exec rspec
|
data/.gitmodules
ADDED
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 merely
|
|
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,109 @@
|
|
|
1
|
+
# Ratamin
|
|
2
|
+
|
|
3
|
+
A TUI admin console for ActiveRecord, built on [ratatui_ruby](https://github.com/nicholasgasior/ratatui_ruby).
|
|
4
|
+
|
|
5
|
+
Give it a scope, get a navigable table with inline editing and `$EDITOR` support for long text.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "ratamin"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### With ActiveRecord
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require "ratamin"
|
|
21
|
+
|
|
22
|
+
# Edit all users
|
|
23
|
+
Ratamin::App.new(Ratamin::ScopeDataSource.new(User.all)).run
|
|
24
|
+
|
|
25
|
+
# Edit a filtered scope
|
|
26
|
+
Ratamin::App.new(Ratamin::ScopeDataSource.new(User.where(role: "admin"))).run
|
|
27
|
+
|
|
28
|
+
# Specify which columns to show
|
|
29
|
+
columns = [
|
|
30
|
+
Ratamin::Column.new(key: :name, label: "Name", width: 20),
|
|
31
|
+
Ratamin::Column.new(key: :email, label: "Email", width: 30),
|
|
32
|
+
Ratamin::Column.new(key: :bio, label: "Bio", type: :text),
|
|
33
|
+
]
|
|
34
|
+
Ratamin::App.new(Ratamin::ScopeDataSource.new(User.all, columns: columns)).run
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Without ActiveRecord
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
require "ratamin"
|
|
41
|
+
|
|
42
|
+
columns = [
|
|
43
|
+
Ratamin::Column.new(key: :name, width: 20),
|
|
44
|
+
Ratamin::Column.new(key: :email, width: 30),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
rows = [
|
|
48
|
+
{ name: "Alice", email: "alice@example.com" },
|
|
49
|
+
{ name: "Bob", email: "bob@example.com" },
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
ds = Ratamin::ArrayDataSource.new(columns: columns, rows: rows)
|
|
53
|
+
Ratamin::App.new(ds).run
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Keybindings
|
|
57
|
+
|
|
58
|
+
### Table view
|
|
59
|
+
|
|
60
|
+
| Key | Action |
|
|
61
|
+
|-----|--------|
|
|
62
|
+
| `j` / `Down` | Next row |
|
|
63
|
+
| `k` / `Up` | Previous row |
|
|
64
|
+
| `g` | First row |
|
|
65
|
+
| `G` | Last row |
|
|
66
|
+
| `Enter` | Edit selected record |
|
|
67
|
+
| `r` | Reload data |
|
|
68
|
+
| `q` | Quit |
|
|
69
|
+
|
|
70
|
+
### Form view
|
|
71
|
+
|
|
72
|
+
| Key | Action |
|
|
73
|
+
|-----|--------|
|
|
74
|
+
| `Tab` | Next field |
|
|
75
|
+
| `Shift+Tab` | Previous field |
|
|
76
|
+
| `Enter` | Save |
|
|
77
|
+
| `Esc` | Cancel |
|
|
78
|
+
| `e` | Open `$EDITOR` (on `:text` fields) |
|
|
79
|
+
|
|
80
|
+
## Architecture
|
|
81
|
+
|
|
82
|
+
Ratamin separates data, state, and rendering:
|
|
83
|
+
|
|
84
|
+
- **DataSource** — duck-type protocol (`#columns`, `#rows`, `#update_row`, `#reload!`)
|
|
85
|
+
- `ArrayDataSource` — in-memory arrays
|
|
86
|
+
- `ScopeDataSource` — wraps an ActiveRecord scope
|
|
87
|
+
- **State** — pure Ruby, no TUI dependency, fully unit-testable
|
|
88
|
+
- `FormState` — field values, cursor, text editing, validation errors
|
|
89
|
+
- **Views** — thin renderers that read from state objects
|
|
90
|
+
- `TableView` — record list with selection
|
|
91
|
+
- `FormView` — field-by-field editor
|
|
92
|
+
|
|
93
|
+
## Column types
|
|
94
|
+
|
|
95
|
+
| Type | Behavior |
|
|
96
|
+
|------|----------|
|
|
97
|
+
| `:string` (default) | Inline text editing |
|
|
98
|
+
| `:text` | Truncated in table, opens `$EDITOR` for editing |
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
bin/setup
|
|
104
|
+
bundle exec rspec
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
data/Rakefile
ADDED
data/examples/demo.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../lib/ratamin"
|
|
5
|
+
|
|
6
|
+
columns = [
|
|
7
|
+
Ratamin::Column.new(key: :id, label: "ID", width: 6),
|
|
8
|
+
Ratamin::Column.new(key: :name, label: "Name", width: 20),
|
|
9
|
+
Ratamin::Column.new(key: :email, label: "Email", width: 30),
|
|
10
|
+
Ratamin::Column.new(key: :role, label: "Role", width: 10),
|
|
11
|
+
Ratamin::Column.new(key: :bio, label: "Bio", type: :text)
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
rows = [
|
|
15
|
+
{id: 1, name: "Alice", email: "alice@example.com", role: "admin", bio: "Software engineer with 10 years of experience in distributed systems and cloud infrastructure."},
|
|
16
|
+
{id: 2, name: "Bob", email: "bob@example.com", role: "user", bio: "Designer"},
|
|
17
|
+
{id: 3, name: "Charlie", email: "charlie@example.com", role: "user", bio: "Product manager focused on developer tools and platform engineering."},
|
|
18
|
+
{id: 4, name: "Diana", email: "diana@example.com", role: "moderator", bio: "Community lead"},
|
|
19
|
+
{id: 5, name: "Eve", email: "eve@example.com", role: "admin", bio: "Security researcher specializing in cryptographic protocols and zero-knowledge proofs."},
|
|
20
|
+
{id: 6, name: "Frank", email: "frank@example.com", role: "user", bio: "Full-stack developer"},
|
|
21
|
+
{id: 7, name: "Grace", email: "grace@example.com", role: "user", bio: "Data scientist working on ML pipelines and recommendation systems."},
|
|
22
|
+
{id: 8, name: "Hank", email: "hank@example.com", role: "moderator", bio: "DevOps engineer"},
|
|
23
|
+
{id: 9, name: "Ivy", email: "ivy@example.com", role: "user", bio: "Frontend developer passionate about accessibility and design systems."},
|
|
24
|
+
{id: 10, name: "Jack", email: "jack@example.com", role: "user", bio: "Backend engineer focused on high-performance APIs and database optimization."},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
data_source = Ratamin::ArrayDataSource.new(columns: columns, rows: rows)
|
|
28
|
+
app = Ratamin::App.new(data_source)
|
|
29
|
+
app.run
|
data/lib/ratamin/app.rb
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ratamin
|
|
4
|
+
class App
|
|
5
|
+
attr_reader :data_source, :mode, :table_view, :form_state, :form_view
|
|
6
|
+
|
|
7
|
+
def initialize(data_source)
|
|
8
|
+
@data_source = data_source
|
|
9
|
+
@table_view = TableView.new(data_source)
|
|
10
|
+
@form_state = nil
|
|
11
|
+
@form_view = nil
|
|
12
|
+
@mode = :table
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
RatatuiRuby.run do |tui|
|
|
17
|
+
loop do
|
|
18
|
+
tui.draw { |frame| render(tui, frame) }
|
|
19
|
+
|
|
20
|
+
event = tui.poll_event
|
|
21
|
+
result = handle_event(event)
|
|
22
|
+
break if result == :quit
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def render(tui, frame)
|
|
30
|
+
body_area, status_area = RatatuiRuby::Layout::Layout.split(
|
|
31
|
+
frame.area,
|
|
32
|
+
direction: :vertical,
|
|
33
|
+
constraints: [
|
|
34
|
+
RatatuiRuby::Layout::Constraint.fill(1),
|
|
35
|
+
RatatuiRuby::Layout::Constraint.length(1)
|
|
36
|
+
]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
case @mode
|
|
40
|
+
when :table
|
|
41
|
+
@table_view.render(tui, frame, body_area)
|
|
42
|
+
render_status_bar(tui, frame, status_area, table_help)
|
|
43
|
+
when :form
|
|
44
|
+
@form_view.render(tui, frame, body_area)
|
|
45
|
+
render_status_bar(tui, frame, status_area, form_help)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_status_bar(tui, frame, area, text)
|
|
50
|
+
bar = tui.paragraph(
|
|
51
|
+
text: text,
|
|
52
|
+
style: {fg: :black, bg: :cyan}
|
|
53
|
+
)
|
|
54
|
+
frame.render_widget(bar, area)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def table_help
|
|
58
|
+
help = " q:quit j/↓:down k/↑:up g:first G:last enter:edit r:reload"
|
|
59
|
+
help += " n:next page p:prev page" if @data_source.paginated?
|
|
60
|
+
help + " "
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def form_help
|
|
64
|
+
" Tab:next field Shift+Tab:prev Enter:save Esc:cancel "
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def handle_event(event)
|
|
68
|
+
case @mode
|
|
69
|
+
when :table then handle_table_event(event)
|
|
70
|
+
when :form then handle_form_event(event)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def handle_table_event(event)
|
|
75
|
+
case event
|
|
76
|
+
in {type: :key, code: "q"} | {type: :key, code: "c", modifiers: ["ctrl"]}
|
|
77
|
+
:quit
|
|
78
|
+
in {type: :key, code: "j"} | {type: :key, code: "down"}
|
|
79
|
+
@table_view.select_next
|
|
80
|
+
nil
|
|
81
|
+
in {type: :key, code: "k"} | {type: :key, code: "up"}
|
|
82
|
+
@table_view.select_previous
|
|
83
|
+
nil
|
|
84
|
+
in {type: :key, code: "g"}
|
|
85
|
+
@table_view.select_first
|
|
86
|
+
nil
|
|
87
|
+
in {type: :key, code: "G"} | {type: :key, code: "g", modifiers: ["shift"]}
|
|
88
|
+
@table_view.select_last
|
|
89
|
+
nil
|
|
90
|
+
in {type: :key, code: "enter"}
|
|
91
|
+
enter_form
|
|
92
|
+
nil
|
|
93
|
+
in {type: :key, code: "n"} if @data_source.paginated?
|
|
94
|
+
@data_source.next_page
|
|
95
|
+
@table_view.reset_selection
|
|
96
|
+
nil
|
|
97
|
+
in {type: :key, code: "p"} if @data_source.paginated?
|
|
98
|
+
@data_source.prev_page
|
|
99
|
+
@table_view.reset_selection
|
|
100
|
+
nil
|
|
101
|
+
in {type: :key, code: "r"}
|
|
102
|
+
@data_source.reload!
|
|
103
|
+
@table_view.reset_selection
|
|
104
|
+
nil
|
|
105
|
+
else
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_form_event(event)
|
|
111
|
+
result = @form_state.handle_event(event)
|
|
112
|
+
case result
|
|
113
|
+
when :save
|
|
114
|
+
save_form
|
|
115
|
+
when :cancel
|
|
116
|
+
exit_form
|
|
117
|
+
when :editor
|
|
118
|
+
open_editor
|
|
119
|
+
end
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def enter_form
|
|
124
|
+
row = @table_view.selected_row
|
|
125
|
+
return unless row
|
|
126
|
+
|
|
127
|
+
@form_state = FormState.new(@data_source.columns, row)
|
|
128
|
+
@form_view = FormView.new(@form_state)
|
|
129
|
+
@mode = :form
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def exit_form
|
|
133
|
+
@mode = :table
|
|
134
|
+
@form_state = nil
|
|
135
|
+
@form_view = nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def save_form
|
|
139
|
+
idx = @table_view.selected_index
|
|
140
|
+
return unless idx
|
|
141
|
+
|
|
142
|
+
result = @data_source.update_row(idx, @form_state.changes)
|
|
143
|
+
if result.ok?
|
|
144
|
+
exit_form
|
|
145
|
+
else
|
|
146
|
+
@form_state.set_errors(result.errors)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def open_editor
|
|
151
|
+
field_idx = @form_state.field_index
|
|
152
|
+
current_value = @form_state.values[field_idx]
|
|
153
|
+
|
|
154
|
+
edited = EditorBridge.edit(current_value)
|
|
155
|
+
@form_state.set_value(field_idx, edited.chomp)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ratamin
|
|
4
|
+
class ArrayDataSource
|
|
5
|
+
include DataSource
|
|
6
|
+
|
|
7
|
+
attr_reader :columns, :rows
|
|
8
|
+
|
|
9
|
+
# @param columns [Array<Column>] column definitions
|
|
10
|
+
# @param rows [Array<Hash>] row data, each hash maps column key -> value
|
|
11
|
+
def initialize(columns:, rows:)
|
|
12
|
+
@columns = columns
|
|
13
|
+
@rows = rows.map(&:dup)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def row_count
|
|
17
|
+
@rows.length
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def update_row(index, changes)
|
|
21
|
+
changes.each { |k, v| @rows[index][k] = v }
|
|
22
|
+
UpdateResult.success
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reload!
|
|
26
|
+
# no-op for array source
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ratamin
|
|
4
|
+
Column = Data.define(:key, :label, :width, :type, :editable) do
|
|
5
|
+
def initialize(key:, label: nil, width: nil, type: :string, editable: true)
|
|
6
|
+
super(key: key, label: label || key.to_s.capitalize, width: width, type: type, editable: editable)
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ratamin
|
|
4
|
+
# Duck-type protocol for data sources.
|
|
5
|
+
# Implementations must respond to:
|
|
6
|
+
# #columns -> [Column]
|
|
7
|
+
# #rows -> [Hash] (current page of data)
|
|
8
|
+
# #row_count -> Integer
|
|
9
|
+
# #update_row(index, changes) -> UpdateResult
|
|
10
|
+
# #reload! -> void
|
|
11
|
+
#
|
|
12
|
+
# Pagination (optional, when #paginated? returns true):
|
|
13
|
+
# #page -> Integer (1-indexed)
|
|
14
|
+
# #total_count -> Integer
|
|
15
|
+
# #total_pages -> Integer
|
|
16
|
+
# #next_page -> void
|
|
17
|
+
# #prev_page -> void
|
|
18
|
+
# #go_to_page(n) -> void
|
|
19
|
+
module DataSource
|
|
20
|
+
def paginated?
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
|
|
6
|
+
module Ratamin
|
|
7
|
+
module EditorBridge
|
|
8
|
+
# Opens $EDITOR with the given text, returns edited text.
|
|
9
|
+
# Temporarily restores the terminal so the editor can run normally.
|
|
10
|
+
def self.edit(text)
|
|
11
|
+
editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
|
|
12
|
+
|
|
13
|
+
tmpfile = Tempfile.new(["ratamin_edit", ".txt"])
|
|
14
|
+
tmpfile.write(text)
|
|
15
|
+
tmpfile.flush
|
|
16
|
+
|
|
17
|
+
RatatuiRuby.restore_terminal
|
|
18
|
+
|
|
19
|
+
# Use shell expansion so editors with arguments (e.g. "code --wait") work
|
|
20
|
+
success = system(*Shellwords.split(editor), tmpfile.path)
|
|
21
|
+
|
|
22
|
+
RatatuiRuby.init_terminal
|
|
23
|
+
|
|
24
|
+
unless success
|
|
25
|
+
tmpfile.close
|
|
26
|
+
tmpfile.unlink
|
|
27
|
+
return text
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
tmpfile.rewind
|
|
31
|
+
result = tmpfile.read
|
|
32
|
+
tmpfile.close
|
|
33
|
+
tmpfile.unlink
|
|
34
|
+
|
|
35
|
+
result
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ratamin
|
|
4
|
+
class FormState
|
|
5
|
+
attr_reader :columns, :values, :field_index, :cursor_positions, :errors
|
|
6
|
+
|
|
7
|
+
def initialize(columns, row)
|
|
8
|
+
@columns = columns
|
|
9
|
+
@values = columns.map { |col| row[col.key].to_s }
|
|
10
|
+
@original_values = @values.dup
|
|
11
|
+
@field_index = first_editable_index || 0
|
|
12
|
+
@cursor_positions = @values.map(&:length)
|
|
13
|
+
@errors = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def errors?
|
|
17
|
+
!@errors.empty?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def set_errors(errors)
|
|
21
|
+
@errors = Array(errors)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear_errors
|
|
25
|
+
@errors = []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def handle_event(event)
|
|
29
|
+
# Enter/Esc always work, even when all fields are read-only.
|
|
30
|
+
case event
|
|
31
|
+
in {type: :key, code: "enter"}
|
|
32
|
+
return :save
|
|
33
|
+
in {type: :key, code: "esc"}
|
|
34
|
+
return :cancel
|
|
35
|
+
else
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
case event
|
|
40
|
+
in {type: :key, code: "tab", modifiers: []}
|
|
41
|
+
return next_field
|
|
42
|
+
in {type: :key, code: "backtab"} | {type: :key, code: "tab", modifiers: ["shift"]}
|
|
43
|
+
return prev_field
|
|
44
|
+
else
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
return nil unless current_column&.editable
|
|
49
|
+
|
|
50
|
+
case event
|
|
51
|
+
in {type: :key, code: "e"} if current_column.type == :text
|
|
52
|
+
return :editor
|
|
53
|
+
in {type: :key, code: "backspace"}
|
|
54
|
+
handle_backspace
|
|
55
|
+
in {type: :key, code: "delete"}
|
|
56
|
+
handle_delete
|
|
57
|
+
in {type: :key, code: "left"}
|
|
58
|
+
move_cursor_left
|
|
59
|
+
in {type: :key, code: "right"}
|
|
60
|
+
move_cursor_right
|
|
61
|
+
in {type: :key, code: "home"}
|
|
62
|
+
@cursor_positions[@field_index] = 0
|
|
63
|
+
in {type: :key, code: "end"}
|
|
64
|
+
@cursor_positions[@field_index] = @values[@field_index].length
|
|
65
|
+
in {type: :key, code: c, modifiers: []} if c.length == 1
|
|
66
|
+
insert_char(c)
|
|
67
|
+
else
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def current_column
|
|
73
|
+
columns[@field_index]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def current_value
|
|
77
|
+
@values[@field_index]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def set_value(index, value)
|
|
81
|
+
@values[index] = value.to_s
|
|
82
|
+
@cursor_positions[index] = @values[index].length
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Returns only dirty editable fields.
|
|
86
|
+
def changes
|
|
87
|
+
result = {}
|
|
88
|
+
columns.each_with_index do |col, i|
|
|
89
|
+
next unless col.editable
|
|
90
|
+
next if @values[i] == @original_values[i]
|
|
91
|
+
result[col.key] = @values[i]
|
|
92
|
+
end
|
|
93
|
+
result
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def next_field
|
|
97
|
+
return unless any_editable?
|
|
98
|
+
loop do
|
|
99
|
+
@field_index = (@field_index + 1) % columns.length
|
|
100
|
+
break if current_column.editable
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def prev_field
|
|
105
|
+
return unless any_editable?
|
|
106
|
+
loop do
|
|
107
|
+
@field_index = (@field_index - 1) % columns.length
|
|
108
|
+
break if current_column.editable
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def insert_char(c)
|
|
113
|
+
pos = @cursor_positions[@field_index]
|
|
114
|
+
@values[@field_index] = @values[@field_index].dup.insert(pos, c)
|
|
115
|
+
@cursor_positions[@field_index] += 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def handle_backspace
|
|
119
|
+
pos = @cursor_positions[@field_index]
|
|
120
|
+
return if pos == 0
|
|
121
|
+
val = @values[@field_index].dup
|
|
122
|
+
val.slice!(pos - 1)
|
|
123
|
+
@values[@field_index] = val
|
|
124
|
+
@cursor_positions[@field_index] -= 1
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def handle_delete
|
|
128
|
+
pos = @cursor_positions[@field_index]
|
|
129
|
+
val = @values[@field_index].dup
|
|
130
|
+
return if pos >= val.length
|
|
131
|
+
val.slice!(pos)
|
|
132
|
+
@values[@field_index] = val
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def move_cursor_left
|
|
136
|
+
@cursor_positions[@field_index] = [0, @cursor_positions[@field_index] - 1].max
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def move_cursor_right
|
|
140
|
+
@cursor_positions[@field_index] = [@values[@field_index].length, @cursor_positions[@field_index] + 1].min
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def any_editable?
|
|
146
|
+
columns.any?(&:editable)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def first_editable_index
|
|
150
|
+
columns.index(&:editable)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ratamin
|
|
4
|
+
class FormView
|
|
5
|
+
attr_reader :state
|
|
6
|
+
|
|
7
|
+
def initialize(state)
|
|
8
|
+
@state = state
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def render(tui, frame, area)
|
|
12
|
+
border_style = state.errors? ? {fg: :red} : {fg: :yellow}
|
|
13
|
+
inner_block = tui.block(
|
|
14
|
+
title: " Edit Record ",
|
|
15
|
+
borders: [:all],
|
|
16
|
+
border_type: :rounded,
|
|
17
|
+
border_style: border_style
|
|
18
|
+
)
|
|
19
|
+
inner_area = inner_block.inner(area)
|
|
20
|
+
frame.render_widget(inner_block, area)
|
|
21
|
+
|
|
22
|
+
columns = state.columns
|
|
23
|
+
|
|
24
|
+
constraints = []
|
|
25
|
+
# Error lines
|
|
26
|
+
if state.errors?
|
|
27
|
+
constraints << RatatuiRuby::Layout::Constraint.length(state.errors.length + 1)
|
|
28
|
+
end
|
|
29
|
+
# Field rows
|
|
30
|
+
columns.each { constraints << RatatuiRuby::Layout::Constraint.length(1) }
|
|
31
|
+
# Help line + spacer
|
|
32
|
+
constraints << RatatuiRuby::Layout::Constraint.length(1)
|
|
33
|
+
constraints << RatatuiRuby::Layout::Constraint.fill(1)
|
|
34
|
+
|
|
35
|
+
regions = RatatuiRuby::Layout::Layout.split(
|
|
36
|
+
inner_area,
|
|
37
|
+
direction: :vertical,
|
|
38
|
+
constraints: constraints
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
region_idx = 0
|
|
42
|
+
|
|
43
|
+
# Render errors if present
|
|
44
|
+
if state.errors?
|
|
45
|
+
error_text = state.errors.map { |e| " ! #{e}" }.join("\n")
|
|
46
|
+
error_widget = tui.paragraph(text: error_text, style: {fg: :red, modifiers: [:bold]})
|
|
47
|
+
frame.render_widget(error_widget, regions[region_idx])
|
|
48
|
+
region_idx += 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
label_width = columns.map { |c| c.label.length }.max + 2
|
|
52
|
+
|
|
53
|
+
columns.each_with_index do |col, i|
|
|
54
|
+
field_area = regions[region_idx + i]
|
|
55
|
+
label_area, value_area = RatatuiRuby::Layout::Layout.split(
|
|
56
|
+
field_area,
|
|
57
|
+
direction: :horizontal,
|
|
58
|
+
constraints: [
|
|
59
|
+
RatatuiRuby::Layout::Constraint.length(label_width),
|
|
60
|
+
RatatuiRuby::Layout::Constraint.fill(1)
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
active = i == state.field_index
|
|
65
|
+
|
|
66
|
+
if col.editable
|
|
67
|
+
label_style = active ? {fg: :yellow, modifiers: [:bold]} : {fg: :dark_gray}
|
|
68
|
+
frame.render_widget(tui.paragraph(text: "#{col.label}: ", style: label_style), label_area)
|
|
69
|
+
|
|
70
|
+
display_value = if col.type == :text && state.values[i].length > 40
|
|
71
|
+
"[press 'e' to edit in $EDITOR]"
|
|
72
|
+
else
|
|
73
|
+
state.values[i]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
value_style = active ? {fg: :white, modifiers: [:underlined]} : {fg: :gray}
|
|
77
|
+
frame.render_widget(tui.paragraph(text: display_value, style: value_style), value_area)
|
|
78
|
+
|
|
79
|
+
if active && col.type != :text
|
|
80
|
+
cursor_x = value_area.x + [state.cursor_positions[i], value_area.width - 1].min
|
|
81
|
+
frame.set_cursor_position(cursor_x, value_area.y)
|
|
82
|
+
end
|
|
83
|
+
else
|
|
84
|
+
frame.render_widget(tui.paragraph(text: "#{col.label}: ", style: {fg: :dark_gray}), label_area)
|
|
85
|
+
frame.render_widget(tui.paragraph(text: state.values[i], style: {fg: :dark_gray, modifiers: [:dim]}), value_area)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
help_area = regions[region_idx + columns.length]
|
|
90
|
+
help = tui.paragraph(
|
|
91
|
+
text: " Tab:next Shift+Tab:prev Enter:save Esc:cancel e:editor(text) ",
|
|
92
|
+
style: {fg: :dark_gray}
|
|
93
|
+
)
|
|
94
|
+
frame.render_widget(help, help_area)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ratamin
|
|
4
|
+
class ScopeDataSource
|
|
5
|
+
include DataSource
|
|
6
|
+
|
|
7
|
+
attr_reader :columns, :page, :per_page, :total_count
|
|
8
|
+
|
|
9
|
+
# @param scope [ActiveRecord::Relation] the AR scope to display
|
|
10
|
+
# @param columns [Array<Column>, nil] explicit columns, or nil to infer from model
|
|
11
|
+
# @param per_page [Integer] records per page
|
|
12
|
+
def initialize(scope, columns: nil, per_page: 20)
|
|
13
|
+
@scope = scope
|
|
14
|
+
@columns = columns || infer_columns
|
|
15
|
+
@per_page = per_page
|
|
16
|
+
@page = 1
|
|
17
|
+
@total_count = 0
|
|
18
|
+
@records = []
|
|
19
|
+
reload!
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def paginated?
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def total_pages
|
|
27
|
+
[(@total_count.to_f / @per_page).ceil, 1].max
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def next_page
|
|
31
|
+
go_to_page(@page + 1)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def prev_page
|
|
35
|
+
go_to_page(@page - 1)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def go_to_page(n)
|
|
39
|
+
n = [[1, n].max, total_pages].min
|
|
40
|
+
return if n == @page
|
|
41
|
+
@page = n
|
|
42
|
+
load_page
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def rows
|
|
46
|
+
@records.map { |record| row_hash(record) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def row_count
|
|
50
|
+
@records.length
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def update_row(index, changes)
|
|
54
|
+
record = @records[index]
|
|
55
|
+
cast_changes = changes.transform_keys(&:to_s)
|
|
56
|
+
record.assign_attributes(cast_changes)
|
|
57
|
+
|
|
58
|
+
if record.save
|
|
59
|
+
UpdateResult.success
|
|
60
|
+
else
|
|
61
|
+
UpdateResult.failure(record.errors.full_messages)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def reload!
|
|
66
|
+
@total_count = @scope.reset.count
|
|
67
|
+
@page = [[1, @page].max, total_pages].min
|
|
68
|
+
load_page
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def load_page
|
|
74
|
+
@records = ordered_scope.limit(@per_page).offset((@page - 1) * @per_page).to_a
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def ordered_scope
|
|
78
|
+
s = @scope.reset
|
|
79
|
+
if s.order_values.empty?
|
|
80
|
+
s.order(s.klass.arel_table[s.klass.primary_key])
|
|
81
|
+
else
|
|
82
|
+
s
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def row_hash(record)
|
|
87
|
+
columns.each_with_object({}) do |col, hash|
|
|
88
|
+
hash[col.key] = record.public_send(col.key)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
READONLY_COLUMNS = %w[id created_at updated_at].freeze
|
|
93
|
+
|
|
94
|
+
def infer_columns
|
|
95
|
+
model = @scope.klass
|
|
96
|
+
model.column_names.map do |name|
|
|
97
|
+
col = model.columns_hash[name]
|
|
98
|
+
Column.new(
|
|
99
|
+
key: name.to_sym,
|
|
100
|
+
type: map_ar_type(col.type),
|
|
101
|
+
width: guess_width(name, col.type),
|
|
102
|
+
editable: !READONLY_COLUMNS.include?(name)
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def map_ar_type(ar_type)
|
|
108
|
+
case ar_type
|
|
109
|
+
when :text then :text
|
|
110
|
+
else :string
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def guess_width(name, ar_type)
|
|
115
|
+
case ar_type
|
|
116
|
+
when :text then nil
|
|
117
|
+
when :integer then 12
|
|
118
|
+
when :boolean then 8
|
|
119
|
+
when :datetime, :timestamp then 22
|
|
120
|
+
when :date then 12
|
|
121
|
+
else
|
|
122
|
+
name == "id" ? 8 : nil
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ratamin
|
|
4
|
+
class TableView
|
|
5
|
+
attr_reader :data_source, :table_state
|
|
6
|
+
|
|
7
|
+
def initialize(data_source)
|
|
8
|
+
@data_source = data_source
|
|
9
|
+
@table_state = RatatuiRuby::TableState.new(0)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def render(tui, frame, area)
|
|
13
|
+
columns = data_source.columns
|
|
14
|
+
rows = data_source.rows
|
|
15
|
+
|
|
16
|
+
header = columns.map(&:label)
|
|
17
|
+
|
|
18
|
+
table_rows = rows.map do |row|
|
|
19
|
+
columns.map { |col| format_cell(row[col.key], col) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
widths = compute_widths(columns, area.width)
|
|
23
|
+
|
|
24
|
+
table = tui.table(
|
|
25
|
+
header: header,
|
|
26
|
+
rows: table_rows,
|
|
27
|
+
widths: widths,
|
|
28
|
+
row_highlight_style: {bg: :dark_gray, modifiers: [:bold]},
|
|
29
|
+
highlight_symbol: "> ",
|
|
30
|
+
block: tui.block(
|
|
31
|
+
title: " #{title_text} ",
|
|
32
|
+
borders: [:all],
|
|
33
|
+
border_type: :rounded,
|
|
34
|
+
border_style: {fg: :cyan}
|
|
35
|
+
),
|
|
36
|
+
style: {fg: :white}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
frame.render_stateful_widget(table, area, @table_state)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def selected_index
|
|
43
|
+
@table_state.selected
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def selected_row
|
|
47
|
+
idx = selected_index
|
|
48
|
+
return nil if idx.nil? || idx >= data_source.row_count
|
|
49
|
+
data_source.rows[idx]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def select_next
|
|
53
|
+
max = data_source.row_count - 1
|
|
54
|
+
return if max < 0
|
|
55
|
+
return if (selected_index || 0) >= max
|
|
56
|
+
@table_state.select_next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def select_previous
|
|
60
|
+
return if (selected_index || 0) <= 0
|
|
61
|
+
@table_state.select_previous
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def select_first
|
|
65
|
+
@table_state.select_first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def select_last
|
|
69
|
+
max = data_source.row_count - 1
|
|
70
|
+
@table_state.select(max) if max >= 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reset_selection
|
|
74
|
+
@table_state.select(0)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def title_text
|
|
80
|
+
ds = data_source
|
|
81
|
+
if ds.paginated?
|
|
82
|
+
"Records (page #{ds.page}/#{ds.total_pages}, #{ds.total_count} total)"
|
|
83
|
+
else
|
|
84
|
+
"Records (#{ds.row_count})"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def format_cell(value, column)
|
|
89
|
+
text = value.to_s
|
|
90
|
+
case column.type
|
|
91
|
+
when :text
|
|
92
|
+
# Truncate long text for table display
|
|
93
|
+
text.length > 40 ? "#{text[0, 37]}..." : text
|
|
94
|
+
else
|
|
95
|
+
text
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def compute_widths(columns, available_width)
|
|
100
|
+
# Reserve space for borders (2) + highlight symbol (2) + column spacing
|
|
101
|
+
usable = available_width - 4
|
|
102
|
+
total_spacing = [columns.length - 1, 0].max
|
|
103
|
+
|
|
104
|
+
columns.map do |col|
|
|
105
|
+
if col.width
|
|
106
|
+
RatatuiRuby::Layout::Constraint.length(col.width)
|
|
107
|
+
else
|
|
108
|
+
RatatuiRuby::Layout::Constraint.fill(1)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ratamin
|
|
4
|
+
UpdateResult = Data.define(:ok, :errors) do
|
|
5
|
+
def initialize(ok:, errors: [])
|
|
6
|
+
super
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def ok? = ok
|
|
10
|
+
def failed? = !ok
|
|
11
|
+
|
|
12
|
+
def self.success
|
|
13
|
+
new(ok: true)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.failure(errors)
|
|
17
|
+
new(ok: false, errors: Array(errors))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/ratamin.rb
ADDED
data/sig/ratamin.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ratamin
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- merely
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ratatui_ruby
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.4'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.4'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: zeitwerk
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.6'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.6'
|
|
40
|
+
description: A terminal UI for browsing and editing ActiveRecord scopes, built on
|
|
41
|
+
ratatui_ruby.
|
|
42
|
+
email:
|
|
43
|
+
- git@merely.ca
|
|
44
|
+
executables: []
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files: []
|
|
47
|
+
files:
|
|
48
|
+
- ".github/workflows/ci.yml"
|
|
49
|
+
- ".gitmodules"
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- LICENSE.txt
|
|
52
|
+
- README.md
|
|
53
|
+
- Rakefile
|
|
54
|
+
- examples/demo.rb
|
|
55
|
+
- lib/ratamin.rb
|
|
56
|
+
- lib/ratamin/app.rb
|
|
57
|
+
- lib/ratamin/array_data_source.rb
|
|
58
|
+
- lib/ratamin/column.rb
|
|
59
|
+
- lib/ratamin/data_source.rb
|
|
60
|
+
- lib/ratamin/editor_bridge.rb
|
|
61
|
+
- lib/ratamin/form_state.rb
|
|
62
|
+
- lib/ratamin/form_view.rb
|
|
63
|
+
- lib/ratamin/scope_data_source.rb
|
|
64
|
+
- lib/ratamin/table_view.rb
|
|
65
|
+
- lib/ratamin/update_result.rb
|
|
66
|
+
- lib/ratamin/version.rb
|
|
67
|
+
- sig/ratamin.rbs
|
|
68
|
+
homepage: https://github.com/onyxblade/ratamin
|
|
69
|
+
licenses:
|
|
70
|
+
- MIT
|
|
71
|
+
metadata:
|
|
72
|
+
homepage_uri: https://github.com/onyxblade/ratamin
|
|
73
|
+
source_code_uri: https://github.com/onyxblade/ratamin
|
|
74
|
+
changelog_uri: https://github.com/onyxblade/ratamin/blob/main/CHANGELOG.md
|
|
75
|
+
rdoc_options: []
|
|
76
|
+
require_paths:
|
|
77
|
+
- lib
|
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: 3.3.0
|
|
83
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
requirements: []
|
|
89
|
+
rubygems_version: 4.0.3
|
|
90
|
+
specification_version: 4
|
|
91
|
+
summary: TUI admin console for ActiveRecord
|
|
92
|
+
test_files: []
|