thundercli 0.2.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/README.md +5 -0
- data/exe/thunder +11 -0
- data/lib/thunder/application.rb +35 -0
- data/lib/thunder/cli/command.rb +19 -0
- data/lib/thunder/cli/commands/console.rb +24 -0
- data/lib/thunder/cli/commands/db/create.rb +20 -0
- data/lib/thunder/cli/commands/db/drop.rb +20 -0
- data/lib/thunder/cli/commands/db/migrate.rb +20 -0
- data/lib/thunder/cli/commands/db/reset.rb +22 -0
- data/lib/thunder/cli/commands/db/setup.rb +21 -0
- data/lib/thunder/cli/commands/generate/chunk.rb +23 -0
- data/lib/thunder/cli/commands/generate/command.rb +64 -0
- data/lib/thunder/cli/commands/generate/event.rb +28 -0
- data/lib/thunder/cli/commands/generate/model.rb +39 -0
- data/lib/thunder/cli/commands/generate/operation.rb +39 -0
- data/lib/thunder/cli/commands/generate/outbox.rb +33 -0
- data/lib/thunder/cli/commands/generate/subscriber.rb +33 -0
- data/lib/thunder/cli/commands/messaging/consumer.rb +52 -0
- data/lib/thunder/cli/commands/messaging/outbox.rb +36 -0
- data/lib/thunder/cli/commands/new.rb +44 -0
- data/lib/thunder/cli/commands/serve.rb +38 -0
- data/lib/thunder/cli/commands/spec.rb +77 -0
- data/lib/thunder/cli/commands/version.rb +16 -0
- data/lib/thunder/cli.rb +78 -0
- data/lib/thunder/console/backtrace.rb +36 -0
- data/lib/thunder/console/banner.rb +93 -0
- data/lib/thunder/console/completion.rb +68 -0
- data/lib/thunder/console/constants.rb +57 -0
- data/lib/thunder/console/evaluation.rb +72 -0
- data/lib/thunder/console/help.rb +30 -0
- data/lib/thunder/console/helpers.rb +16 -0
- data/lib/thunder/console/inspector.rb +114 -0
- data/lib/thunder/console/pretty.rb +70 -0
- data/lib/thunder/console/prompt.rb +52 -0
- data/lib/thunder/console/theme.rb +85 -0
- data/lib/thunder/console/views/collection.rb +172 -0
- data/lib/thunder/console/views/format.rb +114 -0
- data/lib/thunder/console/views/layout.rb +72 -0
- data/lib/thunder/console/views/model.rb +92 -0
- data/lib/thunder/console/views/operation.rb +112 -0
- data/lib/thunder/console/views/record.rb +95 -0
- data/lib/thunder/console/views.rb +45 -0
- data/lib/thunder/console.rb +167 -0
- data/lib/thunder/generator.rb +73 -0
- data/lib/thunder/templates/chunk/app/common/models/application_model.rb.erb +4 -0
- data/lib/thunder/templates/chunk/app/common/operations/external_operation.rb.erb +3 -0
- data/lib/thunder/templates/chunk/app/common/operations/internal_operation.rb.erb +3 -0
- data/lib/thunder/templates/chunk/app/external/operations/_keep.erb +0 -0
- data/lib/thunder/templates/chunk/app/internal/models/_keep.erb +0 -0
- data/lib/thunder/templates/chunk/app/internal/operations/_keep.erb +0 -0
- data/lib/thunder/templates/chunk/config/chunk.rb.erb +9 -0
- data/lib/thunder/templates/chunk/database/migrate/_keep.erb +0 -0
- data/lib/thunder/templates/event/event.rb.erb +6 -0
- data/lib/thunder/templates/model/migration.rb.erb +17 -0
- data/lib/thunder/templates/model/model.rb.erb +6 -0
- data/lib/thunder/templates/new/Gemfile.erb +21 -0
- data/lib/thunder/templates/new/README.md.erb +8 -0
- data/lib/thunder/templates/new/_gitignore.erb +4 -0
- data/lib/thunder/templates/new/_ruby-version.erb +2 -0
- data/lib/thunder/templates/new/bin/thunder.erb +6 -0
- data/lib/thunder/templates/new/chunks/_keep.erb +0 -0
- data/lib/thunder/templates/operation/external.rb.erb +24 -0
- data/lib/thunder/templates/operation/internal.rb.erb +26 -0
- data/lib/thunder/templates/outbox/migration.rb.erb +21 -0
- data/lib/thunder/templates/subscriber/subscriber.rb.erb +18 -0
- data/lib/thunder/version.rb +5 -0
- data/lib/thunder.rb +32 -0
- metadata +147 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
# Prints hashes and arrays as blocks: one item per line, a trailing comma, the closing bracket on its own line and
|
|
7
|
+
# a │ guide per level. Hashes always open a block; an array stays on one line while it holds only short values
|
|
8
|
+
# (except at the top, where two or more items open a block). Anything else prints as IRB does.
|
|
9
|
+
# {
|
|
10
|
+
# │ a: 1,
|
|
11
|
+
# │ "b" => [2, 3],
|
|
12
|
+
# │ c: {
|
|
13
|
+
# │ │ d: 1,
|
|
14
|
+
# │ │ e: 2,
|
|
15
|
+
# │ }
|
|
16
|
+
# }
|
|
17
|
+
class Pretty
|
|
18
|
+
include Theme
|
|
19
|
+
|
|
20
|
+
INLINE_WIDTH = 60
|
|
21
|
+
GUIDE = '│ '
|
|
22
|
+
|
|
23
|
+
def initialize(colorize:)
|
|
24
|
+
@colorize = colorize
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(value) = render(value, top: true)
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def render(value, top: false)
|
|
32
|
+
return block('{', '}', value.map { |key, item| pair(key, item) }) if value.is_a?(Hash) && value.any?
|
|
33
|
+
return block('[', ']', value.map { |item| render(item) }) if value.is_a?(Array) && block_array?(value, top)
|
|
34
|
+
return Views.reference(value) if value.is_a?(ActiveRecord::Base)
|
|
35
|
+
|
|
36
|
+
color_pp(value)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def block_array?(array, top)
|
|
40
|
+
return array.size > 1 || array.any? { |item| nested?(item) } if top
|
|
41
|
+
|
|
42
|
+
array.any? { |item| nested?(item) } || !inline?(color_pp(array))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def nested?(value) = (value.is_a?(Hash) || value.is_a?(Array)) && value.any?
|
|
46
|
+
|
|
47
|
+
def inline?(text) = !text.include?("\n") && text.gsub(/\e\[[\d;]*m/, '').size <= INLINE_WIDTH
|
|
48
|
+
|
|
49
|
+
def block(open, close, items)
|
|
50
|
+
guide = paint(GUIDE, :faint)
|
|
51
|
+
body = items.map do |item|
|
|
52
|
+
comma = item.end_with?('}', ']') && item.include?("\n") ? '' : ','
|
|
53
|
+
"#{item}#{comma}".gsub(/^/, guide)
|
|
54
|
+
end
|
|
55
|
+
[open, *body, close].join("\n")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Ruby's own key format: a: for a symbol, "a" => for anything else.
|
|
59
|
+
def pair(key, value)
|
|
60
|
+
label = key.is_a?(Symbol) ? "#{key.inspect.delete_prefix(':')}:" : "#{color_pp(key)} =>"
|
|
61
|
+
label = paint(label, :accent) if key.is_a?(Symbol)
|
|
62
|
+
"#{label} #{render(value)}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def color_pp(value) = IRB::ColorPrinter.pp(value, +'', colorize: @colorize).chomp
|
|
66
|
+
|
|
67
|
+
def paint(text, *roles) = @colorize ? Theme.paint(text, *roles) : text
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
# Powerline prompt: [⚡ chunk][env] ❯, the arrow red after a failed evaluation. With a Nerd Font the segments end
|
|
7
|
+
# in the separator; without one (see Theme.nerd_font?) they are plain colored blocks.
|
|
8
|
+
class Prompt
|
|
9
|
+
include Theme
|
|
10
|
+
|
|
11
|
+
def initialize(console)
|
|
12
|
+
@console = console
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# IRB switches to PROMPT_S inside an open string and to PROMPT_C in an unfinished expression, on the first line
|
|
16
|
+
# too, so all three are the full prompt.
|
|
17
|
+
def irb_prompts(failed: false)
|
|
18
|
+
prompt = main(failed:)
|
|
19
|
+
{ PROMPT_I: prompt, PROMPT_S: prompt, PROMPT_C: prompt, RETURN: "#{paint('=>', :faint)} %s\n" }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Repaints the prompts of a running IRB context.
|
|
23
|
+
def apply(context, failed:)
|
|
24
|
+
prompt = main(failed:)
|
|
25
|
+
context.prompt_i = prompt
|
|
26
|
+
context.prompt_s = prompt
|
|
27
|
+
context.prompt_c = prompt
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def main(failed: false)
|
|
31
|
+
name = @console.chunk&.identifier || 'thunder'
|
|
32
|
+
env = @console.env
|
|
33
|
+
return "#{name}(#{env})#{' ✗' if failed} > " unless enabled?
|
|
34
|
+
|
|
35
|
+
"#{segments(name, env)} #{paint('❯', failed ? :fail : :ok)} "
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Reverse video: the segment is the theme color, the text the terminal background.
|
|
41
|
+
def segments(name, env)
|
|
42
|
+
production = env == 'production'
|
|
43
|
+
chunk_segment = "\e[0;7;3#{ACCENT_COLOR}m ⚡ #{name} "
|
|
44
|
+
env_segment = "\e[0;#{'1;' if production}7;3#{env_color(env)}m#{production ? ' PRODUCTION ' : " #{env} "}"
|
|
45
|
+
return "#{chunk_segment}#{env_segment}\e[0m" unless Theme.nerd_font?
|
|
46
|
+
|
|
47
|
+
"#{chunk_segment}\e[0;3#{ACCENT_COLOR};4#{env_color(env)}m#{SEPARATOR}#{env_segment}" \
|
|
48
|
+
"\e[0;3#{env_color(env)}m#{SEPARATOR}\e[0m"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
# The 16 ANSI colors (30-37, 90-97), so the terminal theme decides the actual colors. Off when stdout is not a
|
|
7
|
+
# terminal or NO_COLOR is set.
|
|
8
|
+
module Theme
|
|
9
|
+
# Indexes of the ANSI palette: foreground 30 + index, background 40 + index.
|
|
10
|
+
RED = 1
|
|
11
|
+
GREEN = 2
|
|
12
|
+
YELLOW = 3
|
|
13
|
+
BLUE = 4
|
|
14
|
+
MAGENTA = 5
|
|
15
|
+
|
|
16
|
+
ROLES = {
|
|
17
|
+
accent: "3#{BLUE}",
|
|
18
|
+
ok: "3#{GREEN}",
|
|
19
|
+
fail: "3#{RED}",
|
|
20
|
+
warn: "3#{YELLOW}",
|
|
21
|
+
muted: '39',
|
|
22
|
+
faint: '90',
|
|
23
|
+
bold: '1',
|
|
24
|
+
reverse: '7'
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# Color of the env segment of the prompt.
|
|
28
|
+
ENV_COLORS = { 'development' => GREEN, 'test' => YELLOW, 'production' => RED }.freeze
|
|
29
|
+
OTHER_ENV_COLOR = MAGENTA
|
|
30
|
+
ACCENT_COLOR = BLUE
|
|
31
|
+
SEPARATOR = ''
|
|
32
|
+
|
|
33
|
+
# A terminal does not report its font. THUNDER_NERD_FONT=1/0 decides; without it, only terminals that ship the
|
|
34
|
+
# Nerd Font symbols themselves (WezTerm, Ghostty, kitty) get the powerline separator.
|
|
35
|
+
NERD_FONT_TERMINALS = %w[WezTerm ghostty].freeze
|
|
36
|
+
|
|
37
|
+
def self.nerd_font?
|
|
38
|
+
return @nerd_font if defined?(@nerd_font)
|
|
39
|
+
|
|
40
|
+
@nerd_font =
|
|
41
|
+
case ENV.fetch('THUNDER_NERD_FONT', nil)
|
|
42
|
+
when '1', 'true' then true
|
|
43
|
+
when '0', 'false' then false
|
|
44
|
+
else nerd_font_terminal?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.nerd_font_terminal?
|
|
49
|
+
NERD_FONT_TERMINALS.include?(ENV.fetch('TERM_PROGRAM', nil)) ||
|
|
50
|
+
ENV['TERM'] == 'xterm-kitty' || ENV.key?('KITTY_WINDOW_ID')
|
|
51
|
+
end
|
|
52
|
+
private_class_method :nerd_font_terminal?
|
|
53
|
+
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
def enabled? = $stdout.tty? && !ENV.key?('NO_COLOR')
|
|
57
|
+
|
|
58
|
+
def paint(text, *roles)
|
|
59
|
+
return text.to_s unless enabled? && roles.any?
|
|
60
|
+
|
|
61
|
+
"\e[#{roles.map { |role| ROLES.fetch(role) }.join(';')}m#{text}\e[0m"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def env_color(env) = ENV_COLORS.fetch(env, OTHER_ENV_COLOR)
|
|
65
|
+
|
|
66
|
+
# Label/value lines with the labels in one column; a multi-line value continues under its first line. guide (a
|
|
67
|
+
# role) draws a │ in that color before every line, tying the rows to a block.
|
|
68
|
+
# chunk library
|
|
69
|
+
# operations books.add
|
|
70
|
+
def rows(pairs, indent: 2, guide: nil)
|
|
71
|
+
pairs = pairs.compact
|
|
72
|
+
width = pairs.map { |label, _| label.size }.max.to_i
|
|
73
|
+
prefix = "#{' ' * indent}#{"#{paint('│', guide)} " if guide}"
|
|
74
|
+
margin = "#{prefix}#{' ' * (width + 2)}"
|
|
75
|
+
pairs.map do |label, value|
|
|
76
|
+
"#{prefix}#{paint(label.ljust(width), :faint)} #{value.to_s.gsub("\n", "\n#{margin}")}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def duration(seconds)
|
|
81
|
+
seconds < 1 ? "#{(seconds * 1000).round}ms" : format('%.1fs', seconds)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
module Views
|
|
7
|
+
# A relation, or an array of records of one class, as a table: one record per line, the columns that fit the
|
|
8
|
+
# terminal and rows saying what was left out. Columns go in this order until the width ends: primary key, an
|
|
9
|
+
# identifying column (number, name, title...), the others in schema order, timestamps last. A column with one
|
|
10
|
+
# value in every record moves to the "same" row. Up to 3 records that do not fit are shown transposed, one
|
|
11
|
+
# record per column. Like ActiveRecord's own inspect, a relation loads at most 11 records.
|
|
12
|
+
# => 10 orders relation · 40 columns, 9 shown
|
|
13
|
+
# │ id number status total_cents created_at
|
|
14
|
+
# │ 7c1e0b5a… ORD-2026-000431 paid 15990 2026-10-21 14:19
|
|
15
|
+
# │ same currency BRL · coupon nil
|
|
16
|
+
# │ hidden subtotal_cents, notes, metadata, +19
|
|
17
|
+
class Collection
|
|
18
|
+
include Format
|
|
19
|
+
|
|
20
|
+
MAX_RECORDS = 10
|
|
21
|
+
MAX_ARRAY_RECORDS = 50
|
|
22
|
+
TRANSPOSE_UP_TO = 3
|
|
23
|
+
MAX_ROWS = 20
|
|
24
|
+
IDENTITY = %w[number code name title email slug key].freeze
|
|
25
|
+
GAP = 2
|
|
26
|
+
|
|
27
|
+
def self.records?(value)
|
|
28
|
+
value.is_a?(Array) && value.size > 1 && value.all?(ActiveRecord::Base) && value.map(&:class).uniq.size == 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A relation is read only up to MAX_RECORDS + 1 rows, so its total is unknown ("10+"); an array is already in
|
|
32
|
+
# memory, so its size is the total and up to MAX_ARRAY_RECORDS are shown.
|
|
33
|
+
def initialize(value)
|
|
34
|
+
if value.is_a?(ActiveRecord::Relation)
|
|
35
|
+
@klass = value.klass
|
|
36
|
+
@source = 'relation'
|
|
37
|
+
@limit = MAX_RECORDS
|
|
38
|
+
@records = read(value)
|
|
39
|
+
@total = @records.size > @limit ? nil : @records.size
|
|
40
|
+
else
|
|
41
|
+
@klass = value.first.class
|
|
42
|
+
@source = 'array'
|
|
43
|
+
@limit = MAX_ARRAY_RECORDS
|
|
44
|
+
@records = value
|
|
45
|
+
@total = value.size
|
|
46
|
+
end
|
|
47
|
+
@more = @records.size > @limit
|
|
48
|
+
@records = @records.first(@limit)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call
|
|
52
|
+
return header([]) if @records.empty?
|
|
53
|
+
return Record.new(@records.first).call if @records.size == 1
|
|
54
|
+
|
|
55
|
+
names = @records.first.attribute_names
|
|
56
|
+
same = constant_columns(names)
|
|
57
|
+
shown, hidden = fit(order(names - same))
|
|
58
|
+
return transposed(names, same) if hidden.any? && @records.size <= TRANSPOSE_UP_TO
|
|
59
|
+
|
|
60
|
+
footer_rows = rows(footer(same, hidden), indent: 3, guide: :accent)
|
|
61
|
+
[header(names, shown + same), *table(shown), *footer_rows].join("\n")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def read(relation)
|
|
67
|
+
return relation.records.first(@limit + 1) if relation.loaded?
|
|
68
|
+
|
|
69
|
+
relation.take([relation.limit_value, @limit + 1].compact.min)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Columns, other than the primary key, with one value in every record.
|
|
73
|
+
def constant_columns(names)
|
|
74
|
+
names.select do |name|
|
|
75
|
+
name != @klass.primary_key && @records.map { |record| record[name] }.uniq.size == 1
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def header(names, shown = names)
|
|
80
|
+
count = @total || "#{@limit}+"
|
|
81
|
+
noun = key_for(@klass)
|
|
82
|
+
noun = noun.pluralize unless @total == 1
|
|
83
|
+
columns = "#{names.size} #{names.size == 1 ? 'column' : 'columns'}"
|
|
84
|
+
columns += ", #{shown.size} shown" unless names.size == shown.size
|
|
85
|
+
"#{badge("#{count} #{noun}")} #{paint([@source, (columns if names.any?)].compact.join(' · '), :faint)}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def order(names)
|
|
89
|
+
primary = @klass.primary_key
|
|
90
|
+
identity = IDENTITY.find { |name| names.include?(name) }
|
|
91
|
+
first = [primary, identity].compact & names
|
|
92
|
+
stamps = names & TIMESTAMPS
|
|
93
|
+
first + (names - first - stamps) + stamps
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def fit(names)
|
|
97
|
+
available = screen_width - MARGIN
|
|
98
|
+
used = 0
|
|
99
|
+
shown = names.take_while do |name|
|
|
100
|
+
used += width_of(name) + GAP
|
|
101
|
+
used - GAP <= available
|
|
102
|
+
end
|
|
103
|
+
shown = names.first(1) if shown.empty?
|
|
104
|
+
[shown, names - shown]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def width_of(name)
|
|
108
|
+
@widths ||= {}
|
|
109
|
+
@widths[name] ||= [name.size, *@records.map { |record| visible_size(cell(record[name])) }].max
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def table(names)
|
|
113
|
+
guide = " #{paint('│', :accent)} "
|
|
114
|
+
head = names.map { |name| ljust(paint(name, :faint), width_of(name)) }.join(' ' * GAP).rstrip
|
|
115
|
+
lines = @records.map do |record|
|
|
116
|
+
names.map do |name|
|
|
117
|
+
value = record[name]
|
|
118
|
+
value.is_a?(Numeric) ? rjust(cell(value), width_of(name)) : ljust(cell(value), width_of(name))
|
|
119
|
+
end.join(' ' * GAP).rstrip
|
|
120
|
+
end
|
|
121
|
+
[head, *lines].map { |line| "#{guide}#{line}" }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# label_width: the widest label of the rows these go with, so the values stay inside the screen.
|
|
125
|
+
def footer(same, hidden, label_width = 'hidden'.size)
|
|
126
|
+
width = screen_width - MARGIN - label_width - 2
|
|
127
|
+
[
|
|
128
|
+
(['same', same_text(same, width)] if same.any?),
|
|
129
|
+
(['hidden', "#{list(hidden, width - 22)} #{paint('.select(...) to pick', :faint)}"] if hidden.any?),
|
|
130
|
+
(['more', paint(more_hint, :faint)] if @more)
|
|
131
|
+
]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def more_hint
|
|
135
|
+
return "#{@limit} of #{@total} shown; .each or pp for all" if @total
|
|
136
|
+
|
|
137
|
+
"inspect reads #{@limit}; .to_a shows up to #{MAX_ARRAY_RECORDS}, .each goes through all"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def same_text(names, width)
|
|
141
|
+
entries = names.map do |name|
|
|
142
|
+
value = @records.first[name]
|
|
143
|
+
"#{name} #{value.nil? ? paint('nil', :faint) : cell(value)}"
|
|
144
|
+
end
|
|
145
|
+
text = entries.join(paint(' · ', :faint))
|
|
146
|
+
visible_size(text) <= width ? text : list(names, width)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# One record per column, one attribute per row.
|
|
150
|
+
def transposed(names, same)
|
|
151
|
+
columns = order(names - same)
|
|
152
|
+
primary = @klass.primary_key
|
|
153
|
+
@record_widths = @records.map { |record| [primary, *columns].map { |name| width_in(record, name) }.max }
|
|
154
|
+
|
|
155
|
+
ids = record_line(@records.map { |record| paint(cell(record[primary]), :bold) })
|
|
156
|
+
attributes = (columns - [primary]).map { |name| [name, record_line(@records.map { |r| cell(r[name]) })] }
|
|
157
|
+
label_width = [*columns, 'same'].map(&:size).max
|
|
158
|
+
table = [['', ids], *cap(attributes, MAX_ROWS), *footer(same, [], label_width)]
|
|
159
|
+
|
|
160
|
+
[header(names), *rows(table, indent: 3, guide: :accent)].join("\n")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def width_in(record, name) = visible_size(cell(record[name]))
|
|
164
|
+
|
|
165
|
+
# The cells of one attribute, one per record, each in its record's column.
|
|
166
|
+
def record_line(cells)
|
|
167
|
+
cells.each_with_index.map { |text, index| ljust(text, @record_widths[index]) }.join(' ' * GAP).rstrip
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
|
|
5
|
+
# WARN: AI MADE
|
|
6
|
+
module Thunder
|
|
7
|
+
class Console
|
|
8
|
+
module Views
|
|
9
|
+
# What the views share: the terminal width, the registry or exporter key of a class, and cell and value formatting; Layout
|
|
10
|
+
# adds the sections and aligned columns they are printed in.
|
|
11
|
+
module Format
|
|
12
|
+
include Theme
|
|
13
|
+
include Layout
|
|
14
|
+
|
|
15
|
+
UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/i
|
|
16
|
+
ANSI = /\e\[[\d;]*m/
|
|
17
|
+
# Room taken by IRB's "=> " and the "│ " guide before a view's rows.
|
|
18
|
+
MARGIN = 5
|
|
19
|
+
CELL_WIDTH = 24
|
|
20
|
+
TIMESTAMPS = %w[created_at updated_at].freeze
|
|
21
|
+
|
|
22
|
+
# Columns taken before the view starts, when it is printed inside another table (a Response's payload).
|
|
23
|
+
attr_writer :inset
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def screen_width
|
|
28
|
+
width = IO.console&.winsize&.last.to_i
|
|
29
|
+
width = width.positive? ? [width, 60].max : 120
|
|
30
|
+
[width - @inset.to_i, 40].max
|
|
31
|
+
rescue StandardError
|
|
32
|
+
120
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The key the class is registered or exported under in the console's chunk ('book', 'books.add',
|
|
36
|
+
# 'books.show'); its underscored name otherwise.
|
|
37
|
+
def key_for(klass)
|
|
38
|
+
chunk = Console.current&.chunk
|
|
39
|
+
items = [chunk&.registry, chunk&.exporter].compact.flat_map { |source| source.hash.values }
|
|
40
|
+
key = items.lazy.filter_map { |by_key| by_key.key(klass) }.first
|
|
41
|
+
key || klass.name.to_s.demodulize.underscore
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def visible_size(text) = text.to_s.gsub(ANSI, '').size
|
|
45
|
+
|
|
46
|
+
def ljust(text, width) = "#{text}#{' ' * [width - visible_size(text), 0].max}"
|
|
47
|
+
|
|
48
|
+
def rjust(text, width) = "#{' ' * [width - visible_size(text), 0].max}#{text}"
|
|
49
|
+
|
|
50
|
+
def truncate(text, width)
|
|
51
|
+
text = text.to_s
|
|
52
|
+
text.size > width ? "#{text[0, [width - 1, 1].max]}…" : text
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def uuid?(value) = value.is_a?(String) && value.match?(UUID)
|
|
56
|
+
|
|
57
|
+
def short_id(value) = uuid?(value) ? "#{value[0, 8]}…" : value.to_s
|
|
58
|
+
|
|
59
|
+
def time?(value) = value.is_a?(Time) || value.is_a?(DateTime) || value.is_a?(ActiveSupport::TimeWithZone)
|
|
60
|
+
|
|
61
|
+
# Times come from the database in UTC; they are shown in Time.zone when it is set, in the system's zone
|
|
62
|
+
# otherwise, so a time without its zone is the local one.
|
|
63
|
+
def local(value) = Time.zone ? value.in_time_zone : value.to_time.getlocal
|
|
64
|
+
|
|
65
|
+
# A value in a table cell: one line, at most CELL_WIDTH wide.
|
|
66
|
+
def cell(value)
|
|
67
|
+
return paint('·', :faint) if value.nil?
|
|
68
|
+
return paint(short_id(value), :faint) if uuid?(value)
|
|
69
|
+
return local(value).strftime('%Y-%m-%d %H:%M') if time?(value)
|
|
70
|
+
|
|
71
|
+
truncate(scalar(value) || value.inspect, CELL_WIDTH)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The text of a date, decimal, string, number or boolean; nil for anything else.
|
|
75
|
+
def scalar(value)
|
|
76
|
+
case value
|
|
77
|
+
when Date then value.iso8601
|
|
78
|
+
when BigDecimal then value.to_s('F')
|
|
79
|
+
when String, Numeric, true, false then value.to_s
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A value in a row of its own, cut to width.
|
|
84
|
+
def value_text(value, width)
|
|
85
|
+
return paint('nil', :faint) if value.nil?
|
|
86
|
+
return value if uuid?(value)
|
|
87
|
+
return local(value).strftime('%Y-%m-%d %H:%M:%S') if time?(value)
|
|
88
|
+
|
|
89
|
+
case value
|
|
90
|
+
when Date, BigDecimal then scalar(value)
|
|
91
|
+
when String then long_string(value, width)
|
|
92
|
+
when Hash, Array then collection_text(value, width)
|
|
93
|
+
else truncate(value.inspect, width)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def long_string(value, width)
|
|
98
|
+
text = value.inspect
|
|
99
|
+
return text if text.size <= width
|
|
100
|
+
|
|
101
|
+
size = " #{value.size >= 1000 ? format('%.1fk', value.size / 1000.0) : value.size} chars"
|
|
102
|
+
"#{truncate(text, width - size.size)}#{paint(size, :faint)}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def collection_text(value, width)
|
|
106
|
+
text = value.inspect
|
|
107
|
+
return text if text.size <= width
|
|
108
|
+
|
|
109
|
+
value.is_a?(Hash) ? "{…} #{value.size} keys" : "[…] #{value.size} items"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
module Views
|
|
7
|
+
# How the views lay text out: lists cut to a width, badges, capped rows, titled sections and aligned columns.
|
|
8
|
+
# Measures text with visible_size and ljust, and fits it to screen_width, all from Format.
|
|
9
|
+
module Layout
|
|
10
|
+
include Theme
|
|
11
|
+
|
|
12
|
+
SECTION_INDENT = ' '
|
|
13
|
+
ITEM_INDENT = ' '
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Names joined with ", " up to width; the rest becomes "+N".
|
|
18
|
+
def list(names, width)
|
|
19
|
+
shown = []
|
|
20
|
+
names.each_with_index do |name, index|
|
|
21
|
+
rest = names.size - index - 1
|
|
22
|
+
candidate = [*shown, name].join(', ')
|
|
23
|
+
budget = rest.positive? ? width - ", +#{rest}".size : width
|
|
24
|
+
break if candidate.size > budget
|
|
25
|
+
|
|
26
|
+
shown << name
|
|
27
|
+
end
|
|
28
|
+
hidden = names.size - shown.size
|
|
29
|
+
return shown.join(', ') if hidden.zero?
|
|
30
|
+
return paint("+#{hidden}", :faint) if shown.empty?
|
|
31
|
+
|
|
32
|
+
"#{shown.join(', ')}#{paint(", +#{hidden}", :faint)}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def badge(text, role = :accent) = paint(" #{text} ", role, :bold, :reverse)
|
|
36
|
+
|
|
37
|
+
# The first max - 1 rows and a row saying how many were left out, when there are more than max.
|
|
38
|
+
def cap(rows, max, hint = '')
|
|
39
|
+
return rows if rows.size <= max
|
|
40
|
+
|
|
41
|
+
[*rows.first(max - 1), ['', paint("… #{rows.size - (max - 1)} more#{hint}", :faint)]]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A titled section: the title on a line of its own, then its lines indented under it.
|
|
45
|
+
# params 2
|
|
46
|
+
# title String
|
|
47
|
+
# copies Integer optional
|
|
48
|
+
def section(title, lines, count: nil)
|
|
49
|
+
head = paint(title, :accent, :bold)
|
|
50
|
+
head += " #{paint(count, :faint)}" if count
|
|
51
|
+
["#{SECTION_INDENT}#{head}", *lines.map { |line| "#{ITEM_INDENT}#{line}" }]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Rows of cells with every column aligned; the last cell of a row is not padded.
|
|
55
|
+
def aligned(table)
|
|
56
|
+
widths = []
|
|
57
|
+
table.each do |cells|
|
|
58
|
+
cells.each_with_index { |text, index| widths[index] = [widths[index].to_i, visible_size(text)].max }
|
|
59
|
+
end
|
|
60
|
+
table.map do |cells|
|
|
61
|
+
last = cells.size - 1
|
|
62
|
+
cells.each_with_index.map { |text, index| index == last ? text : ljust(text, widths[index]) }
|
|
63
|
+
.join(' ').rstrip
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Width left for an item's text after the section indent.
|
|
68
|
+
def item_width = screen_width - 3 - ITEM_INDENT.size
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# WARN: AI MADE
|
|
4
|
+
module Thunder
|
|
5
|
+
class Console
|
|
6
|
+
module Views
|
|
7
|
+
# A model class as its schema, one section per part, one item per line: the columns with type and flags (pk,
|
|
8
|
+
# → the association of a foreign key, not null, default, array), primary and foreign keys first and timestamps
|
|
9
|
+
# last; then the associations by kind and the enums.
|
|
10
|
+
# => MODEL book Library::Internal::Models::Book · books
|
|
11
|
+
# columns 4
|
|
12
|
+
# id integer pk
|
|
13
|
+
# title string not null
|
|
14
|
+
# created_at datetime not null
|
|
15
|
+
# associations
|
|
16
|
+
# has_many loans
|
|
17
|
+
class Model
|
|
18
|
+
include Format
|
|
19
|
+
|
|
20
|
+
MAX_COLUMNS = 20
|
|
21
|
+
MACROS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
|
|
22
|
+
|
|
23
|
+
def initialize(klass)
|
|
24
|
+
@klass = klass
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
columns = @klass.columns
|
|
29
|
+
sections = [
|
|
30
|
+
section('columns', column_lines(columns), count: columns.size),
|
|
31
|
+
(section('associations', association_lines) if association_lines.any?),
|
|
32
|
+
(section('enums', enum_lines) if enum_lines.any?)
|
|
33
|
+
]
|
|
34
|
+
[header, *sections.compact.flatten].join("\n")
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
[header, *section('schema', [paint("unavailable: #{e.message.lines.first.to_s.strip}", :faint)])].join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def header
|
|
42
|
+
details = paint("#{@klass.name} · #{@klass.table_name}", :faint)
|
|
43
|
+
"#{badge('MODEL')} #{paint(key_for(@klass), :bold)} #{details}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def column_lines(columns)
|
|
47
|
+
keys, rest = columns.partition { |column| key?(column.name) }
|
|
48
|
+
stamps, rest = rest.partition { |column| TIMESTAMPS.include?(column.name) }
|
|
49
|
+
ordered = keys + rest + stamps
|
|
50
|
+
lines = aligned(ordered.first(MAX_COLUMNS).map { |column| column_cells(column) })
|
|
51
|
+
return lines if ordered.size <= MAX_COLUMNS
|
|
52
|
+
|
|
53
|
+
[*lines, paint("… #{ordered.size - MAX_COLUMNS} more · #{@klass.name.demodulize}.columns for all", :faint)]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def column_cells(column) = [column.name, paint(type(column), :faint), *flags(column)]
|
|
57
|
+
|
|
58
|
+
def key?(name) = name == @klass.primary_key || foreign_keys.key?(name)
|
|
59
|
+
|
|
60
|
+
def type(column) = "#{column.type || column.sql_type}#{'[]' if column.respond_to?(:array?) && column.array?}"
|
|
61
|
+
|
|
62
|
+
def flags(column)
|
|
63
|
+
flags = []
|
|
64
|
+
flags << paint('pk', :accent) if column.name == @klass.primary_key
|
|
65
|
+
flags << paint("→ #{foreign_keys[column.name]}", :accent) if foreign_keys.key?(column.name)
|
|
66
|
+
flags << paint('not null', :faint) unless column.null || column.name == @klass.primary_key
|
|
67
|
+
flags << paint("default #{truncate(column.default, 20)}", :faint) unless column.default.nil?
|
|
68
|
+
flags.empty? ? [] : [flags.join(paint(' · ', :faint))]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def foreign_keys
|
|
72
|
+
@foreign_keys ||= @klass.reflect_on_all_associations(:belongs_to).to_h do |association|
|
|
73
|
+
[association.foreign_key.to_s, association.name.to_s]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def association_lines
|
|
78
|
+
@association_lines ||= aligned(MACROS.filter_map do |macro|
|
|
79
|
+
names = @klass.reflect_on_all_associations(macro).map { |association| association.name.to_s }
|
|
80
|
+
[paint(macro.to_s, :faint), list(names, item_width - macro.size - 2)] if names.any?
|
|
81
|
+
end)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def enum_lines
|
|
85
|
+
@enum_lines ||= aligned(enums.map { |name, values| [name, list(values.keys, item_width - name.size - 2)] })
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def enums = @klass.respond_to?(:defined_enums) ? @klass.defined_enums : {}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|