ruby-progress 1.2.4 → 1.3.1
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 +33 -1
- data/Gemfile.lock +1 -1
- data/README.md +79 -134
- data/bin/prg +35 -0
- data/examples/daemon_job_example.sh +25 -0
- data/lib/ruby-progress/cli/fill_options.rb +22 -0
- data/lib/ruby-progress/cli/job_cli.rb +99 -0
- data/lib/ruby-progress/cli/ripple_cli.rb +66 -5
- data/lib/ruby-progress/cli/ripple_options.rb +10 -0
- data/lib/ruby-progress/cli/twirl_options.rb +12 -1
- data/lib/ruby-progress/cli/twirl_runner.rb +55 -2
- data/lib/ruby-progress/cli/twirl_spinner.rb +1 -0
- data/lib/ruby-progress/cli/worm_cli.rb +50 -16
- data/lib/ruby-progress/cli/worm_options.rb +20 -3
- data/lib/ruby-progress/cli/worm_runner.rb +37 -15
- data/lib/ruby-progress/daemon.rb +65 -0
- data/lib/ruby-progress/fill.rb +4 -0
- data/lib/ruby-progress/fill_cli.rb +30 -0
- data/lib/ruby-progress/output_capture.rb +136 -0
- data/lib/ruby-progress/ripple.rb +1 -0
- data/lib/ruby-progress/version.rb +6 -6
- metadata +4 -1
|
@@ -17,6 +17,8 @@ module RippleCLI
|
|
|
17
17
|
fail_message: nil,
|
|
18
18
|
complete_checkmark: false,
|
|
19
19
|
output: :error,
|
|
20
|
+
output_position: :above,
|
|
21
|
+
output_lines: 3,
|
|
20
22
|
message: nil
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -57,6 +59,14 @@ module RippleCLI
|
|
|
57
59
|
options[:command] = command
|
|
58
60
|
end
|
|
59
61
|
|
|
62
|
+
opts.on('--output-position POSITION', 'Position to render captured output: above or below (default: above)') do |pos|
|
|
63
|
+
options[:output_position] = pos.to_sym
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
opts.on('--output-lines N', Integer, 'Number of output lines to reserve for captured output (default: 3)') do |n|
|
|
67
|
+
options[:output_lines] = n
|
|
68
|
+
end
|
|
69
|
+
|
|
60
70
|
opts.on('--success MESSAGE', 'Success message to display') do |msg|
|
|
61
71
|
options[:success_message] = msg
|
|
62
72
|
end
|
|
@@ -10,7 +10,10 @@ module TwirlCLI
|
|
|
10
10
|
# so the `TwirlCLI` module stays small and focused on dispatching.
|
|
11
11
|
module Options
|
|
12
12
|
def self.parse_cli_options
|
|
13
|
-
options = {
|
|
13
|
+
options = {
|
|
14
|
+
output_position: :above,
|
|
15
|
+
output_lines: 3
|
|
16
|
+
}
|
|
14
17
|
|
|
15
18
|
OptionParser.new do |opts|
|
|
16
19
|
opts.banner = 'Usage: prg twirl [options]'
|
|
@@ -40,6 +43,14 @@ module TwirlCLI
|
|
|
40
43
|
options[:command] = command
|
|
41
44
|
end
|
|
42
45
|
|
|
46
|
+
opts.on('--output-position POSITION', 'Position to render captured output: above or below (default: above)') do |pos|
|
|
47
|
+
options[:output_position] = pos.to_sym
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
opts.on('--output-lines N', Integer, 'Number of output lines to reserve for captured output (default: 3)') do |n|
|
|
51
|
+
options[:output_lines] = n
|
|
52
|
+
end
|
|
53
|
+
|
|
43
54
|
opts.on('--success MESSAGE', 'Success message to display') do |text|
|
|
44
55
|
options[:success] = text
|
|
45
56
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'json'
|
|
5
5
|
require_relative 'twirl_spinner'
|
|
6
|
+
require_relative '../output_capture'
|
|
6
7
|
|
|
7
8
|
# Top-level runtime helper module for the Twirl CLI.
|
|
8
9
|
#
|
|
@@ -22,8 +23,25 @@ module TwirlRunner
|
|
|
22
23
|
RubyProgress::Utils.hide_cursor
|
|
23
24
|
spinner_thread = Thread.new { loop { spinner.animate } }
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
if $stdout.tty? && options[:stdout]
|
|
27
|
+
oc = RubyProgress::OutputCapture.new(
|
|
28
|
+
command: options[:command],
|
|
29
|
+
lines: options[:output_lines] || 3,
|
|
30
|
+
position: options[:output_position] || :above
|
|
31
|
+
)
|
|
32
|
+
oc.start
|
|
33
|
+
|
|
34
|
+
spinner.instance_variable_set(:@output_capture, oc)
|
|
35
|
+
|
|
36
|
+
# wait for command while spinner thread runs
|
|
37
|
+
oc.wait
|
|
38
|
+
captured_lines = oc.lines
|
|
39
|
+
captured_output = captured_lines.join("\n")
|
|
40
|
+
success = true
|
|
41
|
+
else
|
|
42
|
+
captured_output = `#{options[:command]} 2>&1`
|
|
43
|
+
success = $CHILD_STATUS.success?
|
|
44
|
+
end
|
|
27
45
|
|
|
28
46
|
spinner_thread.kill
|
|
29
47
|
RubyProgress::Utils.clear_line
|
|
@@ -82,6 +100,40 @@ module TwirlRunner
|
|
|
82
100
|
|
|
83
101
|
begin
|
|
84
102
|
RubyProgress::Utils.hide_cursor
|
|
103
|
+
|
|
104
|
+
# Start job processor thread for twirl
|
|
105
|
+
job_dir = RubyProgress::Daemon.job_dir_for_pid(pid_file)
|
|
106
|
+
job_thread = Thread.new do
|
|
107
|
+
RubyProgress::Daemon.process_jobs(job_dir) do |job|
|
|
108
|
+
oc = RubyProgress::OutputCapture.new(
|
|
109
|
+
command: job['command'],
|
|
110
|
+
lines: options[:output_lines] || 3,
|
|
111
|
+
position: options[:output_position] || :above
|
|
112
|
+
)
|
|
113
|
+
oc.start
|
|
114
|
+
|
|
115
|
+
spinner.instance_variable_set(:@output_capture, oc)
|
|
116
|
+
oc.wait
|
|
117
|
+
captured = oc.lines.join("\n")
|
|
118
|
+
exit_status = oc.exit_status
|
|
119
|
+
spinner.instance_variable_set(:@output_capture, nil)
|
|
120
|
+
|
|
121
|
+
success = exit_status.to_i.zero?
|
|
122
|
+
if job['message']
|
|
123
|
+
RubyProgress::Utils.display_completion(
|
|
124
|
+
job['message'],
|
|
125
|
+
success: success,
|
|
126
|
+
show_checkmark: job['checkmark'] || false,
|
|
127
|
+
output_stream: :stdout
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
{ 'exit_status' => exit_status, 'output' => captured }
|
|
132
|
+
rescue StandardError
|
|
133
|
+
# ignore
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
85
137
|
spinner.animate until stop_requested
|
|
86
138
|
ensure
|
|
87
139
|
RubyProgress::Utils.clear_line
|
|
@@ -114,6 +166,7 @@ module TwirlRunner
|
|
|
114
166
|
end
|
|
115
167
|
end
|
|
116
168
|
|
|
169
|
+
job_thread&.kill
|
|
117
170
|
FileUtils.rm_f(pid_file)
|
|
118
171
|
end
|
|
119
172
|
end
|
|
@@ -6,6 +6,14 @@ require_relative 'worm_options'
|
|
|
6
6
|
|
|
7
7
|
# Enhanced Worm CLI (extracted from bin/prg)
|
|
8
8
|
module WormCLI
|
|
9
|
+
def self.resolve_pid_file(options, name_key = :daemon_name)
|
|
10
|
+
return options[:pid_file] if options[:pid_file]
|
|
11
|
+
|
|
12
|
+
return "/tmp/ruby-progress/#{options[name_key]}.pid" if options[name_key]
|
|
13
|
+
|
|
14
|
+
RubyProgress::Daemon.default_pid_file
|
|
15
|
+
end
|
|
16
|
+
|
|
9
17
|
def self.run
|
|
10
18
|
options = WormCLI::Options.parse_cli_options
|
|
11
19
|
|
|
@@ -47,29 +55,55 @@ module WormCLI
|
|
|
47
55
|
progress = RubyProgress::Worm.new(options)
|
|
48
56
|
|
|
49
57
|
begin
|
|
58
|
+
# Start job processor thread for worm
|
|
59
|
+
job_dir = RubyProgress::Daemon.job_dir_for_pid(pid_file)
|
|
60
|
+
job_thread = Thread.new do
|
|
61
|
+
RubyProgress::Daemon.process_jobs(job_dir) do |job|
|
|
62
|
+
jid = job['id'] || SecureRandom.uuid
|
|
63
|
+
log_path = begin
|
|
64
|
+
File.join(File.dirname(job_dir), "#{jid}.log")
|
|
65
|
+
rescue StandardError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
oc = RubyProgress::OutputCapture.new(
|
|
70
|
+
command: job['command'],
|
|
71
|
+
lines: options[:output_lines] || 3,
|
|
72
|
+
position: options[:output_position] || :above,
|
|
73
|
+
log_path: log_path
|
|
74
|
+
)
|
|
75
|
+
oc.start
|
|
76
|
+
|
|
77
|
+
progress.instance_variable_set(:@output_capture, oc)
|
|
78
|
+
oc.wait
|
|
79
|
+
captured = oc.lines.join("\n")
|
|
80
|
+
exit_status = oc.exit_status
|
|
81
|
+
progress.instance_variable_set(:@output_capture, nil)
|
|
82
|
+
|
|
83
|
+
success = exit_status.to_i.zero?
|
|
84
|
+
if job['message']
|
|
85
|
+
RubyProgress::Utils.display_completion(
|
|
86
|
+
job['message'],
|
|
87
|
+
success: success,
|
|
88
|
+
show_checkmark: job['checkmark'] || false,
|
|
89
|
+
output_stream: :stdout
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
{ 'exit_status' => exit_status, 'output' => captured, 'log_path' => log_path }
|
|
94
|
+
rescue StandardError
|
|
95
|
+
# ignore per-job errors
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
50
99
|
progress.run_daemon_mode(
|
|
51
100
|
success_message: options[:success],
|
|
52
101
|
show_checkmark: options[:checkmark],
|
|
53
102
|
control_message_file: RubyProgress::Daemon.control_message_file(pid_file)
|
|
54
103
|
)
|
|
55
104
|
ensure
|
|
105
|
+
job_thread&.kill
|
|
56
106
|
FileUtils.rm_f(pid_file)
|
|
57
107
|
end
|
|
58
108
|
end
|
|
59
|
-
|
|
60
|
-
def self.resolve_pid_file(options, name_key)
|
|
61
|
-
return options[:pid_file] if options[:pid_file]
|
|
62
|
-
|
|
63
|
-
if options[name_key]
|
|
64
|
-
"/tmp/ruby-progress/#{options[name_key]}.pid"
|
|
65
|
-
else
|
|
66
|
-
RubyProgress::Daemon.default_pid_file
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# parse_cli_options is intentionally kept together; further extraction possible
|
|
71
|
-
# parse_cli_options is intentionally delegated to WormCLI::Options
|
|
72
|
-
def self.parse_cli_options
|
|
73
|
-
WormCLI::Options.parse_cli_options
|
|
74
|
-
end
|
|
75
109
|
end
|
|
@@ -9,8 +9,11 @@ module WormCLI
|
|
|
9
9
|
# the main dispatcher to keep the CLI module small and focused.
|
|
10
10
|
module Options
|
|
11
11
|
def self.parse_cli_options
|
|
12
|
-
options = {
|
|
13
|
-
|
|
12
|
+
options = {
|
|
13
|
+
output_position: :above,
|
|
14
|
+
output_lines: 3
|
|
15
|
+
}
|
|
16
|
+
# rubocop:disable Metrics/BlockLength
|
|
14
17
|
begin
|
|
15
18
|
OptionParser.new do |opts|
|
|
16
19
|
opts.banner = 'Usage: prg worm [options]'
|
|
@@ -48,6 +51,14 @@ module WormCLI
|
|
|
48
51
|
options[:command] = command
|
|
49
52
|
end
|
|
50
53
|
|
|
54
|
+
opts.on('--output-position POSITION', 'Position to render captured output: above or below (default: above)') do |pos|
|
|
55
|
+
options[:output_position] = pos.to_sym
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
opts.on('--output-lines N', Integer, 'Number of output lines to reserve for captured output (default: 3)') do |n|
|
|
59
|
+
options[:output_lines] = n
|
|
60
|
+
end
|
|
61
|
+
|
|
51
62
|
opts.on('--success MESSAGE', 'Success message to display') do |text|
|
|
52
63
|
options[:success] = text
|
|
53
64
|
end
|
|
@@ -76,6 +87,12 @@ module WormCLI
|
|
|
76
87
|
options[:daemon_name] = name
|
|
77
88
|
end
|
|
78
89
|
|
|
90
|
+
# Accept --daemon-name as an alias for --daemon-as for compatibility
|
|
91
|
+
opts.on('--daemon-name NAME', 'Alias for --daemon-as (compat)') do |name|
|
|
92
|
+
options[:daemon] = true
|
|
93
|
+
options[:daemon_name] = name
|
|
94
|
+
end
|
|
95
|
+
|
|
79
96
|
opts.on('--pid-file FILE', 'Write process ID to file (default: /tmp/ruby-progress/progress.pid)') do |file|
|
|
80
97
|
options[:pid_file] = file
|
|
81
98
|
end
|
|
@@ -149,7 +166,7 @@ module WormCLI
|
|
|
149
166
|
puts "Run 'prg worm --help' for more information."
|
|
150
167
|
exit 1
|
|
151
168
|
end
|
|
152
|
-
|
|
169
|
+
# rubocop:enable Metrics/BlockLength
|
|
153
170
|
options
|
|
154
171
|
end
|
|
155
172
|
end
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
# rubocop:disable Metrics/ModuleLength
|
|
4
5
|
require 'open3'
|
|
5
6
|
require 'json'
|
|
6
7
|
require_relative '../utils'
|
|
8
|
+
require_relative '../output_capture'
|
|
7
9
|
|
|
8
10
|
# Runtime helper methods for RubyProgress::Worm
|
|
9
11
|
#
|
|
@@ -56,21 +58,36 @@ module WormRunner
|
|
|
56
58
|
stdout_content = nil
|
|
57
59
|
|
|
58
60
|
begin
|
|
59
|
-
stdout_content =
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
61
|
+
stdout_content = if $stdout.tty? && @output_stdout
|
|
62
|
+
oc = RubyProgress::OutputCapture.new(
|
|
63
|
+
command: @command,
|
|
64
|
+
lines: @output_lines || 3,
|
|
65
|
+
position: @output_position || :above
|
|
66
|
+
)
|
|
67
|
+
oc.start
|
|
68
|
+
@output_capture = oc
|
|
69
|
+
animate do
|
|
70
|
+
oc.wait
|
|
71
|
+
end
|
|
72
|
+
@output_capture = nil
|
|
73
|
+
oc.lines.join("\n")
|
|
74
|
+
else
|
|
75
|
+
animate do
|
|
76
|
+
Open3.popen3(@command) do |_stdin, stdout, stderr, wait_thr|
|
|
77
|
+
captured_stdout = stdout.read
|
|
78
|
+
stderr_content = stderr.read
|
|
79
|
+
exit_code = wait_thr.value.exitstatus
|
|
80
|
+
|
|
81
|
+
unless wait_thr.value.success?
|
|
82
|
+
error_msg = @error_text || "Command failed with exit code #{exit_code}"
|
|
83
|
+
error_msg += ": #{stderr_content.strip}" if stderr_content && !stderr_content.empty?
|
|
84
|
+
raise StandardError, error_msg
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
captured_stdout
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
74
91
|
|
|
75
92
|
puts stdout_content if @output_stdout && stdout_content
|
|
76
93
|
rescue StandardError
|
|
@@ -161,6 +178,7 @@ module WormRunner
|
|
|
161
178
|
@direction ||= 1
|
|
162
179
|
|
|
163
180
|
message_part = @message && !@message.empty? ? "#{@message} " : ''
|
|
181
|
+
@output_capture&.redraw($stderr)
|
|
164
182
|
$stderr.print "\r\e[2K#{@start_chars}#{message_part}#{generate_dots(@position, @direction)}#{@end_chars}"
|
|
165
183
|
$stderr.flush
|
|
166
184
|
|
|
@@ -184,6 +202,7 @@ module WormRunner
|
|
|
184
202
|
|
|
185
203
|
while @running
|
|
186
204
|
message_part = @message && !@message.empty? ? "#{@message} " : ''
|
|
205
|
+
@output_capture&.redraw($stderr)
|
|
187
206
|
$stderr.print "\r\e[2K#{@start_chars}#{message_part}#{generate_dots(position, direction)}#{@end_chars}"
|
|
188
207
|
$stderr.flush
|
|
189
208
|
|
|
@@ -209,6 +228,7 @@ module WormRunner
|
|
|
209
228
|
|
|
210
229
|
while @running && !stop_requested_proc.call
|
|
211
230
|
message_part = @message && !@message.empty? ? "#{@message} " : ''
|
|
231
|
+
@output_capture&.redraw($stderr)
|
|
212
232
|
|
|
213
233
|
$stderr.print "\r\e[2K"
|
|
214
234
|
|
|
@@ -258,3 +278,5 @@ module WormRunner
|
|
|
258
278
|
dots.join
|
|
259
279
|
end
|
|
260
280
|
end
|
|
281
|
+
|
|
282
|
+
# rubocop:enable Metrics/ModuleLength
|
data/lib/ruby-progress/daemon.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
4
5
|
|
|
5
6
|
module RubyProgress
|
|
6
7
|
module Daemon
|
|
@@ -14,6 +15,70 @@ module RubyProgress
|
|
|
14
15
|
"#{pid_file}.msg"
|
|
15
16
|
end
|
|
16
17
|
|
|
18
|
+
# Resolve a job directory for the daemon based on pid_file or name.
|
|
19
|
+
# If pid_file is '/tmp/ruby-progress/mytask.pid' -> jobs dir '/tmp/ruby-progress/mytask.jobs'
|
|
20
|
+
def job_dir_for_pid(pid_file)
|
|
21
|
+
base = File.basename(pid_file, '.*')
|
|
22
|
+
File.join(File.dirname(pid_file), "#{base}.jobs")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Process available job files in job_dir. Each job is a JSON file with {"id","command","meta"}.
|
|
26
|
+
# This method polls the directory and yields each parsed job hash to the provided block.
|
|
27
|
+
def process_jobs(job_dir, poll_interval: 0.2)
|
|
28
|
+
FileUtils.mkdir_p(job_dir)
|
|
29
|
+
|
|
30
|
+
loop do
|
|
31
|
+
# Accept any job file ending in .json (UUID filenames are common)
|
|
32
|
+
# Ignore processed-* archives and temporary files (e.g., .tmp)
|
|
33
|
+
files = Dir.children(job_dir).select do |f|
|
|
34
|
+
f.end_with?('.json') && !f.start_with?('processed-')
|
|
35
|
+
end.sort
|
|
36
|
+
|
|
37
|
+
files.each do |f|
|
|
38
|
+
path = File.join(job_dir, f)
|
|
39
|
+
processing = "#{path}.processing"
|
|
40
|
+
|
|
41
|
+
# Claim the file atomically
|
|
42
|
+
begin
|
|
43
|
+
File.rename(path, processing)
|
|
44
|
+
rescue StandardError
|
|
45
|
+
next
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
job = begin
|
|
49
|
+
JSON.parse(File.read(processing))
|
|
50
|
+
rescue StandardError
|
|
51
|
+
FileUtils.rm_f(processing)
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
yielded = yield(job)
|
|
57
|
+
|
|
58
|
+
# on success, write .result info and merge any returned info
|
|
59
|
+
result = { 'id' => job['id'], 'status' => 'done', 'time' => Time.now.to_i }
|
|
60
|
+
if yielded.is_a?(Hash)
|
|
61
|
+
# ensure string keys
|
|
62
|
+
extra = yielded.transform_keys(&:to_s)
|
|
63
|
+
result.merge!(extra)
|
|
64
|
+
end
|
|
65
|
+
File.write("#{processing}.result", result.to_json)
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
result = { 'id' => job['id'], 'status' => 'error', 'error' => e.message }
|
|
68
|
+
File.write("#{processing}.result", result.to_json)
|
|
69
|
+
ensure
|
|
70
|
+
begin
|
|
71
|
+
FileUtils.mv(processing, File.join(job_dir, "processed-#{f}"))
|
|
72
|
+
rescue StandardError
|
|
73
|
+
FileUtils.rm_f(processing)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sleep(poll_interval)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
17
82
|
def show_status(pid_file)
|
|
18
83
|
if File.exist?(pid_file)
|
|
19
84
|
pid = File.read(pid_file).strip
|
data/lib/ruby-progress/fill.rb
CHANGED
|
@@ -25,6 +25,7 @@ module RubyProgress
|
|
|
25
25
|
@current_progress = 0
|
|
26
26
|
@success_message = options[:success]
|
|
27
27
|
@error_message = options[:error]
|
|
28
|
+
@output_capture = options[:output_capture]
|
|
28
29
|
|
|
29
30
|
# Parse --ends characters
|
|
30
31
|
if options[:ends]
|
|
@@ -82,6 +83,9 @@ module RubyProgress
|
|
|
82
83
|
|
|
83
84
|
# Render the current progress bar to stderr
|
|
84
85
|
def render
|
|
86
|
+
# First redraw captured output (if any) so it appears above/below the bar
|
|
87
|
+
@output_capture&.redraw($stderr)
|
|
88
|
+
|
|
85
89
|
filled = @style[:full] * @current_progress
|
|
86
90
|
empty = @style[:empty] * (@length - @current_progress)
|
|
87
91
|
bar = "#{@start_chars}#{filled}#{empty}#{@end_chars}"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'optparse'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require_relative 'cli/fill_options'
|
|
6
|
+
require_relative 'output_capture'
|
|
6
7
|
|
|
7
8
|
module RubyProgress
|
|
8
9
|
# CLI module for Fill command
|
|
@@ -160,6 +161,17 @@ module RubyProgress
|
|
|
160
161
|
error: options[:error_message]
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
# If a command is provided, capture its output and pass an OutputCapture
|
|
165
|
+
if options[:command]
|
|
166
|
+
oc = RubyProgress::OutputCapture.new(
|
|
167
|
+
command: options[:command],
|
|
168
|
+
lines: options[:output_lines] || 3,
|
|
169
|
+
position: options[:output_position] || :above
|
|
170
|
+
)
|
|
171
|
+
oc.start
|
|
172
|
+
fill_options[:output_capture] = oc
|
|
173
|
+
end
|
|
174
|
+
|
|
163
175
|
fill_bar = Fill.new(fill_options)
|
|
164
176
|
Fill.hide_cursor
|
|
165
177
|
|
|
@@ -172,6 +184,22 @@ module RubyProgress
|
|
|
172
184
|
# For non-complete percentages, show the result briefly
|
|
173
185
|
sleep(0.1)
|
|
174
186
|
end
|
|
187
|
+
elsif options[:command]
|
|
188
|
+
# While the command runs, keep redrawing the bar (live redraw handled by Fill#render)
|
|
189
|
+
sleep_time = case options[:speed]
|
|
190
|
+
when :fast then 0.1
|
|
191
|
+
when :medium, nil then 0.2
|
|
192
|
+
when :slow then 0.5
|
|
193
|
+
when Numeric then 1.0 / options[:speed]
|
|
194
|
+
else 0.3
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
fill_bar.render
|
|
198
|
+
# Loop until the OutputCapture reader has finished
|
|
199
|
+
while oc.alive?
|
|
200
|
+
sleep(sleep_time)
|
|
201
|
+
fill_bar.render
|
|
202
|
+
end
|
|
175
203
|
else
|
|
176
204
|
# Auto-advance mode
|
|
177
205
|
sleep_time = case options[:speed]
|
|
@@ -192,6 +220,8 @@ module RubyProgress
|
|
|
192
220
|
rescue Interrupt
|
|
193
221
|
fill_bar.cancel
|
|
194
222
|
ensure
|
|
223
|
+
# Ensure we wait for capture thread to finish and show cursor
|
|
224
|
+
oc&.wait
|
|
195
225
|
Fill.show_cursor
|
|
196
226
|
end
|
|
197
227
|
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pty'
|
|
4
|
+
require 'io/console'
|
|
5
|
+
require 'English'
|
|
6
|
+
|
|
7
|
+
module RubyProgress
|
|
8
|
+
# Simple PTY-based output capture that reserves a small area of the terminal
|
|
9
|
+
# for printing captured output while the animation is drawn separately.
|
|
10
|
+
class OutputCapture
|
|
11
|
+
attr_reader :exit_status
|
|
12
|
+
|
|
13
|
+
def initialize(command:, lines: 3, position: :above, log_path: nil)
|
|
14
|
+
@command = command
|
|
15
|
+
@lines = lines
|
|
16
|
+
@position = position
|
|
17
|
+
@buffer = []
|
|
18
|
+
@buf_mutex = Mutex.new
|
|
19
|
+
@stop = false
|
|
20
|
+
@log_path = log_path
|
|
21
|
+
@log_file = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Start the child process and return a thread that manages capture.
|
|
25
|
+
def start
|
|
26
|
+
@reader_thread = Thread.new { spawn_and_read }
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stop
|
|
31
|
+
@stop = true
|
|
32
|
+
@reader_thread&.join
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Wait for the reader thread to complete
|
|
36
|
+
def wait
|
|
37
|
+
@reader_thread&.join
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Return snapshot of buffered lines (thread-safe)
|
|
41
|
+
def lines
|
|
42
|
+
@buf_mutex.synchronize { @buffer.dup }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns whether the reader thread is still alive
|
|
46
|
+
def alive?
|
|
47
|
+
@reader_thread&.alive? || false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Redraw buffered output into the terminal above/below the current cursor
|
|
51
|
+
# io - IO object to write to (default $stderr)
|
|
52
|
+
def redraw(io = $stderr)
|
|
53
|
+
buf = lines
|
|
54
|
+
_rows, cols = IO.console.winsize
|
|
55
|
+
|
|
56
|
+
# Ensure we have exactly @lines entries (pad with empty strings)
|
|
57
|
+
display_lines = Array.new(@lines) { '' }
|
|
58
|
+
start = [0, buf.size - @lines].max
|
|
59
|
+
buf[start, @lines]&.each_with_index do |l, i|
|
|
60
|
+
display_lines[i + (@lines - [buf.size, @lines].min)] = l.to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Save cursor, move up N lines, clear and print buffer, restore cursor
|
|
64
|
+
io.print "\e[s" # save position
|
|
65
|
+
io.print "\e[#{@lines}A" # move up @lines
|
|
66
|
+
display_lines.each do |line|
|
|
67
|
+
io.print "\e[2K" # clear line
|
|
68
|
+
io.print "\r" # move cursor to start of line
|
|
69
|
+
io.print line[0, cols]
|
|
70
|
+
io.print "\n"
|
|
71
|
+
end
|
|
72
|
+
io.print "\e[u" # restore
|
|
73
|
+
io.flush
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def spawn_and_read
|
|
79
|
+
PTY.spawn(@command) do |reader, _writer, pid|
|
|
80
|
+
@child_pid = pid
|
|
81
|
+
until reader.eof? || @stop
|
|
82
|
+
next unless reader.wait_readable(0.1)
|
|
83
|
+
|
|
84
|
+
chunk = reader.read_nonblock(4096, exception: false)
|
|
85
|
+
next if chunk.nil? || chunk.empty?
|
|
86
|
+
|
|
87
|
+
# lazily open log file if requested
|
|
88
|
+
if @log_path && !@log_file
|
|
89
|
+
begin
|
|
90
|
+
FileUtils.mkdir_p(File.dirname(@log_path))
|
|
91
|
+
@log_file = File.open(@log_path, 'a')
|
|
92
|
+
rescue StandardError
|
|
93
|
+
@log_file = nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
process_chunk(chunk)
|
|
98
|
+
next unless @log_file
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
@log_file.write(chunk)
|
|
102
|
+
@log_file.flush
|
|
103
|
+
rescue StandardError
|
|
104
|
+
# ignore logging errors
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
begin
|
|
109
|
+
Process.wait(@child_pid) if @child_pid
|
|
110
|
+
@exit_status = $CHILD_STATUS.exitstatus if $CHILD_STATUS
|
|
111
|
+
rescue StandardError
|
|
112
|
+
@exit_status = nil
|
|
113
|
+
ensure
|
|
114
|
+
if @log_file
|
|
115
|
+
begin
|
|
116
|
+
@log_file.close
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
rescue Errno::EIO
|
|
123
|
+
# PTY finished
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def process_chunk(chunk)
|
|
127
|
+
@buf_mutex.synchronize do
|
|
128
|
+
# split into lines, keep last N lines
|
|
129
|
+
chunk.each_line do |line|
|
|
130
|
+
@buffer << line.chomp
|
|
131
|
+
@buffer.shift while @buffer.size > @lines
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/ruby-progress/ripple.rb
CHANGED
|
@@ -140,6 +140,7 @@ module RubyProgress
|
|
|
140
140
|
char = @rainbow ? char.rainbow(i) : char.extend(StringExtensions).light_white
|
|
141
141
|
post = letters.slice!(0, letters.length).join.extend(StringExtensions).dark_white
|
|
142
142
|
end
|
|
143
|
+
@output_capture&.redraw($stderr)
|
|
143
144
|
$stderr.print "\r\e[2K#{@start_chars}#{pre}#{char}#{post}#{@end_chars}"
|
|
144
145
|
$stderr.flush
|
|
145
146
|
end
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module RubyProgress
|
|
4
4
|
# Main gem version
|
|
5
|
-
VERSION = '1.
|
|
5
|
+
VERSION = '1.3.1'
|
|
6
6
|
|
|
7
|
-
# Component-specific versions
|
|
8
|
-
WORM_VERSION = '1.1.
|
|
9
|
-
TWIRL_VERSION = '1.1.
|
|
10
|
-
RIPPLE_VERSION = '1.1.
|
|
11
|
-
FILL_VERSION = '1.0.
|
|
7
|
+
# Component-specific versions (patch bumps)
|
|
8
|
+
WORM_VERSION = '1.1.3'
|
|
9
|
+
TWIRL_VERSION = '1.1.3'
|
|
10
|
+
RIPPLE_VERSION = '1.1.3'
|
|
11
|
+
FILL_VERSION = '1.0.3'
|
|
12
12
|
end
|