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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +79 -0
  4. data/lib/r2ui/app.rb +311 -34
  5. data/lib/r2ui/canvas.rb +135 -14
  6. data/lib/r2ui/cli/builder.rb +72 -0
  7. data/lib/r2ui/cli/context.rb +45 -0
  8. data/lib/r2ui/cli/definition.rb +174 -0
  9. data/lib/r2ui/cli/ext/ask.rb +133 -0
  10. data/lib/r2ui/cli/ext/box.rb +112 -0
  11. data/lib/r2ui/cli/ext/choose.rb +162 -0
  12. data/lib/r2ui/cli/ext/choose_many.rb +241 -0
  13. data/lib/r2ui/cli/ext/color_option.rb +64 -0
  14. data/lib/r2ui/cli/ext/completion.rb +520 -0
  15. data/lib/r2ui/cli/ext/config.rb +179 -0
  16. data/lib/r2ui/cli/ext/confirm.rb +110 -0
  17. data/lib/r2ui/cli/ext/dashboard.rb +105 -0
  18. data/lib/r2ui/cli/ext/diff.rb +195 -0
  19. data/lib/r2ui/cli/ext/editor.rb +94 -0
  20. data/lib/r2ui/cli/ext/env.rb +108 -0
  21. data/lib/r2ui/cli/ext/examples.rb +62 -0
  22. data/lib/r2ui/cli/ext/filter.rb +269 -0
  23. data/lib/r2ui/cli/ext/format.rb +104 -0
  24. data/lib/r2ui/cli/ext/list.rb +117 -0
  25. data/lib/r2ui/cli/ext/log.rb +89 -0
  26. data/lib/r2ui/cli/ext/pager.rb +97 -0
  27. data/lib/r2ui/cli/ext/pairs.rb +62 -0
  28. data/lib/r2ui/cli/ext/parallel.rb +274 -0
  29. data/lib/r2ui/cli/ext/password.rb +158 -0
  30. data/lib/r2ui/cli/ext/progress.rb +330 -0
  31. data/lib/r2ui/cli/ext/reference.rb +129 -0
  32. data/lib/r2ui/cli/ext/spinner.rb +165 -0
  33. data/lib/r2ui/cli/ext/summary.rb +63 -0
  34. data/lib/r2ui/cli/ext/table.rb +141 -0
  35. data/lib/r2ui/cli/ext/tasks.rb +216 -0
  36. data/lib/r2ui/cli/ext/trace.rb +64 -0
  37. data/lib/r2ui/cli/ext/tree.rb +114 -0
  38. data/lib/r2ui/cli/ext/version.rb +67 -0
  39. data/lib/r2ui/cli/extension.rb +129 -0
  40. data/lib/r2ui/cli/help.rb +115 -0
  41. data/lib/r2ui/cli/helpers.rb +20 -0
  42. data/lib/r2ui/cli/live.rb +150 -0
  43. data/lib/r2ui/cli/parser.rb +165 -0
  44. data/lib/r2ui/cli/prompt.rb +133 -0
  45. data/lib/r2ui/cli/runner.rb +86 -0
  46. data/lib/r2ui/cli/shell.rb +185 -0
  47. data/lib/r2ui/cli/testing.rb +49 -0
  48. data/lib/r2ui/cli.rb +129 -0
  49. data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
  50. data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
  51. data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
  52. data/lib/r2ui/compat/bubbletea/input.rb +269 -0
  53. data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
  54. data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
  55. data/lib/r2ui/compat/bubbletea/model.rb +65 -0
  56. data/lib/r2ui/compat/bubbletea/native.rb +254 -0
  57. data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
  58. data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
  59. data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
  60. data/lib/r2ui/compat/bubbletea/version.rb +8 -0
  61. data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
  62. data/lib/r2ui/compat/bubbletea.rb +16 -0
  63. data/lib/r2ui/compat/lipgloss/border.rb +17 -0
  64. data/lib/r2ui/compat/lipgloss/color.rb +78 -0
  65. data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
  66. data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
  67. data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
  68. data/lib/r2ui/compat/lipgloss/list.rb +85 -0
  69. data/lib/r2ui/compat/lipgloss/position.rb +30 -0
  70. data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
  71. data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
  72. data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
  73. data/lib/r2ui/compat/lipgloss/table.rb +185 -0
  74. data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
  75. data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
  76. data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
  77. data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
  78. data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
  79. data/lib/r2ui/compat/lipgloss/version.rb +15 -0
  80. data/lib/r2ui/compat/lipgloss.rb +64 -0
  81. data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
  82. data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
  83. data/lib/r2ui/component.rb +67 -0
  84. data/lib/r2ui/context.rb +87 -0
  85. data/lib/r2ui/drop_in.rb +16 -0
  86. data/lib/r2ui/dsl/column.rb +11 -3
  87. data/lib/r2ui/dsl/dashboard.rb +45 -9
  88. data/lib/r2ui/dsl/resource.rb +12 -0
  89. data/lib/r2ui/ext/after.rb +54 -0
  90. data/lib/r2ui/ext/async.rb +81 -0
  91. data/lib/r2ui/ext/batch_sequence.rb +54 -0
  92. data/lib/r2ui/ext/countdown.rb +52 -0
  93. data/lib/r2ui/ext/data_table.rb +71 -0
  94. data/lib/r2ui/ext/every.rb +66 -0
  95. data/lib/r2ui/ext/exec.rb +116 -0
  96. data/lib/r2ui/ext/file_picker.rb +78 -0
  97. data/lib/r2ui/ext/focus_report.rb +69 -0
  98. data/lib/r2ui/ext/help.rb +62 -0
  99. data/lib/r2ui/ext/keys.rb +64 -0
  100. data/lib/r2ui/ext/list.rb +145 -0
  101. data/lib/r2ui/ext/messages.rb +87 -0
  102. data/lib/r2ui/ext/mouse.rb +134 -0
  103. data/lib/r2ui/ext/paginator.rb +86 -0
  104. data/lib/r2ui/ext/paste.rb +86 -0
  105. data/lib/r2ui/ext/println.rb +21 -0
  106. data/lib/r2ui/ext/progress.rb +85 -0
  107. data/lib/r2ui/ext/refresh.rb +56 -0
  108. data/lib/r2ui/ext/resize.rb +62 -0
  109. data/lib/r2ui/ext/screen.rb +47 -0
  110. data/lib/r2ui/ext/screen_mode.rb +63 -0
  111. data/lib/r2ui/ext/spinner.rb +59 -0
  112. data/lib/r2ui/ext/state.rb +45 -0
  113. data/lib/r2ui/ext/stopwatch.rb +67 -0
  114. data/lib/r2ui/ext/suspend.rb +56 -0
  115. data/lib/r2ui/ext/text_area.rb +73 -0
  116. data/lib/r2ui/ext/text_input.rb +83 -0
  117. data/lib/r2ui/ext/theme.rb +112 -0
  118. data/lib/r2ui/ext/view.rb +37 -0
  119. data/lib/r2ui/ext/viewport.rb +92 -0
  120. data/lib/r2ui/ext/window_title.rb +51 -0
  121. data/lib/r2ui/extension.rb +199 -0
  122. data/lib/r2ui/history.rb +4 -1
  123. data/lib/r2ui/keys.rb +35 -8
  124. data/lib/r2ui/motion.rb +169 -0
  125. data/lib/r2ui/panel_state.rb +46 -2
  126. data/lib/r2ui/renderer.rb +87 -22
  127. data/lib/r2ui/version.rb +1 -1
  128. data/lib/r2ui/widgets/box.rb +6 -3
  129. data/lib/r2ui/widgets/glyphs.rb +149 -0
  130. data/lib/r2ui/widgets/table.rb +109 -25
  131. data/lib/r2ui.rb +12 -1
  132. metadata +119 -4
  133. 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