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