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,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