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,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c06-log: styled log levels, one line per message.
|
|
4
|
+
#
|
|
5
|
+
# info "Using node 20" # ℹ Using node 20 stdout
|
|
6
|
+
# success "Installed 42 packages" # ✔ Installed 42 packages stdout
|
|
7
|
+
# warn "3 deprecated packages" # ⚠ 3 deprecated packages stderr
|
|
8
|
+
# error "Build failed" # ✖ Build failed stderr
|
|
9
|
+
# debug "cache hit for react@18" # • cache hit for react@18 stderr, only when asked
|
|
10
|
+
#
|
|
11
|
+
# On a terminal the symbol takes its level's colour (blue, green, yellow, red) and the message
|
|
12
|
+
# stays plain; a debug line is muted. Off a terminal (a pipe, CI, NO_COLOR) the same lines print
|
|
13
|
+
# with no escape codes. A multi-line message indents its other lines under the first:
|
|
14
|
+
#
|
|
15
|
+
# ⚠ Peer dependency missing
|
|
16
|
+
# react@18 is required by app
|
|
17
|
+
#
|
|
18
|
+
# `info` and `success` write to stdout; `warn`, `error` and `debug` to stderr, so `tool | jq`
|
|
19
|
+
# stays clean. `debug` prints only when the command's `options[:verbose]` is true (a `flag
|
|
20
|
+
# :verbose` on the root gives every command `-v`) or the `R2UI_DEBUG` variable is set (and not
|
|
21
|
+
# "0"); otherwise it does nothing.
|
|
22
|
+
#
|
|
23
|
+
# `warn` replaces Kernel#warn inside commands and in scripts that `include R2UI::CLI::Helpers`
|
|
24
|
+
# (several messages become one entry, a line each; Kernel's `uplevel:` and `category:` are
|
|
25
|
+
# accepted and ignored). Inside a Live region (tasks, spinners, progress) the lines print above
|
|
26
|
+
# it. Every helper returns nil.
|
|
27
|
+
module R2UI
|
|
28
|
+
module CLI
|
|
29
|
+
module Ext
|
|
30
|
+
module Log
|
|
31
|
+
# Level => [theme symbol, theme style for the symbol, stream].
|
|
32
|
+
LEVELS = {
|
|
33
|
+
info: [:info, :info, :out],
|
|
34
|
+
success: [:success, :success, :out],
|
|
35
|
+
warn: [:warn, :warn, :err],
|
|
36
|
+
error: [:error, :error, :err],
|
|
37
|
+
debug: [:bullet, :muted, :err]
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
def write(shell, level, message)
|
|
43
|
+
symbol_name, style, stream = LEVELS.fetch(level)
|
|
44
|
+
lines = message.to_s.chomp.split("\n", -1)
|
|
45
|
+
lines = [""] if lines.empty?
|
|
46
|
+
symbol = shell.paint(shell.theme.symbol(symbol_name), style)
|
|
47
|
+
first, *rest = lines
|
|
48
|
+
rest = rest.map { |line| line.empty? ? line : " #{line}" }
|
|
49
|
+
rest = rest.map { |line| shell.paint(line, :muted) } if level == :debug
|
|
50
|
+
first = shell.paint(first, :muted) if level == :debug
|
|
51
|
+
text = ["#{symbol} #{first}", *rest].join("\n")
|
|
52
|
+
stream == :out ? shell.puts(text) : shell.err_puts(text)
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def debug?(context)
|
|
57
|
+
value = context.shell.env["R2UI_DEBUG"]
|
|
58
|
+
return true if value && !value.empty? && value != "0"
|
|
59
|
+
|
|
60
|
+
context.is_a?(Context) && context.options[:verbose] == true
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
extension :log do
|
|
66
|
+
helpers do
|
|
67
|
+
def info(message) = Ext::Log.write(shell, :info, message)
|
|
68
|
+
|
|
69
|
+
def success(message) = Ext::Log.write(shell, :success, message)
|
|
70
|
+
|
|
71
|
+
# Kernel#warn's signature, so it can stand in for it.
|
|
72
|
+
def warn(*messages, uplevel: nil, category: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
73
|
+
messages = messages.flatten
|
|
74
|
+
return nil if messages.empty?
|
|
75
|
+
|
|
76
|
+
Ext::Log.write(shell, :warn, messages.map { |m| m.to_s.chomp }.join("\n"))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def error(message) = Ext::Log.write(shell, :error, message)
|
|
80
|
+
|
|
81
|
+
def debug(message)
|
|
82
|
+
return nil unless Ext::Log.debug?(self)
|
|
83
|
+
|
|
84
|
+
Ext::Log.write(shell, :debug, message)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
# c12-pager: show long output in a pager, the way git log and gh do.
|
|
6
|
+
#
|
|
7
|
+
# pager(changelog) # => nil
|
|
8
|
+
# pager(rows.map { |r| format(r) }.join("\n"))
|
|
9
|
+
#
|
|
10
|
+
# On a terminal (Shell#interactive?), when the text is taller than the screen (wrapped lines count
|
|
11
|
+
# by their rows at the shell width), it is piped through `$PAGER`, or `less -R -F -X` when that is
|
|
12
|
+
# unset: -R keeps colours, -F quits at once if it fits after all, -X leaves the text in the
|
|
13
|
+
# scrollback. `pager` waits for the pager to exit. ctrl+c belongs to the pager while it runs (the
|
|
14
|
+
# tool ignores SIGINT until it exits, like git), and the previous SIGINT handler is put back on
|
|
15
|
+
# every path; nothing else about the terminal is touched, so there is nothing else to restore.
|
|
16
|
+
# Quitting the pager before the end of the text is fine.
|
|
17
|
+
#
|
|
18
|
+
# Otherwise it prints the text through the shell, unchanged, with a final newline: in a pipe or CI
|
|
19
|
+
# (`tool log | grep fix`), with `TERM=dumb`, when the text fits on the screen, when `$PAGER` is
|
|
20
|
+
# empty or `cat`, inside a live region (it prints above it), and when the pager program can't be
|
|
21
|
+
# started (not installed: it falls back to printing, no error).
|
|
22
|
+
#
|
|
23
|
+
# `$PAGER` is split like a shell would split it (`PAGER="less -S"`, quotes allowed) and run
|
|
24
|
+
# without a shell.
|
|
25
|
+
module R2UI
|
|
26
|
+
module CLI
|
|
27
|
+
module Ext
|
|
28
|
+
module Pager
|
|
29
|
+
DEFAULT = "less -R -F -X"
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def show(shell, text)
|
|
34
|
+
text = text.to_s
|
|
35
|
+
return if text.empty?
|
|
36
|
+
|
|
37
|
+
text = "#{text}\n" unless text.end_with?("\n")
|
|
38
|
+
return if page?(shell, text) && run(shell, command(shell), text)
|
|
39
|
+
|
|
40
|
+
shell.puts(text.chomp) # above a live region while one draws
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def page?(shell, text) = !shell.live && shell.interactive? && rows(text, shell.width) > shell.height
|
|
44
|
+
|
|
45
|
+
# $PAGER split into words; nil when it means "don't page" (empty, `cat`, unbalanced quotes).
|
|
46
|
+
def command(shell)
|
|
47
|
+
words = Shellwords.split(shell.env["PAGER"] || DEFAULT)
|
|
48
|
+
words unless words.empty? || words == ["cat"]
|
|
49
|
+
rescue ArgumentError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Screen rows the text takes at `width` columns.
|
|
54
|
+
def rows(text, width)
|
|
55
|
+
text.split("\n").sum { |line| [(Lipgloss.width(line) + width - 1) / width, 1].max }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Runs the pager with the text on its stdin and waits for it. False when it couldn't start.
|
|
59
|
+
def run(shell, command, text)
|
|
60
|
+
return false unless command
|
|
61
|
+
|
|
62
|
+
reader, writer = IO.pipe
|
|
63
|
+
begin
|
|
64
|
+
pid = Process.spawn(shell.env.to_h, [command.first, command.first], *command.drop(1),
|
|
65
|
+
in: reader, out: shell.output, err: shell.error)
|
|
66
|
+
rescue SystemCallError, TypeError, ArgumentError # not installed, or IOs without a descriptor
|
|
67
|
+
return false
|
|
68
|
+
ensure
|
|
69
|
+
reader.close
|
|
70
|
+
end
|
|
71
|
+
previous = trap("INT", "IGNORE")
|
|
72
|
+
begin
|
|
73
|
+
writer.write(text)
|
|
74
|
+
rescue Errno::EPIPE
|
|
75
|
+
nil # quit before the end
|
|
76
|
+
ensure
|
|
77
|
+
writer.close
|
|
78
|
+
Process.wait(pid)
|
|
79
|
+
trap("INT", previous)
|
|
80
|
+
end
|
|
81
|
+
true
|
|
82
|
+
ensure
|
|
83
|
+
writer.close unless writer.nil? || writer.closed?
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
extension :pager do
|
|
89
|
+
helpers do
|
|
90
|
+
def pager(text)
|
|
91
|
+
Ext::Pager.show(shell, text)
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c09-pairs: an aligned key/value summary, like the block `npm publish` or `gh repo view` prints.
|
|
4
|
+
#
|
|
5
|
+
# pairs({ "Version" => "1.4.0", "Env" => "production", "Region" => nil }, title: "Deploy")
|
|
6
|
+
#
|
|
7
|
+
# prints
|
|
8
|
+
#
|
|
9
|
+
# Deploy
|
|
10
|
+
# Version 1.4.0
|
|
11
|
+
# Env production
|
|
12
|
+
# Region —
|
|
13
|
+
#
|
|
14
|
+
# Keys are right-padded to the longest key (by display width, so wide characters line up) and
|
|
15
|
+
# followed by two spaces. With a `title:` the title is its own line and the pairs are indented
|
|
16
|
+
# under it by two spaces; without one they start at the left edge. A nil value shows "—"; other
|
|
17
|
+
# keys and values are shown with `to_s`. A multi-line value continues under the value column. An
|
|
18
|
+
# empty hash prints only the title (or nothing). Pairs may also be given as an Array of
|
|
19
|
+
# [key, value] (order and repeated keys kept). Returns nil.
|
|
20
|
+
#
|
|
21
|
+
# On a terminal (Shell#color?) the title is bold, keys and the "—" are muted, values are plain.
|
|
22
|
+
# Off a terminal, or with NO_COLOR, it prints the same text and alignment with no escape codes.
|
|
23
|
+
# Inside a Live region (a `tasks` step) the lines print above it, as `say` does.
|
|
24
|
+
module R2UI
|
|
25
|
+
module CLI
|
|
26
|
+
module Ext
|
|
27
|
+
module Pairs
|
|
28
|
+
GAP = " "
|
|
29
|
+
INDENT = " "
|
|
30
|
+
NONE = "—"
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def lines(shell, entries, title)
|
|
35
|
+
entries = entries.to_a.map { |key, value| [key.to_s, value] }
|
|
36
|
+
out = []
|
|
37
|
+
out << shell.paint(title.to_s, :heading) unless title.nil?
|
|
38
|
+
indent = title.nil? ? "" : INDENT
|
|
39
|
+
width = entries.map { |key, _| Lipgloss.width(key) }.max || 0
|
|
40
|
+
entries.each do |key, value|
|
|
41
|
+
pad = " " * (width - Lipgloss.width(key))
|
|
42
|
+
first, *rest = value.nil? ? [shell.paint(NONE, :muted)] : value.to_s.split("\n", -1)
|
|
43
|
+
label = "#{indent}#{shell.paint(key, :muted)}"
|
|
44
|
+
out << (first.to_s.empty? ? label : "#{label}#{pad}#{GAP}#{first}")
|
|
45
|
+
continuation = indent + (" " * (width + GAP.length))
|
|
46
|
+
rest.each { |line| out << (line.empty? ? "" : "#{continuation}#{line}") }
|
|
47
|
+
end
|
|
48
|
+
out
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
extension :pairs do
|
|
54
|
+
helpers do
|
|
55
|
+
def pairs(entries, title: nil)
|
|
56
|
+
Ext::Pairs.lines(shell, entries, title).each { |line| shell.puts(line) }
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c13-parallel: concurrent jobs with live status, like `pnpm -r` or turbo. Each job has one status
|
|
4
|
+
# line: queued, spinning while it runs, then ✔ / ✖ with its timing.
|
|
5
|
+
#
|
|
6
|
+
# results = parallel(max: 4) do
|
|
7
|
+
# job("api") { build_api } # the block's value is kept
|
|
8
|
+
# job("web") { |j| bundle { |n| j.detail = "#{n} modules" } }
|
|
9
|
+
# job("docs") { build_docs }
|
|
10
|
+
# end # => { "api" => ..., "web" => ..., "docs" => ... }
|
|
11
|
+
#
|
|
12
|
+
# On a terminal (Shell#live?) every job's line is redrawn together:
|
|
13
|
+
#
|
|
14
|
+
# ✔ api 1.2s
|
|
15
|
+
# ⠹ web · 412 modules
|
|
16
|
+
# ○ docs
|
|
17
|
+
#
|
|
18
|
+
# and the final lines stay in the scrollback. When there are more jobs than the terminal has rows,
|
|
19
|
+
# finished jobs print above the region as they end and the region shows only the running jobs and
|
|
20
|
+
# how many are queued. Off a terminal each job prints one line when it ends, in the order they end
|
|
21
|
+
# ("✔ api 1.2s", "✖ web · Module not found 0.4s"): no spinner frames, no escape codes.
|
|
22
|
+
#
|
|
23
|
+
# The `parallel` block declares the jobs; they run after it on up to `max` threads (default 4,
|
|
24
|
+
# pnpm's), started in declaration order. A job's block runs on its own thread but sees the same
|
|
25
|
+
# `args`, `options` and helpers; `say` inside a job prints above the lines. `j.detail = "..."` and
|
|
26
|
+
# `j.title = "..."` change its line while it runs.
|
|
27
|
+
#
|
|
28
|
+
# A job that raises shows ✖ with the error's message; the other jobs carry on. When every job has
|
|
29
|
+
# ended, `parallel` raises a `CLI::Error` naming the failed jobs, with the first error to happen as
|
|
30
|
+
# its `cause` (a command exits 1 with "✖ 2 jobs failed: web, docs"). Ctrl+c, `halt` or `exit`
|
|
31
|
+
# inside a job stops the rest at once: running and queued jobs show "– name (cancelled)" and the
|
|
32
|
+
# interrupt propagates (exit 130 for ctrl+c). Otherwise it returns the jobs' values as a Hash by
|
|
33
|
+
# job name, in declaration order.
|
|
34
|
+
#
|
|
35
|
+
# Jobs don't nest: `parallel` inside a job raises. Don't start `tasks`, `spin` or another live
|
|
36
|
+
# helper inside a job either; there is one live region at a time.
|
|
37
|
+
require "monitor"
|
|
38
|
+
|
|
39
|
+
module R2UI
|
|
40
|
+
module CLI
|
|
41
|
+
module Ext
|
|
42
|
+
module Parallel
|
|
43
|
+
DEFAULT_MAX = 4
|
|
44
|
+
|
|
45
|
+
# Raised when jobs failed; `failures` maps each failed job's name to its error.
|
|
46
|
+
class Failed < Error
|
|
47
|
+
attr_reader :failures
|
|
48
|
+
|
|
49
|
+
def initialize(failures)
|
|
50
|
+
@failures = failures
|
|
51
|
+
names = failures.keys.map(&:to_s)
|
|
52
|
+
super(names.one? ? "job #{names.first} failed: #{failures.values.first.message}" : "#{names.size} jobs failed: #{names.join(", ")}")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Job = Struct.new(:name, :title, :block, :status, :detail, :started, :finished, :value, :error) do
|
|
57
|
+
def elapsed = finished && started ? finished - started : nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# What a job's block gets: change its title or detail while it runs.
|
|
61
|
+
class Handle
|
|
62
|
+
def initialize(job, pool)
|
|
63
|
+
@job = job
|
|
64
|
+
@pool = pool
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def name = @job.name
|
|
68
|
+
|
|
69
|
+
def title = @job.title
|
|
70
|
+
|
|
71
|
+
def title=(text)
|
|
72
|
+
@pool.change { @job.title = text.to_s }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def detail=(text)
|
|
76
|
+
@pool.change { @job.detail = text&.to_s }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The jobs a `parallel` block declared, and how they run and draw.
|
|
81
|
+
class Pool
|
|
82
|
+
def initialize(shell, max)
|
|
83
|
+
@shell = shell
|
|
84
|
+
@max = max
|
|
85
|
+
@jobs = []
|
|
86
|
+
@lock = Monitor.new
|
|
87
|
+
@live = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def add(name, block)
|
|
91
|
+
raise ArgumentError, "job needs a block" unless block
|
|
92
|
+
raise ArgumentError, "job #{name} is declared twice" if @jobs.any? { |j| j.name.to_s == name.to_s }
|
|
93
|
+
|
|
94
|
+
@jobs << Job.new(name:, title: name.to_s, block:, status: :queued)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Runs every job; returns { name => value }. Raises Failed after all end if any failed.
|
|
98
|
+
def run
|
|
99
|
+
return {} if @jobs.empty?
|
|
100
|
+
|
|
101
|
+
@live = Live.new(@shell) { |frame| view(frame) }
|
|
102
|
+
@compact = @live.live? && @jobs.size > @shell.height - 1
|
|
103
|
+
@live.clear = @compact # every result is already printed above the region
|
|
104
|
+
@live.run { run_jobs }
|
|
105
|
+
failed = @jobs.select { |j| j.status == :failed }
|
|
106
|
+
unless failed.empty?
|
|
107
|
+
first = failed.min_by(&:finished).error
|
|
108
|
+
raise Failed.new(failed.to_h { |j| [j.name, j.error] }), cause: first
|
|
109
|
+
end
|
|
110
|
+
@jobs.to_h { |j| [j.name, j.value] }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Updates a job's state, then redraws (never while holding the lock: the Live ticker
|
|
114
|
+
# takes its own lock and then calls `view`).
|
|
115
|
+
def change(&)
|
|
116
|
+
@lock.synchronize(&)
|
|
117
|
+
@live&.refresh
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def view(frame)
|
|
122
|
+
@lock.synchronize do
|
|
123
|
+
return compact_view(frame) if @compact
|
|
124
|
+
|
|
125
|
+
@jobs.map { |job| line(job, frame) }.join("\n")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def run_jobs
|
|
132
|
+
queue = Queue.new
|
|
133
|
+
@jobs.each { |job| queue << job }
|
|
134
|
+
queue.close
|
|
135
|
+
events = Queue.new
|
|
136
|
+
workers = Array.new([@max, @jobs.size].min) do
|
|
137
|
+
Thread.new do
|
|
138
|
+
Thread.current.report_on_exception = false
|
|
139
|
+
Thread.current[:r2ui_cli_parallel_job] = true
|
|
140
|
+
while (job = queue.pop)
|
|
141
|
+
fatal = run_job(job)
|
|
142
|
+
events << (fatal ? [:fatal, fatal] : [:done, job])
|
|
143
|
+
break if fatal
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
ended = 0
|
|
148
|
+
while ended < @jobs.size
|
|
149
|
+
kind, value = events.pop
|
|
150
|
+
raise value if kind == :fatal
|
|
151
|
+
|
|
152
|
+
ended += 1
|
|
153
|
+
end
|
|
154
|
+
ensure
|
|
155
|
+
stop(workers) if workers
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Runs one job; returns an exception that must stop every job (ctrl+c, halt, exit).
|
|
159
|
+
def run_job(job)
|
|
160
|
+
change do
|
|
161
|
+
job.status = :running
|
|
162
|
+
job.started = now
|
|
163
|
+
end
|
|
164
|
+
value = job.block.call(Handle.new(job, self))
|
|
165
|
+
finish(job, :done) { job.value = value }
|
|
166
|
+
nil
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
finish(job, :failed) { job.error = e }
|
|
169
|
+
nil
|
|
170
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- ctrl+c, halt and exit stop the pool
|
|
171
|
+
finish(job, e.is_a?(Halt) ? :done : :failed) { job.error = e }
|
|
172
|
+
e
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Kills jobs still running (after ctrl+c or a fatal job) and marks the unfinished ones
|
|
176
|
+
# cancelled.
|
|
177
|
+
def stop(workers)
|
|
178
|
+
workers.each(&:kill)
|
|
179
|
+
workers.each { |w| w.join(5) }
|
|
180
|
+
@jobs.each { |job| finish(job, :cancelled) if %i[queued running].include?(job.status) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def finish(job, status)
|
|
184
|
+
text = nil
|
|
185
|
+
change do
|
|
186
|
+
yield if block_given?
|
|
187
|
+
job.status = status
|
|
188
|
+
job.finished = now if job.started
|
|
189
|
+
text = line(job, 0) if !@live.running? || @compact
|
|
190
|
+
end
|
|
191
|
+
@shell.puts(text) if text
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def compact_view(frame)
|
|
195
|
+
running = @jobs.select { |j| j.status == :running }
|
|
196
|
+
queued = @jobs.count { |j| j.status == :queued }
|
|
197
|
+
lines = running.first([@shell.height - 2, 1].max).map { |job| line(job, frame) }
|
|
198
|
+
more = running.size - lines.size
|
|
199
|
+
notes = []
|
|
200
|
+
notes << "#{more} more running" if more.positive?
|
|
201
|
+
notes << "#{queued} queued" if queued.positive?
|
|
202
|
+
lines << "#{@shell.symbol(:pending)} #{@shell.paint(notes.join(" · "), :pending)}" unless notes.empty?
|
|
203
|
+
lines.join("\n")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def line(job, frame)
|
|
207
|
+
title = job.title
|
|
208
|
+
detail = job.error.is_a?(StandardError) ? job.error.message.lines.first.to_s.chomp : job.detail
|
|
209
|
+
detail = detail && !detail.empty? ? @shell.paint(" · #{detail}", :muted) : ""
|
|
210
|
+
time = job.elapsed ? " #{@shell.paint(Parallel.duration(job.elapsed), :muted)}" : ""
|
|
211
|
+
case job.status
|
|
212
|
+
when :queued then "#{@shell.symbol(:pending)} #{@shell.paint(title, :pending)}"
|
|
213
|
+
when :running then "#{@shell.paint(Live.spinner(frame), :running)} #{title}#{detail}"
|
|
214
|
+
when :done then "#{@shell.symbol(:success)} #{title}#{detail}#{time}"
|
|
215
|
+
when :failed then "#{@shell.symbol(:error)} #{title}#{detail}#{time}"
|
|
216
|
+
when :cancelled then "#{@shell.symbol(:skipped)} #{@shell.paint("#{title} (cancelled)", :skipped)}"
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
module_function
|
|
224
|
+
|
|
225
|
+
# 0.12 → "120ms", 3.456 → "3.5s", 75 → "1m 15s" (the task list's format).
|
|
226
|
+
def duration(seconds)
|
|
227
|
+
return "#{(seconds * 1000).round}ms" if (seconds * 1000).round < 1000
|
|
228
|
+
|
|
229
|
+
tenths = (seconds * 10).round
|
|
230
|
+
return "#{format("%.1f", tenths / 10.0)}s" if tenths < 600
|
|
231
|
+
|
|
232
|
+
total = seconds.round
|
|
233
|
+
"#{total / 60}m #{total % 60}s"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The pool a `parallel` block is declaring on this thread (nil outside one).
|
|
237
|
+
def collecting = Thread.current[:r2ui_cli_parallel]
|
|
238
|
+
|
|
239
|
+
def collecting=(pool)
|
|
240
|
+
Thread.current[:r2ui_cli_parallel] = pool
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def in_job? = Thread.current[:r2ui_cli_parallel_job]
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
extension :parallel do
|
|
248
|
+
helpers do
|
|
249
|
+
def parallel(max: Ext::Parallel::DEFAULT_MAX, &block)
|
|
250
|
+
raise ArgumentError, "parallel needs a block" unless block
|
|
251
|
+
raise ArgumentError, "max must be a positive Integer, not #{max.inspect}" unless max.is_a?(Integer) && max.positive?
|
|
252
|
+
raise Error, "parallel can't be nested" if Ext::Parallel.collecting || Ext::Parallel.in_job?
|
|
253
|
+
|
|
254
|
+
pool = Ext::Parallel::Pool.new(shell, max)
|
|
255
|
+
begin
|
|
256
|
+
Ext::Parallel.collecting = pool
|
|
257
|
+
block.call
|
|
258
|
+
ensure
|
|
259
|
+
Ext::Parallel.collecting = nil
|
|
260
|
+
end
|
|
261
|
+
pool.run
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def job(name, &block)
|
|
265
|
+
pool = Ext::Parallel.collecting
|
|
266
|
+
raise Error, "job #{name} must be declared inside a parallel block" unless pool
|
|
267
|
+
|
|
268
|
+
pool.add(name, block)
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
# c18-password: hidden input for tokens and passwords.
|
|
6
|
+
#
|
|
7
|
+
# token = password("Token?") # => "s3cret"
|
|
8
|
+
# pass = password("New password?", confirm: true) # asks twice, again on a mismatch
|
|
9
|
+
#
|
|
10
|
+
# On a terminal (Shell#interactive?) it asks inline with a bubbles TextInput in password echo
|
|
11
|
+
# mode, one • per typed character (backspace, ctrl+u, home/end edit as in bubbles):
|
|
12
|
+
#
|
|
13
|
+
# ? Token? ••••••█
|
|
14
|
+
#
|
|
15
|
+
# enter submits and leaves "✔ Token? · ••••••" in the scrollback: always six bullets, so the
|
|
16
|
+
# line gives away neither the value nor its length. With `confirm: true` it then asks
|
|
17
|
+
# "? Token? (again)"; when the two differ it starts over with "They didn't match. Try again."
|
|
18
|
+
# under the input. ctrl+c or esc raises Interrupt (a command exits 130) after the terminal is
|
|
19
|
+
# restored.
|
|
20
|
+
#
|
|
21
|
+
# Off a terminal it reads one line (spaces kept, only the line ending removed):
|
|
22
|
+
# - stdin is a terminal, stdout piped (`tool | tee log`): asks "Token? " on stderr and reads with
|
|
23
|
+
# echo off (IO#noecho), so the value never appears on the screen; `confirm: true` asks again
|
|
24
|
+
# ("Token? (again) ") and starts over on a mismatch.
|
|
25
|
+
# - from a pipe (`echo "$TOKEN" | tool`, CI): reads silently and records "✔ Token? · ••••••" on
|
|
26
|
+
# stderr; with `confirm: true` it reads a second line and a mismatch is an error.
|
|
27
|
+
# End of input is an error naming the question. The value itself is never printed or logged.
|
|
28
|
+
module R2UI
|
|
29
|
+
module CLI
|
|
30
|
+
module Ext
|
|
31
|
+
module Password
|
|
32
|
+
MASK = "••••••"
|
|
33
|
+
BULLET = "•"
|
|
34
|
+
AGAIN = "(again)"
|
|
35
|
+
MISMATCH = "They didn't match. Try again."
|
|
36
|
+
|
|
37
|
+
# The inline prompt: a TextInput in password echo mode on R2UI::CLI::Prompt::Model.
|
|
38
|
+
class Model < Prompt::Model
|
|
39
|
+
attr_reader :question
|
|
40
|
+
|
|
41
|
+
def initialize(shell, question, confirm)
|
|
42
|
+
super(shell)
|
|
43
|
+
@question = question
|
|
44
|
+
@confirm = confirm
|
|
45
|
+
@first = nil
|
|
46
|
+
@mismatch = false
|
|
47
|
+
self.component = Password.input
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def init = [self, component.focus]
|
|
51
|
+
|
|
52
|
+
def view
|
|
53
|
+
return "#{shell.symbol(:error)} #{question}" if interrupted?
|
|
54
|
+
return Password.answered(shell, question) if done?
|
|
55
|
+
|
|
56
|
+
label = @first.nil? ? question : "#{question} #{shell.paint(AGAIN, :muted)}"
|
|
57
|
+
line = "#{shell.paint("?", :accent)} #{label} #{component.view}"
|
|
58
|
+
@mismatch ? "#{line}\n#{shell.paint(" #{MISMATCH}", :error)}" : line
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def key(name, _message)
|
|
64
|
+
return unless name == "enter"
|
|
65
|
+
|
|
66
|
+
entered = component.value
|
|
67
|
+
return submit(entered) unless @confirm
|
|
68
|
+
return ask_again(entered) if @first.nil?
|
|
69
|
+
return submit(entered) if entered == @first
|
|
70
|
+
|
|
71
|
+
start_over
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def ask_again(entered)
|
|
75
|
+
@first = entered
|
|
76
|
+
@mismatch = false
|
|
77
|
+
fresh_input
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def start_over
|
|
81
|
+
@first = nil
|
|
82
|
+
@mismatch = true
|
|
83
|
+
fresh_input
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def fresh_input
|
|
87
|
+
self.component = Password.input
|
|
88
|
+
[self, component.focus]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
module_function
|
|
93
|
+
|
|
94
|
+
def input
|
|
95
|
+
field = Bubbles::TextInput.new
|
|
96
|
+
field.prompt = ""
|
|
97
|
+
field.echo_mode = Bubbles::TextInput::ECHO_PASSWORD
|
|
98
|
+
field.echo_character = BULLET
|
|
99
|
+
field
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def answered(shell, question)
|
|
103
|
+
"#{shell.symbol(:success)} #{question} #{shell.paint("·", :muted)} #{shell.paint(MASK, :accent)}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def ask_line(shell, question, confirm)
|
|
107
|
+
loop do
|
|
108
|
+
value = read_secret(shell, "#{question} ", question)
|
|
109
|
+
break value unless confirm
|
|
110
|
+
|
|
111
|
+
again = read_secret(shell, "#{question} #{AGAIN} ", question)
|
|
112
|
+
break value if again == value
|
|
113
|
+
raise Error, "the two answers to #{question.inspect} didn't match" unless shell.input_tty?
|
|
114
|
+
|
|
115
|
+
shell.err_puts(MISMATCH)
|
|
116
|
+
end.tap do
|
|
117
|
+
shell.err_puts("#{shell.symbol(:success)} #{question} · #{MASK}") unless shell.input_tty?
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# One line with echo off when a person types it; silently from a pipe.
|
|
122
|
+
def read_secret(shell, prompt, question)
|
|
123
|
+
line =
|
|
124
|
+
if shell.input_tty?
|
|
125
|
+
shell.error.write(prompt)
|
|
126
|
+
shell.error.flush if shell.error.respond_to?(:flush)
|
|
127
|
+
hidden_gets(shell)
|
|
128
|
+
else
|
|
129
|
+
shell.input.gets
|
|
130
|
+
end
|
|
131
|
+
raise Error, "no answer for #{question.inspect}: input ended" if line.nil?
|
|
132
|
+
|
|
133
|
+
line.chomp
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The typed newline isn't echoed with echo off, so end the prompt's line ourselves.
|
|
137
|
+
def hidden_gets(shell)
|
|
138
|
+
return shell.input.gets unless shell.input.respond_to?(:noecho)
|
|
139
|
+
|
|
140
|
+
line = shell.input.noecho(&:gets)
|
|
141
|
+
shell.error.write("\n")
|
|
142
|
+
line
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
extension :password do
|
|
148
|
+
helpers do
|
|
149
|
+
def password(question, confirm: false)
|
|
150
|
+
return Ext::Password.ask_line(shell, question, confirm) unless shell.interactive?
|
|
151
|
+
|
|
152
|
+
CLI.require_bubbles!
|
|
153
|
+
Prompt.run(shell, Ext::Password::Model.new(shell, question, confirm)).value
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|