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,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c27-config: options from a config file.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.cli "deployer" do
|
|
6
|
+
# config_file "~/.deployer.yml" # YAML, or JSON when the name ends in .json
|
|
7
|
+
# option :region, default: "eu"
|
|
8
|
+
# command :deploy do
|
|
9
|
+
# option :replicas, :integer, default: 2
|
|
10
|
+
# ...
|
|
11
|
+
# end
|
|
12
|
+
# command(:db) { command(:migrate) { option :steps, :integer; ... } }
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# # ~/.deployer.yml
|
|
16
|
+
# region: us # top-level keys: options of every command that has them
|
|
17
|
+
# deploy: # a section per command: only that command's options
|
|
18
|
+
# replicas: 4
|
|
19
|
+
# dry-run: true # dashes or underscores, like on the command line
|
|
20
|
+
# db:
|
|
21
|
+
# migrate: # sections nest by command path
|
|
22
|
+
# steps: 3
|
|
23
|
+
#
|
|
24
|
+
# `config_file` (on the root) adds `--config <path>` to every command, so one run can read
|
|
25
|
+
# another file. Before defaults and required checks, every option the command line didn't give
|
|
26
|
+
# is filled from the file: the nearest section wins (`deploy:` over the top level), and a value
|
|
27
|
+
# satisfies `required: true`. Values are converted and checked like typed input (`replicas: "4"`
|
|
28
|
+
# and `replicas: 4` are both 4; `in:` choices apply); `many:` options take a list or one value.
|
|
29
|
+
#
|
|
30
|
+
# What wins: the command line, then the option's environment variable (`env:`, c26), then the
|
|
31
|
+
# file, then the option's `default:`.
|
|
32
|
+
#
|
|
33
|
+
# A missing default file is fine (nothing is read). A missing `--config` file, a file that isn't
|
|
34
|
+
# YAML/JSON or isn't a mapping, a key that names no option or command anywhere in its section's
|
|
35
|
+
# subtree (so a typo fails whatever runs, with "Did you mean"), or a bad value is a usage error:
|
|
36
|
+
#
|
|
37
|
+
# ✖ unknown key 'replicaz' in ~/.deployer.yml (deploy). Did you mean 'replicas'?
|
|
38
|
+
# Run 'deployer deploy --help' for usage.
|
|
39
|
+
#
|
|
40
|
+
# The file is only read when a command runs, so `--help` works with a broken one. Output is the
|
|
41
|
+
# core's error report: styled on a terminal, plain lines off it.
|
|
42
|
+
module R2UI
|
|
43
|
+
module CLI
|
|
44
|
+
module Ext
|
|
45
|
+
module ConfigFile
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# [path to read, how to name it in messages], or nil when there's nothing to read.
|
|
49
|
+
def source(context)
|
|
50
|
+
declared = context.program.declared(:config_file).last
|
|
51
|
+
if context.given?(:config)
|
|
52
|
+
path = File.expand_path(context.options[:config].to_s)
|
|
53
|
+
raise UsageError, "config file #{path} not found" unless File.file?(path)
|
|
54
|
+
|
|
55
|
+
[path, path]
|
|
56
|
+
else
|
|
57
|
+
path = File.expand_path(declared)
|
|
58
|
+
[path, declared] if File.file?(path)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The file's contents as a Hash ({} when empty). YAML and JSON load only when a file is read.
|
|
63
|
+
def read(path, label)
|
|
64
|
+
require "json"
|
|
65
|
+
require "yaml"
|
|
66
|
+
json = File.extname(path).casecmp?(".json")
|
|
67
|
+
text = File.read(path)
|
|
68
|
+
data = if text.strip.empty? then {}
|
|
69
|
+
elsif json then JSON.parse(text)
|
|
70
|
+
else YAML.safe_load(text, aliases: true)
|
|
71
|
+
end
|
|
72
|
+
raise UsageError, "#{label}: expected a mapping of option names to values" unless data.nil? || data.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
data || {}
|
|
75
|
+
rescue SystemCallError => e
|
|
76
|
+
raise UsageError, "can't read config file #{label}: #{e.message}"
|
|
77
|
+
rescue JSON::ParserError, Psych::Exception => e
|
|
78
|
+
raise UsageError, "#{label}: not valid #{json ? "JSON" : "YAML"} (#{e.message.lines.first.to_s.strip})"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def key(name) = name.to_s.tr("-", "_")
|
|
82
|
+
|
|
83
|
+
# Options a section may set: those of its command and of every command under it.
|
|
84
|
+
def settable(command)
|
|
85
|
+
(command.all_options + command.commands.flat_map { |c| settable(c) }).reject { |o| o.name == :config }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Every key in the file names an option in its section's subtree or a subcommand section.
|
|
89
|
+
def validate!(section, command, label)
|
|
90
|
+
names = settable(command).map { |o| o.name.to_s }.uniq
|
|
91
|
+
section.each do |name, value|
|
|
92
|
+
sub = command.find(name.to_s)
|
|
93
|
+
next validate!(value, sub, label) if sub && value.is_a?(Hash)
|
|
94
|
+
next if names.include?(key(name))
|
|
95
|
+
|
|
96
|
+
where = command.root? ? label : "#{label} (#{command.path.drop(1).join(" ")})"
|
|
97
|
+
candidates = names + names.map { |n| n.tr("_", "-") } + command.commands.flat_map { |c| [c.name, *c.aliases] }
|
|
98
|
+
guess = DidYouMean::SpellChecker.new(dictionary: candidates.uniq).correct(name.to_s).first if defined?(DidYouMean)
|
|
99
|
+
raise UsageError, "unknown key '#{name}' in #{where}#{". Did you mean '#{guess}'?" if guess}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# name => value for `command`: the top level, then each section down its path (a section
|
|
104
|
+
# may be keyed by the command's name or an alias), so the nearest wins.
|
|
105
|
+
def values_for(data, command)
|
|
106
|
+
values = {}
|
|
107
|
+
take = lambda do |section, owner|
|
|
108
|
+
section.each { |name, value| values[key(name)] = value unless value.is_a?(Hash) && owner.find(name.to_s) }
|
|
109
|
+
end
|
|
110
|
+
section = data
|
|
111
|
+
current = command.root
|
|
112
|
+
take.call(section, current)
|
|
113
|
+
command.path.drop(1).each do |name|
|
|
114
|
+
parent = current
|
|
115
|
+
current = parent.find(name)
|
|
116
|
+
section = section.find { |k, v| v.is_a?(Hash) && parent.find(k.to_s) == current }&.last
|
|
117
|
+
break unless section
|
|
118
|
+
|
|
119
|
+
take.call(section, current)
|
|
120
|
+
end
|
|
121
|
+
values
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def convert(option, value, label, name)
|
|
125
|
+
option.many ? Array(value).map { |v| scalar(option, v) } : scalar(option, value)
|
|
126
|
+
rescue UsageError => e
|
|
127
|
+
raise UsageError, "#{label}: #{name} #{e.message.delete_prefix("#{option.long} ")}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def scalar(option, value)
|
|
131
|
+
raise UsageError, "expects one value" if value.is_a?(Array) || value.is_a?(Hash)
|
|
132
|
+
|
|
133
|
+
option.convert(value.is_a?(String) ? value : value.to_s)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def env_set?(context, option)
|
|
137
|
+
var = option.meta[:env]
|
|
138
|
+
var && !context.shell.env[var.to_s].to_s.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def fill(context)
|
|
142
|
+
path, label = source(context)
|
|
143
|
+
return unless path
|
|
144
|
+
|
|
145
|
+
data = read(path, label)
|
|
146
|
+
validate!(data, context.program, label)
|
|
147
|
+
values = values_for(data, context.command)
|
|
148
|
+
context.command.all_options.each do |option|
|
|
149
|
+
next if option.name == :config || context.options.key?(option.name) || env_set?(context, option)
|
|
150
|
+
next unless values.key?(option.name.to_s)
|
|
151
|
+
|
|
152
|
+
value = values[option.name.to_s]
|
|
153
|
+
context.options[option.name] = convert(option, value, label, option.name.to_s.tr("_", "-")) unless value.nil?
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
extension :config do
|
|
160
|
+
dsl(:command) do
|
|
161
|
+
# The file whose keys fill options the command line didn't give (on the root only).
|
|
162
|
+
def config_file(path)
|
|
163
|
+
raise ArgumentError, "config_file belongs on the root command" unless definition.root?
|
|
164
|
+
|
|
165
|
+
declare(:config_file, path.to_s)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
setup do
|
|
170
|
+
file = declared(:config_file).last
|
|
171
|
+
if file && !definition.option(:config)
|
|
172
|
+
option :config, :path, placeholder: "path", desc: "Read options from this file (default: #{file})"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
after_parse { Ext::ConfigFile.fill(self) if program.declared(:config_file).any? }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c03-confirm: a yes/no question.
|
|
4
|
+
#
|
|
5
|
+
# confirm("Deploy api to production?") # => true / false
|
|
6
|
+
# confirm("Overwrite config?", default: true)
|
|
7
|
+
# confirm("Deploy?") or abort!("cancelled")
|
|
8
|
+
#
|
|
9
|
+
# On a terminal (Shell#interactive?) it asks inline, like gum confirm:
|
|
10
|
+
#
|
|
11
|
+
# ? Deploy api to production? Yes › No ←/→ (h/l, tab) move, enter picks, y/n answer
|
|
12
|
+
#
|
|
13
|
+
# and leaves "✔ Deploy api to production? · Yes" in the scrollback. ctrl+c or esc raises
|
|
14
|
+
# Interrupt (a command exits 130) after the terminal is restored.
|
|
15
|
+
#
|
|
16
|
+
# Off a terminal it reads one line: "y", "yes", "n", "no" (any case); an empty line or end of
|
|
17
|
+
# input means `default:`. When a person can still type (stdin is a terminal, stdout piped) it asks
|
|
18
|
+
# "Deploy? [y/N] " on stderr and asks again after an answer it can't read; from a pipe (`yes |
|
|
19
|
+
# tool`, CI) it doesn't ask, records "✔ Deploy? · yes" on stderr, and an unreadable answer is an
|
|
20
|
+
# error.
|
|
21
|
+
#
|
|
22
|
+
# This is the reference extension for prompts (docs/cli.md): a Prompt::Model run inline on the
|
|
23
|
+
# terminal, a line-reading fallback off it. Copy its shape and its test.
|
|
24
|
+
module R2UI
|
|
25
|
+
module CLI
|
|
26
|
+
module Ext
|
|
27
|
+
module Confirm
|
|
28
|
+
YES = %w[y yes true].freeze
|
|
29
|
+
NO = %w[n no false].freeze
|
|
30
|
+
|
|
31
|
+
# The inline prompt: a Bubbletea model on R2UI::CLI::Prompt.
|
|
32
|
+
class Model < Prompt::Model
|
|
33
|
+
attr_reader :question, :choice
|
|
34
|
+
|
|
35
|
+
def initialize(shell, question, default)
|
|
36
|
+
super(shell)
|
|
37
|
+
@question = question
|
|
38
|
+
@choice = default ? true : false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def view
|
|
42
|
+
return "#{shell.symbol(:error)} #{question}" if interrupted?
|
|
43
|
+
return Confirm.answered(shell, question, value) if done?
|
|
44
|
+
|
|
45
|
+
"#{shell.paint("?", :accent)} #{question} #{option("Yes", choice)} #{option("No", !choice)}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def key(name, _message)
|
|
51
|
+
case name
|
|
52
|
+
when "y", "Y" then submit(true)
|
|
53
|
+
when "n", "N" then submit(false)
|
|
54
|
+
when "enter" then submit(choice)
|
|
55
|
+
when "left", "right", "h", "l", "tab", "shift+tab"
|
|
56
|
+
@choice = !@choice
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The pointer marks the choice without colour too (NO_COLOR on a terminal).
|
|
62
|
+
def option(label, selected)
|
|
63
|
+
selected ? shell.paint("#{shell.theme.symbol(:pointer)} #{label}", :highlight) : shell.paint(" #{label}", :muted)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
def answered(shell, question, value)
|
|
70
|
+
"#{shell.symbol(:success)} #{question} #{shell.paint("·", :muted)} #{shell.paint(value ? "Yes" : "No", :accent)}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# true / false for a typed answer; `default` for none; nil for one it can't read.
|
|
74
|
+
def parse(line, default)
|
|
75
|
+
answer = line.to_s.strip.downcase
|
|
76
|
+
return default if answer.empty?
|
|
77
|
+
return true if YES.include?(answer)
|
|
78
|
+
|
|
79
|
+
false if NO.include?(answer)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def ask_line(shell, question, default)
|
|
83
|
+
hint = default ? "[Y/n]" : "[y/N]"
|
|
84
|
+
loop do
|
|
85
|
+
line = Prompt.read_line(shell, "#{question} #{hint} ")
|
|
86
|
+
value = parse(line, default)
|
|
87
|
+
unless value.nil?
|
|
88
|
+
shell.err_puts("#{shell.symbol(:success)} #{question} · #{value ? "yes" : "no"}") unless shell.input_tty?
|
|
89
|
+
return value
|
|
90
|
+
end
|
|
91
|
+
raise Error, "expected yes or no for #{question.inspect}, got #{line.strip.inspect}" unless shell.input_tty?
|
|
92
|
+
|
|
93
|
+
shell.err_puts("Please answer yes or no.")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
extension :confirm do
|
|
100
|
+
helpers do
|
|
101
|
+
def confirm(question, default: false)
|
|
102
|
+
default = default ? true : false
|
|
103
|
+
return Ext::Confirm.ask_line(shell, question, default) unless shell.interactive?
|
|
104
|
+
|
|
105
|
+
Prompt.run(shell, Ext::Confirm::Model.new(shell, question, default)).value
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c25-dashboard: a command opens an r2ui dashboard.
|
|
4
|
+
#
|
|
5
|
+
# command :top do
|
|
6
|
+
# summary "Watch the fleet"
|
|
7
|
+
# run { dashboard :fleet, file: "dashboards.rb" }
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# `dashboard(name, file: nil, height: nil)` loads the dashboard DSL (`require "r2ui"`; a CLI
|
|
11
|
+
# doesn't load it until a command opens a dashboard), then `file`, which defines the dashboards and
|
|
12
|
+
# resources with `R2UI.dashboard` / `R2UI.resource`. A relative `file` is looked up next to the
|
|
13
|
+
# Ruby file that calls `dashboard` (like require_relative), then in the working directory. Leave
|
|
14
|
+
# `file` out when the tool defines its dashboards itself. `name` is what `R2UI.run` takes: a
|
|
15
|
+
# dashboard, a resource shown full screen, or nil for :main / the first one.
|
|
16
|
+
#
|
|
17
|
+
# On a terminal (Shell#interactive?) it runs `R2UI.run(name)` full screen on the alternate
|
|
18
|
+
# screen, exactly as `r2ui dashboards.rb fleet` does, and returns when the user quits (q, or
|
|
19
|
+
# ctrl+c); raw mode, the alternate screen and the cursor are restored on every exit path, so the
|
|
20
|
+
# command carries on (or exits) with the shell as it was.
|
|
21
|
+
#
|
|
22
|
+
# Off an interactive shell (a pipe, CI, `tool top > frame.txt`) nothing waits for keys: it prints
|
|
23
|
+
# one `R2UI.snapshot` frame as plain text, no escape codes, at the shell's width and height
|
|
24
|
+
# (`height:` to choose), the same frame `r2ui --snapshot` draws:
|
|
25
|
+
#
|
|
26
|
+
# ╭─ Ship ─────────────────────────────────────────╮
|
|
27
|
+
# │Name Status │
|
|
28
|
+
# │alpha up │
|
|
29
|
+
# │beta down │
|
|
30
|
+
# ╰────────────────────────────────────────────────╯
|
|
31
|
+
#
|
|
32
|
+
# A missing file, or a name no dashboard or resource has, is an error (exit 1, "✖ message").
|
|
33
|
+
module R2UI
|
|
34
|
+
module CLI
|
|
35
|
+
module Ext
|
|
36
|
+
module OpenDashboard
|
|
37
|
+
# Bubbletea's runner on the shell's input and output (the upstream runner always uses the
|
|
38
|
+
# process's stdin/stdout), with the app's own program options (alternate screen, fps, and
|
|
39
|
+
# whatever dashboard extensions add).
|
|
40
|
+
class ShellRunner < Bubbletea::Runner
|
|
41
|
+
def initialize(app, shell)
|
|
42
|
+
super(app, **app.program_options)
|
|
43
|
+
@shell = shell
|
|
44
|
+
@program = Bubbletea::Program.new(input: shell.input, output: shell.output)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# The runner ends inline runs with `print "\r\n"`: send it to the shell, not $stdout.
|
|
50
|
+
def print(*args) = @shell.output.print(*args)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
def load_dsl
|
|
56
|
+
require_relative "../../../r2ui" unless defined?(::R2UI::App)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# An absolute path as is; a relative one next to the calling file, else the working dir.
|
|
60
|
+
def resolve(file, base_dir)
|
|
61
|
+
path = file.to_s
|
|
62
|
+
candidates = [File.expand_path(path)]
|
|
63
|
+
candidates.unshift(File.expand_path(path, base_dir)) if base_dir && !File.absolute_path?(path)
|
|
64
|
+
found = candidates.find { |c| File.file?(c) }
|
|
65
|
+
found or raise Error, "no dashboard file #{path} (looked for #{candidates.uniq.join(", ")})"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# R2UI.run(name) on the shell's terminal (R2UI.run itself always uses $stdin/$stdout).
|
|
69
|
+
def run(shell, name)
|
|
70
|
+
return R2UI.run(name) if shell.input.equal?($stdin) && shell.output.equal?($stdout)
|
|
71
|
+
|
|
72
|
+
app = R2UI::App.new(R2UI.registry, name)
|
|
73
|
+
begin
|
|
74
|
+
ShellRunner.new(app, shell).run
|
|
75
|
+
ensure
|
|
76
|
+
app.stop
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def snapshot(shell, name, height)
|
|
81
|
+
shell.puts(R2UI.snapshot(name, width: shell.width, height: height || shell.height))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
extension :dashboard do
|
|
87
|
+
helpers do
|
|
88
|
+
def dashboard(name = nil, file: nil, height: nil)
|
|
89
|
+
Ext::OpenDashboard.load_dsl
|
|
90
|
+
if file
|
|
91
|
+
location = caller_locations(1, 1).first
|
|
92
|
+
caller_file = location && (location.absolute_path || location.path)
|
|
93
|
+
load Ext::OpenDashboard.resolve(file, caller_file && File.dirname(File.expand_path(caller_file)))
|
|
94
|
+
end
|
|
95
|
+
if shell.interactive?
|
|
96
|
+
Ext::OpenDashboard.run(shell, name)
|
|
97
|
+
else
|
|
98
|
+
Ext::OpenDashboard.snapshot(shell, name, height)
|
|
99
|
+
end
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c11-diff: a unified diff of two texts, the way `git diff` and `diff -u` print one.
|
|
4
|
+
#
|
|
5
|
+
# changed = diff(deployed_config, local_config, labels: ["config.yml (deployed)", "config.yml (local)"])
|
|
6
|
+
# say "Nothing to deploy", :muted unless changed
|
|
7
|
+
#
|
|
8
|
+
# prints
|
|
9
|
+
#
|
|
10
|
+
# --- config.yml (deployed)
|
|
11
|
+
# +++ config.yml (local)
|
|
12
|
+
# @@ -2,4 +2,4 @@
|
|
13
|
+
# env: production
|
|
14
|
+
# -replicas: 2
|
|
15
|
+
# +replicas: 4
|
|
16
|
+
# region: eu
|
|
17
|
+
# timeout: 30
|
|
18
|
+
#
|
|
19
|
+
# and returns true. Equal texts print nothing and return false, so `diff(a, b) or say("up to date")`
|
|
20
|
+
# reads naturally.
|
|
21
|
+
#
|
|
22
|
+
# `old` and `new` are Strings (split into lines) or Arrays of lines. `labels:` names the two sides
|
|
23
|
+
# in the `---`/`+++` header (default `a` and `b`); `context:` is how many unchanged lines surround
|
|
24
|
+
# each change (default 3, 0 for none); changes closer than twice that share a hunk. Hunk headers
|
|
25
|
+
# use diff's numbering (`@@ -3,0 +4 @@` is an insertion after line 3), and a last line without a
|
|
26
|
+
# newline is followed by "", so the output is a patch `patch`/`git apply`
|
|
27
|
+
# accept. The edit is minimal: a pure-Ruby LCS (Myers' O(ND) algorithm, after trimming the common
|
|
28
|
+
# head and tail), fast on large files with few changes.
|
|
29
|
+
#
|
|
30
|
+
# On a terminal (Shell#color?) the header is bold, hunk headers muted, removed lines red and added
|
|
31
|
+
# lines green; tabs and trailing spaces are kept as they are. Off a terminal (a pipe, CI,
|
|
32
|
+
# `NO_COLOR`) the same text without escape codes. It goes to stdout through the Shell; inside a
|
|
33
|
+
# Live region (tasks, spinners) it prints above it.
|
|
34
|
+
module R2UI
|
|
35
|
+
module CLI
|
|
36
|
+
module Ext
|
|
37
|
+
module UnifiedDiff
|
|
38
|
+
# One line of the edit: :same, :del or :ins, the line (with its newline, if it has one),
|
|
39
|
+
# and how many lines of each side come before it.
|
|
40
|
+
Op = Struct.new(:kind, :line, :a, :b)
|
|
41
|
+
|
|
42
|
+
SIGNS = { same: " ", del: "-", ins: "+" }.freeze
|
|
43
|
+
STYLES = { same: [], del: [:error], ins: [:success] }.freeze
|
|
44
|
+
NO_NEWLINE = "\"
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
# The diff's lines (without newlines), painted for the shell; [] when the texts are equal.
|
|
49
|
+
def render(shell, old, new, labels:, context:)
|
|
50
|
+
ops = edits(lines(old), lines(new))
|
|
51
|
+
groups = hunks(ops, context)
|
|
52
|
+
return [] if groups.empty?
|
|
53
|
+
|
|
54
|
+
out = ["--- #{labels[0]}", "+++ #{labels[1]}"].map { |l| shell.paint(l, :heading) }
|
|
55
|
+
groups.each do |hunk|
|
|
56
|
+
out << shell.paint(header(hunk), :muted)
|
|
57
|
+
hunk.each do |op|
|
|
58
|
+
text = op.line.chomp
|
|
59
|
+
out << paint_line(shell, "#{SIGNS[op.kind]}#{text}", STYLES[op.kind])
|
|
60
|
+
out << shell.paint(NO_NEWLINE, :muted) unless op.line.end_with?("\n")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
out
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def lines(text)
|
|
67
|
+
return text.map { |line| "#{line.to_s.chomp}\n" } if text.is_a?(Array)
|
|
68
|
+
|
|
69
|
+
text.to_s.lines
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Paints around tabs: lipgloss would expand them, and the text must stay the same.
|
|
73
|
+
def paint_line(shell, line, styles)
|
|
74
|
+
return line if styles.empty?
|
|
75
|
+
|
|
76
|
+
line.split("\t", -1).map { |part| shell.paint(part, *styles) }.join("\t")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The edit from a to b, as Ops in order.
|
|
80
|
+
def edits(a, b)
|
|
81
|
+
head = 0
|
|
82
|
+
head += 1 while head < a.size && head < b.size && a[head] == b[head]
|
|
83
|
+
tail = 0
|
|
84
|
+
tail += 1 while tail < a.size - head && tail < b.size - head && a[-1 - tail] == b[-1 - tail]
|
|
85
|
+
kinds = Array.new(head, :same) +
|
|
86
|
+
myers(a[head...(a.size - tail)], b[head...(b.size - tail)]) +
|
|
87
|
+
Array.new(tail, :same)
|
|
88
|
+
|
|
89
|
+
i = j = 0
|
|
90
|
+
kinds.map do |kind|
|
|
91
|
+
op = Op.new(kind, kind == :ins ? b[j] : a[i], i, j)
|
|
92
|
+
i += 1 unless kind == :ins
|
|
93
|
+
j += 1 unless kind == :del
|
|
94
|
+
op
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Myers' greedy shortest edit script: the kinds of each step, deletions before insertions.
|
|
99
|
+
def myers(a, b)
|
|
100
|
+
n = a.size
|
|
101
|
+
m = b.size
|
|
102
|
+
return Array.new(m, :ins) if n.zero?
|
|
103
|
+
return Array.new(n, :del) if m.zero?
|
|
104
|
+
|
|
105
|
+
v = { 1 => 0 }
|
|
106
|
+
trace = []
|
|
107
|
+
(0..(n + m)).each do |d|
|
|
108
|
+
trace << v.dup
|
|
109
|
+
(-d..d).step(2) do |k|
|
|
110
|
+
x = down?(v, k, d) ? v[k + 1] : v[k - 1] + 1
|
|
111
|
+
y = x - k
|
|
112
|
+
while x < n && y < m && a[x] == b[y]
|
|
113
|
+
x += 1
|
|
114
|
+
y += 1
|
|
115
|
+
end
|
|
116
|
+
v[k] = x
|
|
117
|
+
return backtrack(trace, n, m) if x >= n && y >= m
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def down?(v, k, d) = k == -d || (k != d && v[k - 1] < v[k + 1])
|
|
123
|
+
|
|
124
|
+
def backtrack(trace, n, m)
|
|
125
|
+
x = n
|
|
126
|
+
y = m
|
|
127
|
+
kinds = []
|
|
128
|
+
(trace.size - 1).downto(0) do |d|
|
|
129
|
+
v = trace[d]
|
|
130
|
+
k = x - y
|
|
131
|
+
prev_k = down?(v, k, d) ? k + 1 : k - 1
|
|
132
|
+
prev_x = v[prev_k]
|
|
133
|
+
prev_y = prev_x - prev_k
|
|
134
|
+
while x > prev_x && y > prev_y
|
|
135
|
+
kinds << :same
|
|
136
|
+
x -= 1
|
|
137
|
+
y -= 1
|
|
138
|
+
end
|
|
139
|
+
kinds << (x == prev_x ? :ins : :del) if d.positive?
|
|
140
|
+
x = prev_x
|
|
141
|
+
y = prev_y
|
|
142
|
+
end
|
|
143
|
+
kinds.reverse
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The Ops of each hunk: changes with `context` lines around them, merged when they'd touch.
|
|
147
|
+
def hunks(ops, context)
|
|
148
|
+
ranges = []
|
|
149
|
+
ops.each_with_index do |op, i|
|
|
150
|
+
next if op.kind == :same
|
|
151
|
+
|
|
152
|
+
if ranges.any? && i - ranges.last[1] - 1 <= 2 * context
|
|
153
|
+
ranges.last[1] = i
|
|
154
|
+
else
|
|
155
|
+
ranges << [i, i]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
ranges.map do |first, last|
|
|
159
|
+
ops[[first - context, 0].max..[last + context, ops.size - 1].min]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def header(hunk)
|
|
164
|
+
old_count = hunk.count { |op| op.kind != :ins }
|
|
165
|
+
new_count = hunk.count { |op| op.kind != :del }
|
|
166
|
+
"@@ -#{range(hunk.first.a, old_count)} +#{range(hunk.first.b, new_count)} @@"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# diff's numbering: 1-based; an empty range names the line before it.
|
|
170
|
+
def range(start, count)
|
|
171
|
+
case count
|
|
172
|
+
when 0 then "#{start},0"
|
|
173
|
+
when 1 then (start + 1).to_s
|
|
174
|
+
else "#{start + 1},#{count}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
extension :diff do
|
|
181
|
+
helpers do
|
|
182
|
+
def diff(old, new, labels: %w[a b], context: 3)
|
|
183
|
+
unless context.is_a?(Integer) && !context.negative?
|
|
184
|
+
raise ArgumentError, "context must be a non-negative Integer, not #{context.inspect}"
|
|
185
|
+
end
|
|
186
|
+
raise ArgumentError, "labels must be two names" unless labels.respond_to?(:size) && labels.size == 2
|
|
187
|
+
|
|
188
|
+
lines = Ext::UnifiedDiff.render(shell, old, new, labels: labels.to_a, context:)
|
|
189
|
+
lines.each { |line| shell.puts(line) }
|
|
190
|
+
!lines.empty?
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
# c22-editor: let the user write something in their own editor, like `git commit`.
|
|
7
|
+
#
|
|
8
|
+
# notes = edit("# Release notes\n\n") # => what they saved
|
|
9
|
+
# config = edit(File.read("app.yml"), ext: ".yml") # the extension picks the syntax highlighting
|
|
10
|
+
#
|
|
11
|
+
# On a terminal (Shell#interactive?) it writes `text` to a temp file, runs `$VISUAL`, else
|
|
12
|
+
# `$EDITOR`, else `vi` on it in the foreground (on the tool's own terminal) and returns the saved
|
|
13
|
+
# contents. While the editor is open the tool shows
|
|
14
|
+
#
|
|
15
|
+
# ℹ Waiting for your editor to close the file…
|
|
16
|
+
#
|
|
17
|
+
# (seen behind GUI editors such as `code --wait`), and erases it when the editor exits. The editor
|
|
18
|
+
# command may carry arguments (`EDITOR="code --wait"`). The temp file is removed on every path:
|
|
19
|
+
# return, an editor that fails ("editor vi exited with status 1", a CLI::Error), one that can't be
|
|
20
|
+
# started, or ctrl+c.
|
|
21
|
+
#
|
|
22
|
+
# Off an interactive shell (a pipe, CI, TERM=dumb) there is nobody to edit, so it raises a
|
|
23
|
+
# CLI::Error ("edit needs a terminal ...") without starting the editor; a command exits 1 with
|
|
24
|
+
# that message on stderr. Read the text from a file or stdin instead in that case.
|
|
25
|
+
module R2UI
|
|
26
|
+
module CLI
|
|
27
|
+
module Ext
|
|
28
|
+
module Editor
|
|
29
|
+
DEFAULT = "vi"
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# The editor command as argv words: $VISUAL, $EDITOR, then vi.
|
|
34
|
+
def command(shell)
|
|
35
|
+
[shell.env["VISUAL"], shell.env["EDITOR"]].each do |value|
|
|
36
|
+
words = value.to_s.strip.empty? ? [] : Shellwords.split(value)
|
|
37
|
+
return words unless words.empty?
|
|
38
|
+
end
|
|
39
|
+
[DEFAULT]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def edit(shell, text, ext)
|
|
43
|
+
unless shell.interactive?
|
|
44
|
+
raise Error, "edit needs a terminal: run this from an interactive shell (it opens $VISUAL or $EDITOR)"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
words = command(shell)
|
|
48
|
+
Tempfile.create(["r2ui-edit-", ext.to_s]) do |file|
|
|
49
|
+
file.write(text.to_s)
|
|
50
|
+
file.close
|
|
51
|
+
run(shell, words, file.path)
|
|
52
|
+
File.read(file.path, mode: "r:UTF-8")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def run(shell, words, path)
|
|
57
|
+
hint(shell)
|
|
58
|
+
ok = begin
|
|
59
|
+
system(*words, path, **redirects(shell))
|
|
60
|
+
ensure
|
|
61
|
+
shell.print("\r\e[2K") if shell.live?
|
|
62
|
+
end
|
|
63
|
+
name = File.basename(words.first)
|
|
64
|
+
raise Error, "could not start editor #{name.inspect}: set $VISUAL or $EDITOR" if ok.nil?
|
|
65
|
+
return if ok
|
|
66
|
+
|
|
67
|
+
status = $?
|
|
68
|
+
raise Error, "editor #{name} exited with status #{status.exitstatus}" if status.exitstatus
|
|
69
|
+
|
|
70
|
+
raise Error, "editor #{name} was stopped (signal #{status.termsig})"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def hint(shell)
|
|
74
|
+
return unless shell.live?
|
|
75
|
+
|
|
76
|
+
shell.print("#{shell.symbol(:info)} #{shell.paint("Waiting for your editor to close the file…", :muted)}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The editor gets the tool's own terminal (only real IOs can be handed to a child).
|
|
80
|
+
def redirects(shell)
|
|
81
|
+
{ in: shell.input, out: shell.output, err: shell.error }.select { |_, io| io.is_a?(IO) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
extension :editor do
|
|
87
|
+
helpers do
|
|
88
|
+
def edit(text = "", ext: ".md")
|
|
89
|
+
Ext::Editor.edit(shell, text, ext)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|