scint 0.1.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 +7 -0
- data/FEATURES.md +13 -0
- data/README.md +216 -0
- data/bin/bundler-vs-scint +233 -0
- data/bin/scint +35 -0
- data/bin/scint-io-summary +46 -0
- data/bin/scint-syscall-trace +41 -0
- data/lib/bundler/setup.rb +5 -0
- data/lib/bundler.rb +168 -0
- data/lib/scint/cache/layout.rb +131 -0
- data/lib/scint/cache/metadata_store.rb +75 -0
- data/lib/scint/cache/prewarm.rb +192 -0
- data/lib/scint/cli/add.rb +85 -0
- data/lib/scint/cli/cache.rb +316 -0
- data/lib/scint/cli/exec.rb +150 -0
- data/lib/scint/cli/install.rb +1047 -0
- data/lib/scint/cli/remove.rb +60 -0
- data/lib/scint/cli.rb +77 -0
- data/lib/scint/commands/exec.rb +17 -0
- data/lib/scint/commands/install.rb +17 -0
- data/lib/scint/credentials.rb +153 -0
- data/lib/scint/debug/io_trace.rb +218 -0
- data/lib/scint/debug/sampler.rb +138 -0
- data/lib/scint/downloader/fetcher.rb +113 -0
- data/lib/scint/downloader/pool.rb +112 -0
- data/lib/scint/errors.rb +63 -0
- data/lib/scint/fs.rb +119 -0
- data/lib/scint/gem/extractor.rb +86 -0
- data/lib/scint/gem/package.rb +62 -0
- data/lib/scint/gemfile/dependency.rb +30 -0
- data/lib/scint/gemfile/editor.rb +93 -0
- data/lib/scint/gemfile/parser.rb +275 -0
- data/lib/scint/index/cache.rb +166 -0
- data/lib/scint/index/client.rb +301 -0
- data/lib/scint/index/parser.rb +142 -0
- data/lib/scint/installer/extension_builder.rb +264 -0
- data/lib/scint/installer/linker.rb +226 -0
- data/lib/scint/installer/planner.rb +140 -0
- data/lib/scint/installer/preparer.rb +207 -0
- data/lib/scint/lockfile/parser.rb +251 -0
- data/lib/scint/lockfile/writer.rb +178 -0
- data/lib/scint/platform.rb +71 -0
- data/lib/scint/progress.rb +579 -0
- data/lib/scint/resolver/provider.rb +230 -0
- data/lib/scint/resolver/resolver.rb +249 -0
- data/lib/scint/runtime/exec.rb +141 -0
- data/lib/scint/runtime/setup.rb +45 -0
- data/lib/scint/scheduler.rb +392 -0
- data/lib/scint/source/base.rb +46 -0
- data/lib/scint/source/git.rb +92 -0
- data/lib/scint/source/path.rb +70 -0
- data/lib/scint/source/rubygems.rb +79 -0
- data/lib/scint/vendor/pub_grub/assignment.rb +20 -0
- data/lib/scint/vendor/pub_grub/basic_package_source.rb +169 -0
- data/lib/scint/vendor/pub_grub/failure_writer.rb +182 -0
- data/lib/scint/vendor/pub_grub/incompatibility.rb +150 -0
- data/lib/scint/vendor/pub_grub/package.rb +43 -0
- data/lib/scint/vendor/pub_grub/partial_solution.rb +121 -0
- data/lib/scint/vendor/pub_grub/rubygems.rb +45 -0
- data/lib/scint/vendor/pub_grub/solve_failure.rb +19 -0
- data/lib/scint/vendor/pub_grub/static_package_source.rb +61 -0
- data/lib/scint/vendor/pub_grub/strategy.rb +42 -0
- data/lib/scint/vendor/pub_grub/term.rb +105 -0
- data/lib/scint/vendor/pub_grub/version.rb +3 -0
- data/lib/scint/vendor/pub_grub/version_constraint.rb +129 -0
- data/lib/scint/vendor/pub_grub/version_range.rb +423 -0
- data/lib/scint/vendor/pub_grub/version_solver.rb +236 -0
- data/lib/scint/vendor/pub_grub/version_union.rb +178 -0
- data/lib/scint/vendor/pub_grub.rb +32 -0
- data/lib/scint/worker_pool.rb +114 -0
- data/lib/scint.rb +87 -0
- metadata +116 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scint
|
|
4
|
+
class Progress
|
|
5
|
+
HIDDEN_TYPES = {
|
|
6
|
+
binstub: true,
|
|
7
|
+
}.freeze
|
|
8
|
+
STREAM_TYPES = {
|
|
9
|
+
download: true,
|
|
10
|
+
extract: true,
|
|
11
|
+
link: true,
|
|
12
|
+
build_ext: true,
|
|
13
|
+
}.freeze
|
|
14
|
+
SETUP_TYPES = {
|
|
15
|
+
fetch_index: true,
|
|
16
|
+
git_clone: true,
|
|
17
|
+
resolve: true,
|
|
18
|
+
}.freeze
|
|
19
|
+
BUILD_TAIL_MAX = 6
|
|
20
|
+
BUILD_TAIL_PREVIEW_LINES = 4
|
|
21
|
+
RENDER_HZ = 5
|
|
22
|
+
RENDER_INTERVAL = 1.0 / RENDER_HZ
|
|
23
|
+
MAX_DETAIL_ROWS_PER_PHASE = 4
|
|
24
|
+
MAX_LINE_LEN = 220
|
|
25
|
+
MIN_RENDER_WIDTH = 40
|
|
26
|
+
MAX_PANEL_ROWS = 14
|
|
27
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
28
|
+
IDLE_MARK = "○".freeze
|
|
29
|
+
PANEL_PHASE_ORDER = %i[download extract build_ext link].freeze
|
|
30
|
+
|
|
31
|
+
PHASE_LABELS = {
|
|
32
|
+
fetch_index: "Fetching index",
|
|
33
|
+
git_clone: "Cloning",
|
|
34
|
+
resolve: "Resolving",
|
|
35
|
+
download: "Downloading",
|
|
36
|
+
extract: "Extraction",
|
|
37
|
+
link: "Installing",
|
|
38
|
+
build_ext: "Compiling",
|
|
39
|
+
}.freeze
|
|
40
|
+
PHASE_SUMMARY_LABELS = {
|
|
41
|
+
download: "Downloads",
|
|
42
|
+
extract: "Extraction",
|
|
43
|
+
link: "Installing",
|
|
44
|
+
build_ext: "Compiling",
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
def initialize(output: $stderr)
|
|
48
|
+
@output = output
|
|
49
|
+
@interactive = tty_output?(@output)
|
|
50
|
+
@render_width = detect_terminal_width(@output)
|
|
51
|
+
@build_tail_width = [@render_width - 28, 40].max
|
|
52
|
+
@mutex = Thread::Mutex.new
|
|
53
|
+
@started = 0
|
|
54
|
+
@completed = Hash.new(0)
|
|
55
|
+
@failed = Hash.new(0)
|
|
56
|
+
@total = Hash.new(0)
|
|
57
|
+
@active_jobs = {}
|
|
58
|
+
@build_tail = []
|
|
59
|
+
@build_tail_by_name = {}
|
|
60
|
+
@spinner_idx = 0
|
|
61
|
+
@live_rows = 0
|
|
62
|
+
@rendered_widths = []
|
|
63
|
+
@cursor_hidden = false
|
|
64
|
+
@phase_started_at = {}
|
|
65
|
+
@phase_finished = {}
|
|
66
|
+
@phase_elapsed = {}
|
|
67
|
+
@phase_reserved_detail_rows = Hash.new(0)
|
|
68
|
+
@setup_lines_printed = false
|
|
69
|
+
@setup_gap_printed = false
|
|
70
|
+
@render_stop = false
|
|
71
|
+
@render_thread = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def start
|
|
75
|
+
return unless @interactive
|
|
76
|
+
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
start_render_thread_locked
|
|
79
|
+
end
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stop
|
|
84
|
+
thread = nil
|
|
85
|
+
@mutex.synchronize do
|
|
86
|
+
@render_stop = true
|
|
87
|
+
thread = @render_thread
|
|
88
|
+
end
|
|
89
|
+
thread&.join
|
|
90
|
+
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
begin
|
|
93
|
+
mark_completed_phase_elapsed_locked
|
|
94
|
+
render_live_locked if any_stream_activity?
|
|
95
|
+
if @interactive && @live_rows.positive?
|
|
96
|
+
move_cursor_down(@live_rows - 1)
|
|
97
|
+
@output.print "\r\n"
|
|
98
|
+
@output.flush if @output.respond_to?(:flush)
|
|
99
|
+
# Final frame is now in scrollback; clear live-block tracking so
|
|
100
|
+
# repeated stop calls do not move/overwrite previously rendered rows.
|
|
101
|
+
@live_rows = 0
|
|
102
|
+
@rendered_widths = []
|
|
103
|
+
end
|
|
104
|
+
ensure
|
|
105
|
+
show_cursor_locked
|
|
106
|
+
@render_thread = nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def on_enqueue(job_id, type, name)
|
|
112
|
+
@mutex.synchronize do
|
|
113
|
+
@total[type] += 1 unless hidden_type?(type)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def on_start(job_id, type, name)
|
|
118
|
+
return if hidden_type?(type)
|
|
119
|
+
|
|
120
|
+
@mutex.synchronize do
|
|
121
|
+
@active_jobs[job_id] = { type: type, name: name }
|
|
122
|
+
@started += 1
|
|
123
|
+
emit_setup_gap_if_needed(type)
|
|
124
|
+
@phase_started_at[type] ||= Process.clock_gettime(Process::CLOCK_MONOTONIC) if stream_type?(type)
|
|
125
|
+
if @interactive && stream_type?(type)
|
|
126
|
+
start_render_thread_locked
|
|
127
|
+
else
|
|
128
|
+
clear_live_block_locked
|
|
129
|
+
emit_setup_or_log_line(type, name)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def on_complete(job_id, type, name)
|
|
135
|
+
@mutex.synchronize do
|
|
136
|
+
@completed[type] += 1
|
|
137
|
+
active = @active_jobs.delete(job_id)
|
|
138
|
+
@build_tail_by_name.delete(active[:name]) if active
|
|
139
|
+
if !@interactive || !stream_type?(type)
|
|
140
|
+
emit_phase_completion_locked(type)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def on_fail(job_id, type, name, error)
|
|
146
|
+
return if hidden_type?(type)
|
|
147
|
+
|
|
148
|
+
@mutex.synchronize do
|
|
149
|
+
active = @active_jobs.delete(job_id)
|
|
150
|
+
@build_tail_by_name.delete(active[:name]) if active
|
|
151
|
+
@failed[type] += 1
|
|
152
|
+
clear_live_block_locked
|
|
153
|
+
label = PHASE_LABELS[type] || type.to_s
|
|
154
|
+
@output.puts "#{RED}FAILED#{RESET} #{label} #{BOLD}#{name}#{RESET}: #{error.message}"
|
|
155
|
+
if !@interactive
|
|
156
|
+
emit_phase_completion_locked(type)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Accepts the latest build command output lines for one gem and prints
|
|
162
|
+
# a rolling tail so native build activity is visible without log spam.
|
|
163
|
+
def on_build_tail(name, lines)
|
|
164
|
+
cleaned = Array(lines).map { |line| line.to_s.strip }.reject(&:empty?)
|
|
165
|
+
return if cleaned.empty?
|
|
166
|
+
|
|
167
|
+
@mutex.synchronize do
|
|
168
|
+
truncated = cleaned.map { |line| truncate_plain(line, @build_tail_width) }
|
|
169
|
+
truncated.each { |line| @build_tail << "#{name}: #{line}" }
|
|
170
|
+
@build_tail = @build_tail.last(BUILD_TAIL_MAX)
|
|
171
|
+
@build_tail_by_name[name] ||= []
|
|
172
|
+
@build_tail_by_name[name].concat(truncated)
|
|
173
|
+
@build_tail_by_name[name] = @build_tail_by_name[name].last(BUILD_TAIL_MAX)
|
|
174
|
+
|
|
175
|
+
unless @interactive
|
|
176
|
+
@output.puts "#{DIM} build tail#{RESET}"
|
|
177
|
+
@build_tail.last(4).each do |line|
|
|
178
|
+
@output.puts "#{DIM} #{truncate_plain(line, 180)}#{RESET}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def summary
|
|
185
|
+
total_completed = @completed.values.sum
|
|
186
|
+
total_failed = @failed.values.sum
|
|
187
|
+
if total_failed > 0
|
|
188
|
+
"#{total_completed} gems installed, #{total_failed} failed"
|
|
189
|
+
else
|
|
190
|
+
"#{total_completed} gems processed"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def hidden_type?(type)
|
|
197
|
+
HIDDEN_TYPES[type] == true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def render_live_locked
|
|
201
|
+
return unless @interactive
|
|
202
|
+
return unless any_stream_activity?
|
|
203
|
+
|
|
204
|
+
mark_completed_phase_elapsed_locked
|
|
205
|
+
|
|
206
|
+
lines = []
|
|
207
|
+
spinner = SPINNER_FRAMES[@spinner_idx % SPINNER_FRAMES.length]
|
|
208
|
+
@spinner_idx += 1
|
|
209
|
+
|
|
210
|
+
lines.concat(phase_lines(spinner))
|
|
211
|
+
lines = clamp_panel_rows(lines)
|
|
212
|
+
|
|
213
|
+
redraw_live_block_locked(lines)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def truncate_plain(text, max_len)
|
|
217
|
+
return text if text.length <= max_len
|
|
218
|
+
|
|
219
|
+
"#{text[0, max_len - 1]}…"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def stream_type?(type)
|
|
223
|
+
STREAM_TYPES[type] == true
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def stream_active_or_pending?
|
|
227
|
+
return true if STREAM_TYPES.keys.any? { |type| !active_stream_jobs_for(type).empty? }
|
|
228
|
+
|
|
229
|
+
STREAM_TYPES.keys.any? do |type|
|
|
230
|
+
@total[type].positive? && (@completed[type] + @failed[type] < @total[type])
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def any_stream_activity?
|
|
235
|
+
STREAM_TYPES.keys.any? do |type|
|
|
236
|
+
@total[type].positive? || @completed[type].positive? || @failed[type].positive? || !active_stream_jobs_for(type).empty?
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def phase_lines(spinner)
|
|
241
|
+
PANEL_PHASE_ORDER.flat_map do |type|
|
|
242
|
+
phase_rows_for(type, spinner)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def phase_rows_for(type, spinner)
|
|
247
|
+
active = active_stream_jobs_for(type)
|
|
248
|
+
total = @total[type]
|
|
249
|
+
completed = @completed[type] + @failed[type]
|
|
250
|
+
total = completed if total < completed
|
|
251
|
+
|
|
252
|
+
label = PHASE_SUMMARY_LABELS[type] || (PHASE_LABELS[type] || type.to_s)
|
|
253
|
+
line = phase_status_line(type, active, label, completed, total, spinner)
|
|
254
|
+
detail_rows = phase_detail_rows_for(type, active)
|
|
255
|
+
[line, *reserve_phase_detail_space(type, detail_rows, total, completed, active)]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def phase_status_line(type, active_jobs, label, completed, total, spinner)
|
|
259
|
+
base = "#{label}... (#{completed}/#{total})"
|
|
260
|
+
if active_jobs.empty?
|
|
261
|
+
elapsed = @phase_elapsed[type]
|
|
262
|
+
elapsed_text = elapsed ? " · #{format_phase_elapsed(elapsed)}" : ""
|
|
263
|
+
"#{DIM}#{IDLE_MARK} #{base}#{elapsed_text}#{RESET}"
|
|
264
|
+
else
|
|
265
|
+
name = active_jobs.first[:name]
|
|
266
|
+
"#{spinner} #{base} · #{BOLD}#{name}#{RESET}"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def phase_detail_rows_for(type, active_jobs)
|
|
271
|
+
return [] if active_jobs.empty?
|
|
272
|
+
|
|
273
|
+
if type == :build_ext
|
|
274
|
+
compile_tail_rows_for(active_jobs.first[:name])
|
|
275
|
+
else
|
|
276
|
+
active_rows_for(type, active_jobs)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def reserve_phase_detail_space(type, details, total, completed, active_jobs)
|
|
281
|
+
if phase_complete?(type, total, completed, active_jobs)
|
|
282
|
+
@phase_reserved_detail_rows[type] = 0
|
|
283
|
+
return []
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
desired = [details.length, MAX_DETAIL_ROWS_PER_PHASE].min
|
|
287
|
+
@phase_reserved_detail_rows[type] = [@phase_reserved_detail_rows[type], desired].max
|
|
288
|
+
reserved = @phase_reserved_detail_rows[type]
|
|
289
|
+
|
|
290
|
+
rows = details.first(reserved)
|
|
291
|
+
while rows.length < reserved
|
|
292
|
+
rows << "#{DIM} #{RESET}"
|
|
293
|
+
end
|
|
294
|
+
rows
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def phase_complete?(type, total, completed, active_jobs)
|
|
298
|
+
return false if total.zero?
|
|
299
|
+
|
|
300
|
+
if completed >= total && active_jobs.empty?
|
|
301
|
+
mark_phase_elapsed_locked(type)
|
|
302
|
+
return true
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
false
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def active_rows_for(_type, active_jobs)
|
|
309
|
+
return [] if active_jobs.empty?
|
|
310
|
+
if active_jobs.length <= MAX_DETAIL_ROWS_PER_PHASE
|
|
311
|
+
return active_jobs.map { |job| " #{BOLD}#{job[:name]}#{RESET}" }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
rows = active_jobs.first(MAX_DETAIL_ROWS_PER_PHASE - 1).map { |job| " #{BOLD}#{job[:name]}#{RESET}" }
|
|
315
|
+
remaining = active_jobs.length - rows.length
|
|
316
|
+
rows << "#{DIM} +#{remaining} more#{RESET}"
|
|
317
|
+
rows
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def compile_tail_rows_for(name)
|
|
321
|
+
tail = @build_tail_by_name[name]
|
|
322
|
+
return [] if tail.nil? || tail.empty?
|
|
323
|
+
|
|
324
|
+
tail.last(BUILD_TAIL_PREVIEW_LINES).map do |line|
|
|
325
|
+
"#{DIM} #{truncate_plain(line, @build_tail_width)}#{RESET}"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def active_stream_jobs_for(type)
|
|
330
|
+
@active_jobs.values.select { |job| job[:type] == type }
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def clear_live_block_locked
|
|
334
|
+
return unless @interactive
|
|
335
|
+
return if @live_rows.zero?
|
|
336
|
+
|
|
337
|
+
move_cursor_up(@live_rows - 1)
|
|
338
|
+
@live_rows.times do |idx|
|
|
339
|
+
prev_width = @rendered_widths[idx] || 0
|
|
340
|
+
@output.print "\r#{' ' * prev_width}"
|
|
341
|
+
@output.print "\n" if idx < (@live_rows - 1)
|
|
342
|
+
end
|
|
343
|
+
move_cursor_up(@live_rows - 1)
|
|
344
|
+
@output.print "\r"
|
|
345
|
+
@output.flush if @output.respond_to?(:flush)
|
|
346
|
+
@live_rows = 0
|
|
347
|
+
@rendered_widths = []
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def redraw_live_block_locked(lines)
|
|
351
|
+
hide_cursor_locked
|
|
352
|
+
previous_rows = @live_rows
|
|
353
|
+
previous_widths = @rendered_widths.dup
|
|
354
|
+
|
|
355
|
+
move_cursor_up(previous_rows - 1) if previous_rows.positive?
|
|
356
|
+
|
|
357
|
+
max_rows = [previous_rows, lines.length].max
|
|
358
|
+
new_widths = []
|
|
359
|
+
|
|
360
|
+
max_rows.times do |idx|
|
|
361
|
+
line = idx < lines.length ? fit_line(lines[idx], @render_width) : ""
|
|
362
|
+
prev_width = previous_widths[idx] || 0
|
|
363
|
+
line_width = visible_width(line)
|
|
364
|
+
pad_len = prev_width > line_width ? (prev_width - line_width) : 0
|
|
365
|
+
pad = pad_len.positive? ? (" " * pad_len) : ""
|
|
366
|
+
|
|
367
|
+
@output.print "\r#{line}#{pad}"
|
|
368
|
+
@output.print "\n" if idx < (max_rows - 1)
|
|
369
|
+
new_widths << line_width
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
move_cursor_up(max_rows - lines.length) if max_rows > lines.length
|
|
373
|
+
@output.flush if @output.respond_to?(:flush)
|
|
374
|
+
@live_rows = lines.length
|
|
375
|
+
@rendered_widths = new_widths.first(lines.length)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def clamp_panel_rows(lines)
|
|
379
|
+
return lines if lines.length <= MAX_PANEL_ROWS
|
|
380
|
+
|
|
381
|
+
clipped = lines.first(MAX_PANEL_ROWS - 1)
|
|
382
|
+
clipped << "#{DIM}...#{RESET}"
|
|
383
|
+
clipped
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def move_cursor_up(lines)
|
|
387
|
+
return if lines <= 0
|
|
388
|
+
|
|
389
|
+
@output.print "\e[#{lines}A"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def move_cursor_down(lines)
|
|
393
|
+
return if lines <= 0
|
|
394
|
+
|
|
395
|
+
@output.print "\e[#{lines}B"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def start_render_thread_locked
|
|
399
|
+
return if @render_thread&.alive?
|
|
400
|
+
|
|
401
|
+
@render_stop = false
|
|
402
|
+
@render_thread = Thread.new do
|
|
403
|
+
loop do
|
|
404
|
+
should_stop = false
|
|
405
|
+
@mutex.synchronize do
|
|
406
|
+
should_stop = @render_stop
|
|
407
|
+
unless should_stop
|
|
408
|
+
render_live_locked
|
|
409
|
+
should_stop = render_done_locked?
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
break if should_stop
|
|
413
|
+
sleep(RENDER_INTERVAL)
|
|
414
|
+
end
|
|
415
|
+
rescue StandardError
|
|
416
|
+
nil
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def hide_cursor_locked
|
|
421
|
+
return if @cursor_hidden
|
|
422
|
+
|
|
423
|
+
@output.print "\e[?25l"
|
|
424
|
+
@output.flush if @output.respond_to?(:flush)
|
|
425
|
+
@cursor_hidden = true
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def show_cursor_locked
|
|
429
|
+
return unless @cursor_hidden
|
|
430
|
+
|
|
431
|
+
@output.print "\e[?25h"
|
|
432
|
+
@output.flush if @output.respond_to?(:flush)
|
|
433
|
+
@cursor_hidden = false
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def emit_setup_or_log_line(type, name)
|
|
437
|
+
label = PHASE_LABELS[type] || type.to_s
|
|
438
|
+
if SETUP_TYPES[type]
|
|
439
|
+
@output.puts "#{BOLD}#{label}#{RESET} #{name}"
|
|
440
|
+
@setup_lines_printed = true
|
|
441
|
+
else
|
|
442
|
+
@output.puts "#{GREEN}[#{@started}/#{@total.values.sum}]#{RESET} #{label} #{BOLD}#{name}#{RESET}"
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def emit_setup_gap_if_needed(type)
|
|
447
|
+
return if @setup_gap_printed
|
|
448
|
+
return unless stream_type?(type)
|
|
449
|
+
return unless @setup_lines_printed
|
|
450
|
+
|
|
451
|
+
@output.puts
|
|
452
|
+
@setup_gap_printed = true
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def emit_phase_completion_locked(type)
|
|
456
|
+
return unless stream_type?(type)
|
|
457
|
+
return if @phase_finished[type]
|
|
458
|
+
|
|
459
|
+
total = @total[type]
|
|
460
|
+
return if total.zero?
|
|
461
|
+
|
|
462
|
+
done = @completed[type] + @failed[type]
|
|
463
|
+
return if done < total
|
|
464
|
+
|
|
465
|
+
started_at = @phase_started_at[type] || Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
466
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
467
|
+
failed = @failed[type]
|
|
468
|
+
label = PHASE_SUMMARY_LABELS[type] || (PHASE_LABELS[type] || type.to_s)
|
|
469
|
+
failure_suffix = failed.positive? ? " (#{failed} failed)" : ""
|
|
470
|
+
@phase_elapsed[type] ||= elapsed
|
|
471
|
+
line = "#{DIM}#{label}#{RESET} #{done}/#{total} in #{DIM}#{format_phase_elapsed(elapsed)}#{RESET}#{failure_suffix}"
|
|
472
|
+
|
|
473
|
+
clear_live_block_locked if @interactive
|
|
474
|
+
@output.print "\r"
|
|
475
|
+
@output.puts line
|
|
476
|
+
@phase_finished[type] = true
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def mark_phase_elapsed_locked(type)
|
|
480
|
+
return unless stream_type?(type)
|
|
481
|
+
return if @phase_elapsed.key?(type)
|
|
482
|
+
|
|
483
|
+
total = @total[type]
|
|
484
|
+
return if total.zero?
|
|
485
|
+
|
|
486
|
+
done = @completed[type] + @failed[type]
|
|
487
|
+
return if done < total
|
|
488
|
+
|
|
489
|
+
started_at = @phase_started_at[type] || Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
490
|
+
@phase_elapsed[type] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def mark_completed_phase_elapsed_locked
|
|
494
|
+
PANEL_PHASE_ORDER.each do |type|
|
|
495
|
+
total = @total[type]
|
|
496
|
+
next if total.zero?
|
|
497
|
+
next unless active_stream_jobs_for(type).empty?
|
|
498
|
+
|
|
499
|
+
done = @completed[type] + @failed[type]
|
|
500
|
+
mark_phase_elapsed_locked(type) if done >= total
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def render_done_locked?
|
|
505
|
+
any_stream_activity? && !stream_active_or_pending?
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def format_phase_elapsed(seconds)
|
|
509
|
+
ms = (seconds * 1000.0).round
|
|
510
|
+
return "#{ms}ms" if ms < 1000
|
|
511
|
+
|
|
512
|
+
format("%.2fs", seconds)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def fit_line(text, max_width)
|
|
516
|
+
line = text.to_s
|
|
517
|
+
return line if visible_width(line) <= max_width
|
|
518
|
+
|
|
519
|
+
target = [max_width - 1, 1].max
|
|
520
|
+
visible = 0
|
|
521
|
+
out = +""
|
|
522
|
+
|
|
523
|
+
line.scan(/\e\[[0-9;?]*[ -\/]*[@-~]|[^\e]+/).each do |token|
|
|
524
|
+
if token.start_with?("\e[")
|
|
525
|
+
out << token
|
|
526
|
+
next
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
token.each_char do |ch|
|
|
530
|
+
break if visible >= target
|
|
531
|
+
out << ch
|
|
532
|
+
visible += 1
|
|
533
|
+
end
|
|
534
|
+
break if visible >= target
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
"#{out}…"
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def visible_width(text)
|
|
541
|
+
text.to_s.gsub(/\e\[[0-9;?]*[ -\/]*[@-~]/, "").length
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def detect_terminal_width(io)
|
|
545
|
+
from_env = ENV["COLUMNS"].to_i
|
|
546
|
+
width = from_env if from_env.positive?
|
|
547
|
+
|
|
548
|
+
if width.nil? && io.respond_to?(:winsize)
|
|
549
|
+
begin
|
|
550
|
+
winsize = io.winsize
|
|
551
|
+
width = winsize[1] if winsize && winsize[1].to_i.positive?
|
|
552
|
+
rescue StandardError
|
|
553
|
+
width = nil
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
if width.nil?
|
|
558
|
+
begin
|
|
559
|
+
require "io/console"
|
|
560
|
+
console = IO.console
|
|
561
|
+
winsize = console&.winsize
|
|
562
|
+
width = winsize[1] if winsize && winsize[1].to_i.positive?
|
|
563
|
+
rescue StandardError
|
|
564
|
+
width = nil
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
width ||= MAX_LINE_LEN
|
|
569
|
+
width = [width, MAX_LINE_LEN].min
|
|
570
|
+
[width, MIN_RENDER_WIDTH].max
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def tty_output?(io)
|
|
574
|
+
io.respond_to?(:tty?) && io.tty?
|
|
575
|
+
rescue StandardError
|
|
576
|
+
false
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
end
|