r2ui 0.1.0 → 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 +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +79 -0
- data/lib/r2ui/app.rb +311 -34
- data/lib/r2ui/canvas.rb +135 -14
- data/lib/r2ui/cli/builder.rb +72 -0
- data/lib/r2ui/cli/context.rb +45 -0
- data/lib/r2ui/cli/definition.rb +174 -0
- data/lib/r2ui/cli/ext/ask.rb +133 -0
- data/lib/r2ui/cli/ext/box.rb +112 -0
- data/lib/r2ui/cli/ext/choose.rb +162 -0
- data/lib/r2ui/cli/ext/choose_many.rb +241 -0
- data/lib/r2ui/cli/ext/color_option.rb +64 -0
- data/lib/r2ui/cli/ext/completion.rb +520 -0
- data/lib/r2ui/cli/ext/config.rb +179 -0
- data/lib/r2ui/cli/ext/confirm.rb +110 -0
- data/lib/r2ui/cli/ext/dashboard.rb +105 -0
- data/lib/r2ui/cli/ext/diff.rb +195 -0
- data/lib/r2ui/cli/ext/editor.rb +94 -0
- data/lib/r2ui/cli/ext/env.rb +108 -0
- data/lib/r2ui/cli/ext/examples.rb +62 -0
- data/lib/r2ui/cli/ext/filter.rb +269 -0
- data/lib/r2ui/cli/ext/format.rb +104 -0
- data/lib/r2ui/cli/ext/list.rb +117 -0
- data/lib/r2ui/cli/ext/log.rb +89 -0
- data/lib/r2ui/cli/ext/pager.rb +97 -0
- data/lib/r2ui/cli/ext/pairs.rb +62 -0
- data/lib/r2ui/cli/ext/parallel.rb +274 -0
- data/lib/r2ui/cli/ext/password.rb +158 -0
- data/lib/r2ui/cli/ext/progress.rb +330 -0
- data/lib/r2ui/cli/ext/reference.rb +129 -0
- data/lib/r2ui/cli/ext/spinner.rb +165 -0
- data/lib/r2ui/cli/ext/summary.rb +63 -0
- data/lib/r2ui/cli/ext/table.rb +141 -0
- data/lib/r2ui/cli/ext/tasks.rb +216 -0
- data/lib/r2ui/cli/ext/trace.rb +64 -0
- data/lib/r2ui/cli/ext/tree.rb +114 -0
- data/lib/r2ui/cli/ext/version.rb +67 -0
- data/lib/r2ui/cli/extension.rb +129 -0
- data/lib/r2ui/cli/help.rb +115 -0
- data/lib/r2ui/cli/helpers.rb +20 -0
- data/lib/r2ui/cli/live.rb +150 -0
- data/lib/r2ui/cli/parser.rb +165 -0
- data/lib/r2ui/cli/prompt.rb +133 -0
- data/lib/r2ui/cli/runner.rb +86 -0
- data/lib/r2ui/cli/shell.rb +185 -0
- data/lib/r2ui/cli/testing.rb +49 -0
- data/lib/r2ui/cli.rb +129 -0
- data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
- data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
- data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
- data/lib/r2ui/compat/bubbletea/input.rb +269 -0
- data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
- data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
- data/lib/r2ui/compat/bubbletea/model.rb +65 -0
- data/lib/r2ui/compat/bubbletea/native.rb +254 -0
- data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
- data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
- data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
- data/lib/r2ui/compat/bubbletea/version.rb +8 -0
- data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
- data/lib/r2ui/compat/bubbletea.rb +16 -0
- data/lib/r2ui/compat/lipgloss/border.rb +17 -0
- data/lib/r2ui/compat/lipgloss/color.rb +78 -0
- data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
- data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
- data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
- data/lib/r2ui/compat/lipgloss/list.rb +85 -0
- data/lib/r2ui/compat/lipgloss/position.rb +30 -0
- data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
- data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
- data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
- data/lib/r2ui/compat/lipgloss/table.rb +185 -0
- data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
- data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
- data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
- data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
- data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
- data/lib/r2ui/compat/lipgloss/version.rb +15 -0
- data/lib/r2ui/compat/lipgloss.rb +64 -0
- data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
- data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
- data/lib/r2ui/component.rb +67 -0
- data/lib/r2ui/context.rb +87 -0
- data/lib/r2ui/drop_in.rb +16 -0
- data/lib/r2ui/dsl/column.rb +11 -3
- data/lib/r2ui/dsl/dashboard.rb +45 -9
- data/lib/r2ui/dsl/resource.rb +12 -0
- data/lib/r2ui/ext/after.rb +54 -0
- data/lib/r2ui/ext/async.rb +81 -0
- data/lib/r2ui/ext/batch_sequence.rb +54 -0
- data/lib/r2ui/ext/countdown.rb +52 -0
- data/lib/r2ui/ext/data_table.rb +71 -0
- data/lib/r2ui/ext/every.rb +66 -0
- data/lib/r2ui/ext/exec.rb +116 -0
- data/lib/r2ui/ext/file_picker.rb +78 -0
- data/lib/r2ui/ext/focus_report.rb +69 -0
- data/lib/r2ui/ext/help.rb +62 -0
- data/lib/r2ui/ext/keys.rb +64 -0
- data/lib/r2ui/ext/list.rb +145 -0
- data/lib/r2ui/ext/messages.rb +87 -0
- data/lib/r2ui/ext/mouse.rb +134 -0
- data/lib/r2ui/ext/paginator.rb +86 -0
- data/lib/r2ui/ext/paste.rb +86 -0
- data/lib/r2ui/ext/println.rb +21 -0
- data/lib/r2ui/ext/progress.rb +85 -0
- data/lib/r2ui/ext/refresh.rb +56 -0
- data/lib/r2ui/ext/resize.rb +62 -0
- data/lib/r2ui/ext/screen.rb +47 -0
- data/lib/r2ui/ext/screen_mode.rb +63 -0
- data/lib/r2ui/ext/spinner.rb +59 -0
- data/lib/r2ui/ext/state.rb +45 -0
- data/lib/r2ui/ext/stopwatch.rb +67 -0
- data/lib/r2ui/ext/suspend.rb +56 -0
- data/lib/r2ui/ext/text_area.rb +73 -0
- data/lib/r2ui/ext/text_input.rb +83 -0
- data/lib/r2ui/ext/theme.rb +112 -0
- data/lib/r2ui/ext/view.rb +37 -0
- data/lib/r2ui/ext/viewport.rb +92 -0
- data/lib/r2ui/ext/window_title.rb +51 -0
- data/lib/r2ui/extension.rb +199 -0
- data/lib/r2ui/history.rb +4 -1
- data/lib/r2ui/keys.rb +35 -8
- data/lib/r2ui/motion.rb +169 -0
- data/lib/r2ui/panel_state.rb +46 -2
- data/lib/r2ui/renderer.rb +87 -22
- data/lib/r2ui/version.rb +1 -1
- data/lib/r2ui/widgets/box.rb +6 -3
- data/lib/r2ui/widgets/glyphs.rb +149 -0
- data/lib/r2ui/widgets/table.rb +109 -25
- data/lib/r2ui.rb +12 -1
- metadata +119 -4
- data/lib/r2ui/terminal.rb +0 -53
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c10-table: tables, like `npm outdated` or `pnpm ls --long`.
|
|
4
|
+
#
|
|
5
|
+
# table([["rails", "7.1.0", "12 kB"], ["pg", "1.5", "1.2 MB"]],
|
|
6
|
+
# headers: %w[Name Version Size], align: { 2 => :right })
|
|
7
|
+
#
|
|
8
|
+
# table([{ app: "api", state: "up" }, { app: "web", state: "down" }]) # headers from the keys
|
|
9
|
+
#
|
|
10
|
+
# On a terminal (Shell#live?) it draws a Lipgloss::Table with a rounded border (muted) and a bold
|
|
11
|
+
# header, sized to its content and shrunk (wrapping cells) when wider than the shell:
|
|
12
|
+
#
|
|
13
|
+
# ╭───────┬─────────┬────────╮
|
|
14
|
+
# │ Name │ Version │ Size │
|
|
15
|
+
# ├───────┼─────────┼────────┤
|
|
16
|
+
# │ rails │ 7.1.0 │ 12 kB │
|
|
17
|
+
# │ pg │ 1.5 │ 1.2 MB │
|
|
18
|
+
# ╰───────┴─────────┴────────╯
|
|
19
|
+
#
|
|
20
|
+
# Off a terminal (a pipe, CI, TERM=dumb) it prints plain columns separated by two spaces, no
|
|
21
|
+
# border, no colour, no trailing spaces, one line per row (newlines in a cell become spaces), so
|
|
22
|
+
# `tool ls | awk '{print $1}'` works:
|
|
23
|
+
#
|
|
24
|
+
# Name Version Size
|
|
25
|
+
# rails 7.1.0 12 kB
|
|
26
|
+
# pg 1.5 1.2 MB
|
|
27
|
+
#
|
|
28
|
+
# Rows are Arrays (short ones are padded) or Hashes. With Hash rows and no `headers:`, the headers
|
|
29
|
+
# are the keys in the order they first appear; with `headers:`, they pick and order the columns.
|
|
30
|
+
# Cells are shown with #to_s (nil is blank) and measured by display width, so wide characters and
|
|
31
|
+
# already-painted text line up. `align:` maps a column (0-based index, or its header) to :left,
|
|
32
|
+
# :right or :center. With no rows only the header prints; with neither, nothing. Writes to stdout
|
|
33
|
+
# through the Shell (above a running Live region) and returns nil.
|
|
34
|
+
module R2UI
|
|
35
|
+
module CLI
|
|
36
|
+
module Ext
|
|
37
|
+
module Tabulate
|
|
38
|
+
ALIGNMENTS = { left: Lipgloss::LEFT, right: Lipgloss::RIGHT, center: Lipgloss::CENTER }.freeze
|
|
39
|
+
GAP = " "
|
|
40
|
+
|
|
41
|
+
module_function
|
|
42
|
+
|
|
43
|
+
# [headers or nil, rows as Arrays of Strings, all the same length].
|
|
44
|
+
def normalize(rows, headers)
|
|
45
|
+
rows = rows.to_a
|
|
46
|
+
if rows.any? { |row| row.is_a?(Hash) }
|
|
47
|
+
keys = headers || rows.flat_map { |row| row.to_h.keys }.uniq
|
|
48
|
+
rows = rows.map { |row| row.is_a?(Hash) ? keys.map { |key| lookup(row, key) } : Array(row) }
|
|
49
|
+
headers = keys
|
|
50
|
+
else
|
|
51
|
+
rows = rows.map { |row| Array(row) }
|
|
52
|
+
end
|
|
53
|
+
headers = headers&.map { |cell| text(cell) }
|
|
54
|
+
count = [headers&.size || 0, *rows.map(&:size)].max
|
|
55
|
+
rows = rows.map { |row| Array.new(count) { |i| text(row[i]) } }
|
|
56
|
+
headers = Array.new(count) { |i| headers[i] || "" } if headers
|
|
57
|
+
[headers, rows]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def lookup(row, key)
|
|
61
|
+
[key, key.to_s, key.respond_to?(:to_sym) ? key.to_sym : key].each do |k|
|
|
62
|
+
return row[k] if row.key?(k)
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def text(cell) = cell.nil? ? "" : cell.to_s
|
|
68
|
+
|
|
69
|
+
# { column index => :left/:right/:center } from indexes or header names.
|
|
70
|
+
def alignments(align, headers)
|
|
71
|
+
(align || {}).to_h do |column, side|
|
|
72
|
+
raise ArgumentError, "align: #{side.inspect} isn't :left, :right or :center" unless ALIGNMENTS.key?(side)
|
|
73
|
+
|
|
74
|
+
index = column.is_a?(Integer) ? column : headers&.index(column.to_s)
|
|
75
|
+
raise ArgumentError, "align: no column #{column.inspect}" unless index
|
|
76
|
+
|
|
77
|
+
[index, side]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def width(text) = Lipgloss.width(text)
|
|
82
|
+
|
|
83
|
+
def plain(headers, rows, align)
|
|
84
|
+
lines = headers ? [headers, *rows] : rows
|
|
85
|
+
return [] if lines.empty?
|
|
86
|
+
|
|
87
|
+
lines = lines.map { |cells| cells.map { |cell| cell.gsub(/\s*\R\s*/, " ") } }
|
|
88
|
+
widths = lines.transpose.map { |column| column.map { |cell| width(cell) }.max }
|
|
89
|
+
lines.map do |cells|
|
|
90
|
+
cells.each_with_index.map { |cell, i| pad(cell, widths[i], align[i]) }.join(GAP).rstrip
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def pad(cell, size, side)
|
|
95
|
+
space = size - width(cell)
|
|
96
|
+
case side
|
|
97
|
+
when :right then (" " * space) + cell
|
|
98
|
+
when :center then (" " * (space / 2)) + cell + (" " * (space - (space / 2)))
|
|
99
|
+
else cell + (" " * space)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def boxed(shell, headers, rows, align)
|
|
104
|
+
return [] if headers.nil? && rows.empty?
|
|
105
|
+
|
|
106
|
+
columns = (headers || rows.first).size
|
|
107
|
+
color = shell.color?
|
|
108
|
+
cell = Lipgloss::Style.new.padding(0, 1)
|
|
109
|
+
head = color ? cell.bold(true) : cell
|
|
110
|
+
table = Lipgloss::Table.new.border(:rounded).rows(rows)
|
|
111
|
+
table = table.headers(headers) if headers
|
|
112
|
+
table = table.border_header(false) if rows.empty? # no rule above an empty body
|
|
113
|
+
table = table.border_style(shell.theme.style(:muted)) if color
|
|
114
|
+
table = table.style_func(rows: rows.size, columns:) do |row, column|
|
|
115
|
+
style = row == Lipgloss::Table::HEADER_ROW ? head : cell
|
|
116
|
+
(side = align[column]) ? style.align(ALIGNMENTS.fetch(side)) : style
|
|
117
|
+
end
|
|
118
|
+
out = table.render
|
|
119
|
+
out = table.width(shell.width).render if out.lines.any? { |line| width(line.chomp) > shell.width }
|
|
120
|
+
out.split("\n")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
extension :table do
|
|
126
|
+
helpers do
|
|
127
|
+
def table(rows, headers: nil, align: nil)
|
|
128
|
+
headers, rows = Ext::Tabulate.normalize(rows, headers)
|
|
129
|
+
align = Ext::Tabulate.alignments(align, headers)
|
|
130
|
+
lines = if shell.live?
|
|
131
|
+
Ext::Tabulate.boxed(shell, headers, rows, align)
|
|
132
|
+
else
|
|
133
|
+
Ext::Tabulate.plain(headers, rows, align)
|
|
134
|
+
end
|
|
135
|
+
lines.each { |line| shell.puts(line) }
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c02-tasks: an npm-style task list. Steps show as pending, spin while they run and resolve to
|
|
4
|
+
# ✔ / ✖ / – lines with their timings.
|
|
5
|
+
#
|
|
6
|
+
# tasks do
|
|
7
|
+
# step("Resolving packages") { resolve } # the block's value is kept
|
|
8
|
+
# step("Fetching") { |s| files.each_with_index { |f, i| s.detail = "#{i + 1}/#{files.size}"; get(f) } }
|
|
9
|
+
# step("Linking") { |s| s.skip!("nothing to link") if links.empty?; link }
|
|
10
|
+
# end # => [resolved, fetched, linked]
|
|
11
|
+
#
|
|
12
|
+
# On a terminal (Shell#live?) the list redraws in place:
|
|
13
|
+
#
|
|
14
|
+
# ✔ Resolving packages 120ms
|
|
15
|
+
# ⠹ Fetching · 12/40
|
|
16
|
+
# ○ Linking
|
|
17
|
+
#
|
|
18
|
+
# and its final state stays in the scrollback. Off a terminal each step prints one line when it
|
|
19
|
+
# ends ("✔ Resolving packages 120ms"), nothing else: no spinner frames, no escape codes.
|
|
20
|
+
#
|
|
21
|
+
# The `tasks` block declares the steps; they run after it, in order, on the caller's thread (so
|
|
22
|
+
# they see `args`, `options` and every helper). When a step raises, it shows ✖, the steps after it
|
|
23
|
+
# show "– skipped" without running, and the exception propagates (a command then exits 1 with
|
|
24
|
+
# "✖ message"; ctrl+c exits 130). `s.skip!(reason)` ends a step as skipped and carries on.
|
|
25
|
+
# `say`/`shell.puts` inside a step prints above the list.
|
|
26
|
+
#
|
|
27
|
+
# `step("Building") { ... }` outside a `tasks` block is a one-step list: it runs at once and
|
|
28
|
+
# returns the block's value.
|
|
29
|
+
#
|
|
30
|
+
# This is the reference extension for output helpers (docs/cli.md): a `helpers` block, a Live
|
|
31
|
+
# region for the terminal, one plain line per event off it. Copy its shape and its test.
|
|
32
|
+
module R2UI
|
|
33
|
+
module CLI
|
|
34
|
+
module Ext
|
|
35
|
+
module Tasks
|
|
36
|
+
# A step that ended early by choice (Handle#skip!).
|
|
37
|
+
class Skip < StandardError; end
|
|
38
|
+
|
|
39
|
+
Step = Struct.new(:title, :block, :status, :detail, :note, :started, :finished, :value) do
|
|
40
|
+
def elapsed = finished && started ? finished - started : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# What a step's block gets: change its title or detail line while it runs, or skip it.
|
|
44
|
+
class Handle
|
|
45
|
+
def initialize(step, live)
|
|
46
|
+
@step = step
|
|
47
|
+
@live = live
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def title = @step.title
|
|
51
|
+
|
|
52
|
+
def title=(text)
|
|
53
|
+
@step.title = text.to_s
|
|
54
|
+
@live.refresh
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def detail=(text)
|
|
58
|
+
@step.detail = text&.to_s
|
|
59
|
+
@live.refresh
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def skip!(reason = nil) = raise(Skip, reason)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The steps a `tasks` block declared, and how they run and draw.
|
|
66
|
+
class List
|
|
67
|
+
def initialize(shell)
|
|
68
|
+
@shell = shell
|
|
69
|
+
@steps = []
|
|
70
|
+
@lock = Monitor.new
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def add(title, block)
|
|
74
|
+
raise ArgumentError, "step needs a block" unless block
|
|
75
|
+
|
|
76
|
+
@steps << Step.new(title: title.to_s, block:, status: :pending)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Runs every step; returns their values. Re-raises the first step's exception.
|
|
80
|
+
def run
|
|
81
|
+
error = nil
|
|
82
|
+
live = Live.new(@shell) { |frame| view(frame) }
|
|
83
|
+
live.run do
|
|
84
|
+
@steps.each do |step|
|
|
85
|
+
if error
|
|
86
|
+
finish(step, :skipped, live)
|
|
87
|
+
next
|
|
88
|
+
end
|
|
89
|
+
error = run_step(step, live)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
raise error if error
|
|
93
|
+
|
|
94
|
+
@steps.map(&:value)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def view(frame)
|
|
98
|
+
@lock.synchronize { @steps.map { |step| line(step, frame) }.join("\n") }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def run_step(step, live)
|
|
104
|
+
update(step, live) do
|
|
105
|
+
step.status = :running
|
|
106
|
+
step.started = now
|
|
107
|
+
end
|
|
108
|
+
step.value = step.block.call(Handle.new(step, live))
|
|
109
|
+
finish(step, :done, live)
|
|
110
|
+
nil
|
|
111
|
+
rescue Skip => e
|
|
112
|
+
step.note = e.message unless e.message == e.class.name
|
|
113
|
+
finish(step, :skipped, live)
|
|
114
|
+
nil
|
|
115
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- ctrl+c and halt end the list too
|
|
116
|
+
finish(step, e.is_a?(Halt) ? :done : :failed, live)
|
|
117
|
+
e
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def finish(step, status, live)
|
|
121
|
+
update(step, live) do
|
|
122
|
+
step.status = status
|
|
123
|
+
step.finished = now if step.started
|
|
124
|
+
end
|
|
125
|
+
@shell.puts(line(step, 0)) unless live.running?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def update(step, live)
|
|
129
|
+
@lock.synchronize { yield step }
|
|
130
|
+
live.refresh
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def line(step, frame)
|
|
134
|
+
title = step.title
|
|
135
|
+
detail = step.detail ? @shell.paint(" · #{step.detail}", :muted) : ""
|
|
136
|
+
time = step.elapsed ? " #{@shell.paint(Tasks.duration(step.elapsed), :muted)}" : ""
|
|
137
|
+
case step.status
|
|
138
|
+
when :pending then "#{@shell.symbol(:pending)} #{@shell.paint(title, :pending)}"
|
|
139
|
+
when :running then "#{@shell.paint(Live.spinner(frame), :running)} #{title}#{detail}"
|
|
140
|
+
when :done then "#{@shell.symbol(:success)} #{title}#{detail}#{time}"
|
|
141
|
+
when :failed then "#{@shell.symbol(:error)} #{title}#{detail}#{time}"
|
|
142
|
+
when :skipped
|
|
143
|
+
"#{@shell.symbol(:skipped)} #{@shell.paint("#{title} (#{step.note || "skipped"})", :skipped)}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
module_function
|
|
151
|
+
|
|
152
|
+
# 0.12 → "120ms", 3.456 → "3.5s", 75 → "1m 15s".
|
|
153
|
+
# Rounds before picking the unit, so 59.97 is "1m 0s", not "60.0s".
|
|
154
|
+
def duration(seconds)
|
|
155
|
+
return "#{(seconds * 1000).round}ms" if (seconds * 1000).round < 1000
|
|
156
|
+
|
|
157
|
+
tenths = (seconds * 10).round
|
|
158
|
+
return "#{format("%.1f", tenths / 10.0)}s" if tenths < 600
|
|
159
|
+
|
|
160
|
+
total = seconds.round
|
|
161
|
+
"#{total / 60}m #{total % 60}s"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The list a `tasks` block is declaring on this thread (nil outside one).
|
|
165
|
+
def collecting = Thread.current[:r2ui_cli_tasks]
|
|
166
|
+
|
|
167
|
+
def collecting=(list)
|
|
168
|
+
Thread.current[:r2ui_cli_tasks] = list
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def running? = Thread.current[:r2ui_cli_tasks_running]
|
|
172
|
+
|
|
173
|
+
# Runs the block as the thread's one running list (lists draw one region; they don't nest).
|
|
174
|
+
def running
|
|
175
|
+
raise Error, "steps can't run inside a running step" if running?
|
|
176
|
+
|
|
177
|
+
Thread.current[:r2ui_cli_tasks_running] = true
|
|
178
|
+
begin
|
|
179
|
+
yield
|
|
180
|
+
ensure
|
|
181
|
+
Thread.current[:r2ui_cli_tasks_running] = nil
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
extension :tasks do
|
|
188
|
+
helpers do
|
|
189
|
+
def tasks(&block)
|
|
190
|
+
raise ArgumentError, "tasks needs a block" unless block
|
|
191
|
+
raise Error, "tasks can't be nested" if Ext::Tasks.collecting
|
|
192
|
+
|
|
193
|
+
list = Ext::Tasks::List.new(shell)
|
|
194
|
+
begin
|
|
195
|
+
Ext::Tasks.collecting = list
|
|
196
|
+
block.call
|
|
197
|
+
ensure
|
|
198
|
+
Ext::Tasks.collecting = nil
|
|
199
|
+
end
|
|
200
|
+
Ext::Tasks.running { list.run }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def step(title, &block)
|
|
204
|
+
if (list = Ext::Tasks.collecting)
|
|
205
|
+
list.add(title, block)
|
|
206
|
+
return nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
list = Ext::Tasks::List.new(shell)
|
|
210
|
+
list.add(title, block)
|
|
211
|
+
Ext::Tasks.running { list.run }.first
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c28-trace: a --trace flag that shows where an unexpected error came from.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.cli "deployer" do
|
|
6
|
+
# trace_option
|
|
7
|
+
# command(:deploy) { run { Deploy.call } }
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# $ deployer deploy --trace
|
|
11
|
+
# ✖ undefined method 'call' for nil
|
|
12
|
+
# NoMethodError
|
|
13
|
+
# lib/deploy.rb:12:in 'Deploy.call'
|
|
14
|
+
# ...
|
|
15
|
+
#
|
|
16
|
+
# `trace_option` on the root adds `--trace` to every command. With it, an unexpected error (any
|
|
17
|
+
# exception that isn't a CLI::Error, so not `abort!` or a usage error) prints "✖ message" on
|
|
18
|
+
# stderr, then the exception class and its full backtrace, and each "Caused by Class: message"
|
|
19
|
+
# with its own backtrace, and exits 1. On a terminal the class and backtrace are muted; off it
|
|
20
|
+
# they are the same plain lines, nothing else. Without `--trace` errors are reported as before
|
|
21
|
+
# (R2UI_TRACE=1 still works).
|
|
22
|
+
module R2UI
|
|
23
|
+
module CLI
|
|
24
|
+
module Ext
|
|
25
|
+
module Trace
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def report(shell, error)
|
|
29
|
+
shell.err_puts("#{shell.symbol(:error)} #{error.message}")
|
|
30
|
+
lines = [error.class.name, *frames(error)]
|
|
31
|
+
seen = [error]
|
|
32
|
+
cause = error.cause
|
|
33
|
+
while cause && !seen.include?(cause)
|
|
34
|
+
seen << cause
|
|
35
|
+
lines << "Caused by #{cause.class}: #{cause.message}"
|
|
36
|
+
lines.concat(frames(cause))
|
|
37
|
+
cause = cause.cause
|
|
38
|
+
end
|
|
39
|
+
lines.each { |line| shell.err_puts(shell.paint(line, :muted)) }
|
|
40
|
+
1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def frames(error) = Array(error.backtrace).map { |frame| " #{frame}" }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
extension :trace do
|
|
48
|
+
dsl(:command) do
|
|
49
|
+
def trace_option = declare(:trace_option, true)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
setup do
|
|
53
|
+
flag :trace, desc: "Show the backtrace of unexpected errors" if declared(:trace_option).any?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
on_error do |error|
|
|
57
|
+
next if error.is_a?(Error) || !options&.fetch(:trace, false)
|
|
58
|
+
next unless program.declared(:trace_option).any?
|
|
59
|
+
|
|
60
|
+
Ext::Trace.report(shell, error)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c08-tree: a dependency-style tree (npm ls, cargo tree, tree(1)), drawn by Lipgloss::Tree.
|
|
4
|
+
#
|
|
5
|
+
# tree("app@1.0", { "rails@7.1" => { "rack@3.0" => nil }, "pg@1.5" => nil })
|
|
6
|
+
#
|
|
7
|
+
# app@1.0
|
|
8
|
+
# ├── rails@7.1
|
|
9
|
+
# │ └── rack@3.0
|
|
10
|
+
# └── pg@1.5
|
|
11
|
+
#
|
|
12
|
+
# Children nest as Hashes (label => children; nil for a leaf, a single value for one child) and
|
|
13
|
+
# Arrays (leaves as Strings, Hashes for branches). An Array right after an item in an Array nests
|
|
14
|
+
# under that item, the way Lipgloss::Tree treats a rootless subtree:
|
|
15
|
+
#
|
|
16
|
+
# tree("src", ["main.rb", { "lib" => %w[a.rb b.rb] }, "README.md"])
|
|
17
|
+
# tree("root", ["a", %w[a1 a2], "b"]) # a1 and a2 hang under "a"
|
|
18
|
+
# tree({ "a" => nil, "b" => nil }) # no root line: just the branches
|
|
19
|
+
#
|
|
20
|
+
# Labels are anything with #to_s; multi-line labels keep the rail on their later lines.
|
|
21
|
+
# `depth: n` shows n levels below the root and puts one "…" under each branch it cuts
|
|
22
|
+
# (`depth: 0` is the root and "└── …").
|
|
23
|
+
#
|
|
24
|
+
# On a terminal the root is bold, names are in the accent colour with "@version" muted, and the
|
|
25
|
+
# connectors are muted. Off a terminal (pipe, CI, NO_COLOR) it prints the same lines with the same
|
|
26
|
+
# glyphs and no escape codes, so `tool deps | grep rack` works. Printed through the Shell, so
|
|
27
|
+
# inside a Live region (tasks, spinners) it lands above the region. Returns nil.
|
|
28
|
+
module R2UI
|
|
29
|
+
module CLI
|
|
30
|
+
module Ext
|
|
31
|
+
module DepTree
|
|
32
|
+
ELLIPSIS = "…"
|
|
33
|
+
# "name@version": the last "@" that isn't the first character ("@types/node@20.1").
|
|
34
|
+
VERSIONED = /\A(?<name>.+?)(?<version>@[^@\/\s]+)\z/
|
|
35
|
+
|
|
36
|
+
Node = Struct.new(:label, :children)
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# Children in any accepted shape → [Node].
|
|
41
|
+
def nodes(value)
|
|
42
|
+
case value
|
|
43
|
+
when nil then []
|
|
44
|
+
when Hash then value.map { |label, kids| Node.new(label.to_s, nodes(kids)) }
|
|
45
|
+
when Array then array_nodes(value)
|
|
46
|
+
else [Node.new(value.to_s, [])]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def array_nodes(items)
|
|
51
|
+
items.each_with_object([]) do |item, list|
|
|
52
|
+
if item.is_a?(Array) && !list.empty?
|
|
53
|
+
list.last.children.concat(nodes(item))
|
|
54
|
+
else
|
|
55
|
+
list.concat(nodes(item))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Cuts the branches below `depth` levels, leaving one "…" in their place.
|
|
61
|
+
def truncate(list, depth)
|
|
62
|
+
return list if depth.nil?
|
|
63
|
+
return list.empty? ? [] : [Node.new(ELLIPSIS, [])] if depth.zero?
|
|
64
|
+
|
|
65
|
+
list.map { |node| Node.new(node.label, truncate(node.children, depth - 1)) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def render(shell, root, list)
|
|
69
|
+
tree = Lipgloss::Tree.root(root.nil? ? "" : label(shell, root.to_s, root: true))
|
|
70
|
+
tree = tree.enumerator_style(shell.theme.style(:muted).padding_right(1)) if shell.color?
|
|
71
|
+
list.each { |node| tree = tree.child(build(shell, node)) }
|
|
72
|
+
# Lipgloss pads a multi-line item to a block; drop that padding so piped lines stay clean.
|
|
73
|
+
tree.render.split("\n").map(&:rstrip).join("\n")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build(shell, node)
|
|
77
|
+
text = label(shell, node.label)
|
|
78
|
+
return text if node.children.empty?
|
|
79
|
+
|
|
80
|
+
node.children.reduce(Lipgloss::Tree.root(text)) { |tree, child| tree.child(build(shell, child)) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def label(shell, text, root: false)
|
|
84
|
+
return shell.paint(text, :muted) if text == ELLIPSIS
|
|
85
|
+
|
|
86
|
+
name_style = root ? :heading : :accent
|
|
87
|
+
match = text.include?("\n") ? nil : VERSIONED.match(text)
|
|
88
|
+
return shell.paint(text, name_style) unless match
|
|
89
|
+
|
|
90
|
+
shell.paint(match[:name], name_style) + shell.paint(match[:version], :muted)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
extension :tree do
|
|
96
|
+
helpers do
|
|
97
|
+
def tree(root, children = nil, depth: nil)
|
|
98
|
+
unless depth.nil? || (depth.is_a?(Integer) && !depth.negative?)
|
|
99
|
+
raise ArgumentError, "depth must be a non-negative Integer, got #{depth.inspect}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if children.nil? && (root.is_a?(Hash) || root.is_a?(Array))
|
|
103
|
+
children = root
|
|
104
|
+
root = nil
|
|
105
|
+
end
|
|
106
|
+
list = Ext::DepTree.truncate(Ext::DepTree.nodes(children), depth)
|
|
107
|
+
text = Ext::DepTree.render(shell, root, list)
|
|
108
|
+
shell.puts(text) unless text.empty?
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c23-version: `tool --version`.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.cli "deployer" do
|
|
6
|
+
# version "1.4.0" # or Deployer::VERSION
|
|
7
|
+
# ...
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# $ deployer --version # or -V, or `deployer deploy --version`
|
|
11
|
+
# deployer 1.4.0
|
|
12
|
+
#
|
|
13
|
+
# `version` on the root adds `-V, --version` ("Print the version") to every command. The flag
|
|
14
|
+
# prints "<name> <version>" on stdout and exits 0 before anything else is checked: missing
|
|
15
|
+
# required options or arguments don't matter and no command runs. The root's `--help` starts with
|
|
16
|
+
# the same line.
|
|
17
|
+
#
|
|
18
|
+
# On a colour terminal the name is bold and the version in the accent colour; in a pipe it is the
|
|
19
|
+
# plain line, so `deployer --version | cut -d' ' -f2` is stable.
|
|
20
|
+
#
|
|
21
|
+
# A tool that already has a `-V` keeps it (the flag is then `--version` only); one that defines
|
|
22
|
+
# its own `version` option keeps that too, and only the help line is added.
|
|
23
|
+
module R2UI
|
|
24
|
+
module CLI
|
|
25
|
+
module Ext
|
|
26
|
+
module VersionFlag
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def line(shell, program)
|
|
30
|
+
version = program.declared(:version).last
|
|
31
|
+
"#{shell.paint(program.name, :heading)} #{shell.paint(version.to_s, :accent)}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True when --version is the flag this extension added (not the tool's own option).
|
|
35
|
+
def ours?(option) = option&.meta&.fetch(:r2ui_version_flag, false)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
extension :version do
|
|
40
|
+
dsl(:command) do
|
|
41
|
+
def version(value)
|
|
42
|
+
raise ArgumentError, "#{definition.full_name}: version belongs on the root command" unless definition.root?
|
|
43
|
+
|
|
44
|
+
declare(:version, value)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
setup do
|
|
49
|
+
next if declared(:version).empty? || definition.option(:version)
|
|
50
|
+
|
|
51
|
+
short = definition.options.any? { |o| o.short == "V" } ? nil : "V"
|
|
52
|
+
flag :version, short:, desc: "Print the version", r2ui_version_flag: true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
after_parse do
|
|
56
|
+
next unless options[:version] && Ext::VersionFlag.ours?(command.option(:version))
|
|
57
|
+
|
|
58
|
+
shell.puts(Ext::VersionFlag.line(shell, program))
|
|
59
|
+
halt(0)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
help_header do |command, shell|
|
|
63
|
+
Ext::VersionFlag.line(shell, command) if command.root? && command.declared(:version).any?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|