rakpak 1.0.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/LICENSE +21 -0
- data/README.md +135 -0
- data/bin/rakpak +10 -0
- data/lib/rakpak/app.rb +662 -0
- data/lib/rakpak/browser.rb +508 -0
- data/lib/rakpak/dir_cache.rb +38 -0
- data/lib/rakpak/entry.rb +78 -0
- data/lib/rakpak/formats.rb +190 -0
- data/lib/rakpak/job.rb +264 -0
- data/lib/rakpak/modal.rb +562 -0
- data/lib/rakpak/plan.rb +281 -0
- data/lib/rakpak/screen.rb +135 -0
- data/lib/rakpak/sizer.rb +114 -0
- data/lib/rakpak/term.rb +147 -0
- data/lib/rakpak/text.rb +142 -0
- data/lib/rakpak/theme.rb +32 -0
- data/lib/rakpak/version.rb +5 -0
- data/lib/rakpak.rb +100 -0
- metadata +66 -0
data/lib/rakpak/app.rb
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "term"
|
|
4
|
+
require_relative "screen"
|
|
5
|
+
require_relative "browser"
|
|
6
|
+
require_relative "modal"
|
|
7
|
+
require_relative "formats"
|
|
8
|
+
require_relative "plan"
|
|
9
|
+
require_relative "job"
|
|
10
|
+
require_relative "sizer"
|
|
11
|
+
|
|
12
|
+
module Rakpak
|
|
13
|
+
# Review panel for the tag set: the one place to see everything picked up
|
|
14
|
+
# across the filesystem, and drop entries without walking back to them.
|
|
15
|
+
class TagsModal < Modal
|
|
16
|
+
def initialize(tags:, sizer:)
|
|
17
|
+
super(title: "tagged", footer: "space/d remove · D clear all · esc back")
|
|
18
|
+
@tags = tags
|
|
19
|
+
@sizer = sizer
|
|
20
|
+
@index = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def dims(screen)
|
|
24
|
+
[[screen.w - 4, 96].min, [@tags.size + 5, screen.h - 2].min]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def body(screen, x, y, w, h)
|
|
28
|
+
if @tags.empty?
|
|
29
|
+
screen.put(x, y, "nothing tagged", Theme::MODAL_BG + Theme::FAINT)
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
@index = @index.clamp(0, @tags.size - 1)
|
|
33
|
+
top = [[@index - h + 1, @tags.size - h].min, 0].max
|
|
34
|
+
@tags[top, h].to_a.each_with_index do |path, i|
|
|
35
|
+
row = y + i
|
|
36
|
+
sel = (top + i) == @index
|
|
37
|
+
bg = sel ? Theme::CUR_BG : Theme::MODAL_BG
|
|
38
|
+
screen.fill(x - 1, row, w + 2, 1, " ", bg)
|
|
39
|
+
res = @sizer&.[](path)
|
|
40
|
+
meta = res ? "#{res.partial ? '≥' : ''}#{Text.bytes(res.bytes)}" : "…"
|
|
41
|
+
screen.put(x, row, sel ? "❯ " : " ", bg + Theme::TAG)
|
|
42
|
+
screen.put(x + 2, row, Text.fit_left(Text.tilde(path), w - Text.width(meta) - 5),
|
|
43
|
+
bg + (sel ? "\e[1;38;5;231m" : Theme::NORMAL))
|
|
44
|
+
screen.put(x + w - Text.width(meta) - 1, row, meta, bg + Theme::DIM)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def handle(key)
|
|
49
|
+
case key
|
|
50
|
+
when :up, "k" then @index -= 1
|
|
51
|
+
when :down, "j" then @index += 1
|
|
52
|
+
when :space, "d", :delete
|
|
53
|
+
@tags.delete_at(@index) if @tags.any?
|
|
54
|
+
@index = @index.clamp(0, [@tags.size - 1, 0].max)
|
|
55
|
+
when "D" then @tags.clear
|
|
56
|
+
when :esc, "q", :enter then return :cancel
|
|
57
|
+
end
|
|
58
|
+
@index = @index.clamp(0, [@tags.size - 1, 0].max)
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class App
|
|
64
|
+
SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
65
|
+
WIZARD = %i[target options where output confirm].freeze
|
|
66
|
+
|
|
67
|
+
# `pack` is a list of paths to tag before the first frame; when it is
|
|
68
|
+
# non-empty the archive prompts open immediately (rakpak -p).
|
|
69
|
+
def initialize(start_dir = Dir.home, pack: [])
|
|
70
|
+
@sizer = Sizer.new
|
|
71
|
+
@browser = Browser.new(start_dir, sizer: @sizer)
|
|
72
|
+
w, h = Term.size
|
|
73
|
+
@screen = Screen.new(w, h)
|
|
74
|
+
@modal = nil
|
|
75
|
+
@mode = :browse
|
|
76
|
+
@jobs = []
|
|
77
|
+
@reported_jobs = {}.compare_by_identity
|
|
78
|
+
@focus_job = nil
|
|
79
|
+
@quit_confirm = false
|
|
80
|
+
@tags_ref = nil
|
|
81
|
+
@notice = nil
|
|
82
|
+
@notice_until = nil
|
|
83
|
+
@plan = nil
|
|
84
|
+
@wizard_pos = nil
|
|
85
|
+
@quit = false
|
|
86
|
+
return if pack.empty?
|
|
87
|
+
|
|
88
|
+
pack.each { |p| @browser.tag(p) }
|
|
89
|
+
@browser.jump_to(pack.first)
|
|
90
|
+
start_wizard
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def run
|
|
94
|
+
Term.start
|
|
95
|
+
loop do
|
|
96
|
+
sync_size
|
|
97
|
+
draw
|
|
98
|
+
key = Term.wait_key(tick)
|
|
99
|
+
reap_jobs
|
|
100
|
+
next if key.nil?
|
|
101
|
+
|
|
102
|
+
dispatch(key)
|
|
103
|
+
break if @quit
|
|
104
|
+
end
|
|
105
|
+
ensure
|
|
106
|
+
stop_jobs
|
|
107
|
+
Term.stop
|
|
108
|
+
report
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# The quit dialog promises running jobs are stopped. Give each a moment
|
|
114
|
+
# to remove its half-written archive before the terminal is handed back.
|
|
115
|
+
def stop_jobs
|
|
116
|
+
running = @jobs.select(&:running?)
|
|
117
|
+
return if running.empty?
|
|
118
|
+
|
|
119
|
+
running.each(&:cancel)
|
|
120
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 3
|
|
121
|
+
running.each { |j| j.wait([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def tick
|
|
125
|
+
return 0.08 if @jobs.any?(&:running?)
|
|
126
|
+
return 0.4 if @browser.tags.any? { |t| @sizer[t].nil? }
|
|
127
|
+
|
|
128
|
+
1.0
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def sync_size
|
|
132
|
+
return unless Term.resized
|
|
133
|
+
|
|
134
|
+
Term.resized = false
|
|
135
|
+
w, h = Term.size
|
|
136
|
+
@screen.resize(w, h)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def notify(text, style = Theme::OK, secs = 4)
|
|
140
|
+
@notice = [text, style]
|
|
141
|
+
@notice_until = Process.clock_gettime(Process::CLOCK_MONOTONIC) + secs
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def status_line
|
|
145
|
+
if @notice && @notice_until && Process.clock_gettime(Process::CLOCK_MONOTONIC) < @notice_until
|
|
146
|
+
return @notice
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
@notice = nil
|
|
150
|
+
active = @jobs.select(&:running?)
|
|
151
|
+
return nil if active.empty?
|
|
152
|
+
|
|
153
|
+
j = active.first
|
|
154
|
+
frame = SPINNER[(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 12).to_i % SPINNER.size]
|
|
155
|
+
more = active.size > 1 ? " (+#{active.size - 1} more)" : ""
|
|
156
|
+
["#{frame} #{j.label} #{File.basename(j.plan.outputs.first)} · #{j.file_count} files · " \
|
|
157
|
+
"#{Text.bytes(j.output_size)} · #{Text.duration(j.elapsed)}#{more} [b] jobs", Theme::ACCENT]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------------- drawing
|
|
161
|
+
|
|
162
|
+
MIN_W = 40
|
|
163
|
+
MIN_H = 10
|
|
164
|
+
|
|
165
|
+
def draw
|
|
166
|
+
@screen.clear
|
|
167
|
+
if too_small?
|
|
168
|
+
draw_too_small
|
|
169
|
+
Term.flush_frame(@screen.render)
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
if @mode == :job && @focus_job
|
|
173
|
+
draw_job(@focus_job)
|
|
174
|
+
else
|
|
175
|
+
@browser.draw(@screen, status_line)
|
|
176
|
+
end
|
|
177
|
+
if @modal
|
|
178
|
+
@screen.veil
|
|
179
|
+
@modal.draw(@screen)
|
|
180
|
+
end
|
|
181
|
+
Term.flush_frame(@screen.render)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def too_small?
|
|
185
|
+
w, h = Term.size
|
|
186
|
+
w < MIN_W || h < MIN_H
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def draw_too_small
|
|
190
|
+
w, h = Term.size
|
|
191
|
+
msg = "terminal too small"
|
|
192
|
+
want = "need #{MIN_W}x#{MIN_H}, have #{w}x#{h}"
|
|
193
|
+
@screen.put([(@screen.w - Text.width(msg)) / 2, 0].max, @screen.h / 2, msg, Theme::WARN)
|
|
194
|
+
@screen.put([(@screen.w - Text.width(want)) / 2, 0].max, (@screen.h / 2) + 1, want, Theme::DIM)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def draw_job(job)
|
|
198
|
+
s = @screen
|
|
199
|
+
s.fill(0, 0, s.w, 1, " ", Theme::HEAD)
|
|
200
|
+
title = case job.state
|
|
201
|
+
when :running then "archiving"
|
|
202
|
+
when :done then "finished"
|
|
203
|
+
when :failed then "failed"
|
|
204
|
+
when :cancelled then "cancelled"
|
|
205
|
+
else "queued"
|
|
206
|
+
end
|
|
207
|
+
s.put(1, 0, "rakpak", Theme::HEAD + "\e[1;38;5;39m")
|
|
208
|
+
s.put(9, 0, title, Theme::HEAD + "\e[38;5;231m")
|
|
209
|
+
s.put(s.w - 20, 0, Text.duration(job.elapsed), Theme::HEAD + Theme::DIM)
|
|
210
|
+
|
|
211
|
+
y = 2
|
|
212
|
+
job.plan.outputs.each do |out|
|
|
213
|
+
size = begin
|
|
214
|
+
File.size(out)
|
|
215
|
+
rescue StandardError
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
s.put(2, y, "→ ", Theme::DIM)
|
|
219
|
+
s.put(4, y, Text.fit_left(out, s.w - 20), Theme::NORMAL)
|
|
220
|
+
s.put(s.w - 12, y, Text.pad(Text.bytes(size), 10), Theme::TAG)
|
|
221
|
+
y += 1
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
y += 1
|
|
225
|
+
bar_w = s.w - 8
|
|
226
|
+
frac = job.fraction
|
|
227
|
+
if job.running?
|
|
228
|
+
spin = SPINNER[(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 12).to_i % SPINNER.size]
|
|
229
|
+
s.put(2, y, spin, Theme::ACCENT)
|
|
230
|
+
else
|
|
231
|
+
s.put(2, y, job.ok? ? "✓" : "✗", job.ok? ? Theme::OK : Theme::ERR)
|
|
232
|
+
end
|
|
233
|
+
if frac
|
|
234
|
+
filled = (frac * bar_w).round.clamp(0, bar_w)
|
|
235
|
+
bar_style = if job.running? then Theme::ACCENT
|
|
236
|
+
elsif job.ok? then Theme::OK
|
|
237
|
+
else Theme::ERR
|
|
238
|
+
end
|
|
239
|
+
s.put(4, y, "█" * filled, bar_style)
|
|
240
|
+
s.put(4 + filled, y, "░" * (bar_w - filled), Theme::FAINT)
|
|
241
|
+
seen = [job.file_count, job.total_files].min
|
|
242
|
+
s.put(4, y + 1, "#{(frac * 100).round}% #{seen}/#{job.total_files} entries",
|
|
243
|
+
Theme::DIM)
|
|
244
|
+
else
|
|
245
|
+
s.put(4, y, "#{job.file_count} files", Theme::DIM)
|
|
246
|
+
end
|
|
247
|
+
y += 3
|
|
248
|
+
|
|
249
|
+
s.put(2, y, Text.fit(job.current_file.to_s, s.w - 4), Theme::DIM)
|
|
250
|
+
y += 2
|
|
251
|
+
|
|
252
|
+
s.hline(0, y, s.w, Theme::BORDER)
|
|
253
|
+
y += 1
|
|
254
|
+
rows = [s.h - y - 1, 0].max # a very short terminal leaves no room at all
|
|
255
|
+
job.tail(rows).each_with_index do |line, i|
|
|
256
|
+
style = if line.start_with?("▸") then Theme::ACCENT
|
|
257
|
+
elsif line.match?(/error|cannot|denied|warning/i) then Theme::WARN
|
|
258
|
+
else Theme::FAINT
|
|
259
|
+
end
|
|
260
|
+
s.put(1, y + i, Text.fit(line, s.w - 2), style)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
foot = if job.running?
|
|
264
|
+
[["b", "background"], ["x", "cancel"], ["q", "quit"]]
|
|
265
|
+
else
|
|
266
|
+
[["esc", "back"], ["q", "quit"]]
|
|
267
|
+
end
|
|
268
|
+
s.fill(0, s.h - 1, s.w, 1, " ", nil)
|
|
269
|
+
if job.done? && job.error
|
|
270
|
+
s.put(1, s.h - 1, Text.fit(job.error, s.w - 2), Theme::ERR)
|
|
271
|
+
else
|
|
272
|
+
x = 1
|
|
273
|
+
foot.each do |k, label|
|
|
274
|
+
x = s.put(x, s.h - 1, k, Theme::KEY)
|
|
275
|
+
x = s.put(x + 1, s.h - 1, label, Theme::DIM)
|
|
276
|
+
x += 2
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------ dispatch
|
|
282
|
+
|
|
283
|
+
def dispatch(key)
|
|
284
|
+
return modal_key(key) if @modal
|
|
285
|
+
return job_key(key) if @mode == :job
|
|
286
|
+
|
|
287
|
+
case @browser.handle(key)
|
|
288
|
+
when :quit then request_quit
|
|
289
|
+
when :archive then start_wizard
|
|
290
|
+
when :tags then open_tags
|
|
291
|
+
when :help then open_help
|
|
292
|
+
when :jobs then open_jobs
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def job_key(key)
|
|
297
|
+
job = @focus_job
|
|
298
|
+
case key
|
|
299
|
+
when "b", :esc, :backspace
|
|
300
|
+
@mode = :browse
|
|
301
|
+
notify("running in the background · press b to watch", Theme::ACCENT) if job.running?
|
|
302
|
+
when "x"
|
|
303
|
+
if job.running?
|
|
304
|
+
job.cancel
|
|
305
|
+
notify("cancelling…", Theme::WARN)
|
|
306
|
+
end
|
|
307
|
+
when :enter
|
|
308
|
+
@mode = :browse unless job.running?
|
|
309
|
+
when "q" then request_quit # the footer says quit, so it quits
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def modal_key(key)
|
|
314
|
+
# ctrl-c backs out of any panel; raw mode delivers it as a keystroke.
|
|
315
|
+
res = key == :ctrl_c ? :cancel : @modal.handle(key)
|
|
316
|
+
return if res.nil?
|
|
317
|
+
|
|
318
|
+
modal = @modal
|
|
319
|
+
@modal = nil
|
|
320
|
+
|
|
321
|
+
if modal.is_a?(TagsModal)
|
|
322
|
+
@browser.replace_tags(@tags_ref)
|
|
323
|
+
@tags_ref = nil
|
|
324
|
+
return
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
if @quit_confirm
|
|
328
|
+
@quit_confirm = false
|
|
329
|
+
@quit = true if res == :done
|
|
330
|
+
return
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
return unless @wizard_pos
|
|
334
|
+
|
|
335
|
+
res == :done ? wizard_forward(modal) : wizard_back
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# -------------------------------------------------------------- wizard
|
|
339
|
+
|
|
340
|
+
def start_wizard
|
|
341
|
+
sel = @browser.selection
|
|
342
|
+
if sel.empty?
|
|
343
|
+
@modal = MessageModal.new(title: "nothing to archive",
|
|
344
|
+
lines: ["Tag files or folders with space,",
|
|
345
|
+
"or put the cursor on one and press p."],
|
|
346
|
+
style: Theme::WARN)
|
|
347
|
+
return
|
|
348
|
+
end
|
|
349
|
+
sel.each { |p| @sizer.request(p) }
|
|
350
|
+
@plan = Plan.new(paths: sel, outdir: @browser.cwd)
|
|
351
|
+
@name_edited = false
|
|
352
|
+
@where = 0
|
|
353
|
+
@where_text = ""
|
|
354
|
+
@wizard_pos = 0
|
|
355
|
+
open_wizard_step
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# A lone file loses its extension ("notes.txt" becomes notes.tar.gz)
|
|
359
|
+
# unless the output is that file compressed on its own (notes.txt.gz).
|
|
360
|
+
# A folder keeps its name whole, dots and all. Several items take the
|
|
361
|
+
# folder name.
|
|
362
|
+
def default_basename(sel)
|
|
363
|
+
name = if sel.size > 1 then File.basename(@browser.cwd)
|
|
364
|
+
elsif File.directory?(sel.first) || @plan&.single_compress? then File.basename(sel.first)
|
|
365
|
+
else File.basename(sel.first).sub(/(?<=.)\.[^.]+\z/, "")
|
|
366
|
+
end
|
|
367
|
+
name.empty? || name == "/" ? "archive" : name
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def open_wizard_step
|
|
371
|
+
loop do
|
|
372
|
+
step = WIZARD[@wizard_pos]
|
|
373
|
+
return finish_wizard if step.nil?
|
|
374
|
+
|
|
375
|
+
modal = build_step(step)
|
|
376
|
+
if modal.nil?
|
|
377
|
+
@wizard_pos += 1
|
|
378
|
+
next
|
|
379
|
+
end
|
|
380
|
+
@modal = modal
|
|
381
|
+
return
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def wizard_forward(modal)
|
|
386
|
+
apply_step(WIZARD[@wizard_pos], modal)
|
|
387
|
+
return if @wizard_pos.nil? # a step may have ended the wizard itself
|
|
388
|
+
|
|
389
|
+
@wizard_pos += 1
|
|
390
|
+
open_wizard_step
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def wizard_back
|
|
394
|
+
loop do
|
|
395
|
+
@wizard_pos -= 1
|
|
396
|
+
if @wizard_pos.negative?
|
|
397
|
+
@wizard_pos = nil
|
|
398
|
+
@plan = nil
|
|
399
|
+
return
|
|
400
|
+
end
|
|
401
|
+
modal = build_step(WIZARD[@wizard_pos])
|
|
402
|
+
next if modal.nil?
|
|
403
|
+
|
|
404
|
+
@modal = modal
|
|
405
|
+
return
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def build_step(step)
|
|
410
|
+
case step
|
|
411
|
+
when :target then target_modal
|
|
412
|
+
when :options then options_modal
|
|
413
|
+
when :where then where_modal
|
|
414
|
+
when :output then output_modal
|
|
415
|
+
when :confirm then confirm_modal
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def apply_step(step, modal)
|
|
420
|
+
case step
|
|
421
|
+
when :target then @plan.target = modal.result
|
|
422
|
+
when :where
|
|
423
|
+
@where = modal.index
|
|
424
|
+
@where_text = modal.text
|
|
425
|
+
# ~ and $HOME are for typed text; a browsed folder is taken as is,
|
|
426
|
+
# even one with a dollar sign in its name.
|
|
427
|
+
@plan.outdir = @where == 2 ? Rakpak.expand_dir(modal.result) : modal.result
|
|
428
|
+
when :output
|
|
429
|
+
@name_edited = true
|
|
430
|
+
@plan.basename = modal.result
|
|
431
|
+
when :confirm then launch
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def target_modal
|
|
436
|
+
tar_ok = Tools.available?("tar")
|
|
437
|
+
comp_ok = @plan.tar_choices.any? { |c| c[2] }
|
|
438
|
+
zip_ok = @plan.compress_choices.any? { |c| c[2] }
|
|
439
|
+
n = @plan.paths.size
|
|
440
|
+
what = n == 1 ? File.basename(@plan.paths.first) : "#{n} items"
|
|
441
|
+
SelectModal.new(
|
|
442
|
+
title: "archive #{what}",
|
|
443
|
+
items: [
|
|
444
|
+
SelectModal::Item.new(label: "compressed tarball", value: :both, enabled: tar_ok && comp_ok,
|
|
445
|
+
why: tar_ok ? "no compressor installed" : "tar not installed",
|
|
446
|
+
blurb: ".tar.gz by default; zstd, xz and friends on offer"),
|
|
447
|
+
SelectModal::Item.new(label: "plain tarball", value: :tar, enabled: tar_ok,
|
|
448
|
+
why: "tar not installed",
|
|
449
|
+
blurb: ".tar, no compression"),
|
|
450
|
+
SelectModal::Item.new(label: "zip archive", value: :zip, enabled: zip_ok,
|
|
451
|
+
why: "no usable compressor here",
|
|
452
|
+
blurb: ".zip, or a lone file gzipped, zstd, xz")
|
|
453
|
+
],
|
|
454
|
+
index: Plan::TARGETS.index(@plan.target) || 0,
|
|
455
|
+
footer: "j/k move · enter choose · esc cancel"
|
|
456
|
+
)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def options_modal
|
|
460
|
+
case @plan.target
|
|
461
|
+
when :both
|
|
462
|
+
codec_form(@plan.tar, title: "compression", label: "compression",
|
|
463
|
+
flags_label: "tar flags")
|
|
464
|
+
when :tar
|
|
465
|
+
rows = [FormModal::Row.new(kind: :label, label: "tar flags")] + toggle_rows(@plan.tar_flags)
|
|
466
|
+
FormModal.new(title: "tarball options", rows: rows)
|
|
467
|
+
else
|
|
468
|
+
codec_form(@plan.compress, title: "compression", label: "method",
|
|
469
|
+
flags_label: "zip flags", flags_when_container: true)
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# A codec, its level, then that format's switches.
|
|
474
|
+
def codec_form(side, title:, label:, flags_label:, flags_when_container: false)
|
|
475
|
+
rows = [
|
|
476
|
+
FormModal::Row.new(kind: :choice, label: label, hint: "", values: side.choices,
|
|
477
|
+
get: -> { side.codec.id }, set: ->(id) { side.codec = id }),
|
|
478
|
+
FormModal::Row.new(kind: :number, label: "level", hint: "",
|
|
479
|
+
values: side.codec.levels || (0..0),
|
|
480
|
+
get: -> { side.level || 0 }, set: ->(v) { side.level = v })
|
|
481
|
+
]
|
|
482
|
+
extra = [FormModal::Row.new(kind: :spacer),
|
|
483
|
+
FormModal::Row.new(kind: :label, label: flags_label)] + toggle_rows(side.flags)
|
|
484
|
+
DynamicForm.new(title: title, rows: rows, extra: extra, side: side,
|
|
485
|
+
extra_when_container: flags_when_container)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def toggle_rows(flags)
|
|
489
|
+
flags.map do |f|
|
|
490
|
+
FormModal::Row.new(kind: :toggle, label: f.label, hint: f.blurb,
|
|
491
|
+
get: -> { f.on }, set: ->(v) { f.on = v })
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def where_modal
|
|
496
|
+
WhereModal.new(here: @browser.cwd, home: Dir.home, index: @where, text: @where_text,
|
|
497
|
+
validate: lambda { |text|
|
|
498
|
+
d = Rakpak.expand_dir(text)
|
|
499
|
+
if !File.directory?(d) then "not a folder: #{d}"
|
|
500
|
+
elsif !File.writable?(d) then "not writable: #{d}"
|
|
501
|
+
end
|
|
502
|
+
})
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def output_modal
|
|
506
|
+
@plan.basename = default_basename(@plan.paths) unless @name_edited
|
|
507
|
+
InputModal.new(title: "output name",
|
|
508
|
+
value: @plan.basename,
|
|
509
|
+
hint: "in #{Text.tilde(@plan.outdir)} · #{@plan.ext} added automatically",
|
|
510
|
+
validate: lambda { |name|
|
|
511
|
+
# The folder was chosen on the previous screen; this is
|
|
512
|
+
# a bare filename, so it can never reach outside it.
|
|
513
|
+
if name.include?("/") then "just a name, no slashes; the folder was picked already"
|
|
514
|
+
elsif name.include?("\0") || [".", ".."].include?(name) then "not a valid name"
|
|
515
|
+
end
|
|
516
|
+
})
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def confirm_modal
|
|
520
|
+
lines = []
|
|
521
|
+
lines << [:head, "#{@plan.paths.size} item#{'s' unless @plan.paths.size == 1} " \
|
|
522
|
+
"from #{Text.tilde(@plan.base)}"]
|
|
523
|
+
@plan.members.first(6).each { |m| lines << [:key, " #{m}"] }
|
|
524
|
+
lines << [:key, " … #{@plan.members.size - 6} more"] if @plan.members.size > 6
|
|
525
|
+
lines << [:key, ""]
|
|
526
|
+
lines << [:head, "writes #{Text.tilde(@plan.output)}"]
|
|
527
|
+
lines << [:key, ""]
|
|
528
|
+
@plan.preview.each do |label, cmd|
|
|
529
|
+
lines << [:head, "#{label}:"]
|
|
530
|
+
lines << [:cmd, " #{cmd}"]
|
|
531
|
+
end
|
|
532
|
+
lines << [:key, ""]
|
|
533
|
+
ConfirmModal.new(title: "ready", lines: lines,
|
|
534
|
+
warnings: @plan.warnings, errors: @plan.problems)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def finish_wizard
|
|
538
|
+
@wizard_pos = nil
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Always opens the job view; b from there sends it to the background.
|
|
542
|
+
def launch
|
|
543
|
+
total = @sizer.total(@plan.paths)
|
|
544
|
+
job = Job.new(@plan, total_files: total.files.positive? ? total.files : nil).start
|
|
545
|
+
@jobs << job
|
|
546
|
+
@focus_job = job
|
|
547
|
+
@wizard_pos = nil
|
|
548
|
+
@mode = :job
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def reap_jobs
|
|
552
|
+
@jobs.each do |j|
|
|
553
|
+
next if j.running? || @reported_jobs[j]
|
|
554
|
+
|
|
555
|
+
@reported_jobs[j] = true
|
|
556
|
+
@browser.refresh!
|
|
557
|
+
next if @mode == :job && @focus_job == j
|
|
558
|
+
|
|
559
|
+
case j.state
|
|
560
|
+
when :done
|
|
561
|
+
sizes = j.summary.map { |n, sz| "#{n} #{Text.bytes(sz)}" }.join(" · ")
|
|
562
|
+
notify("✓ #{sizes}", Theme::OK, 8)
|
|
563
|
+
when :failed then notify("✗ #{j.error}", Theme::ERR, 10)
|
|
564
|
+
when :cancelled then notify("cancelled", Theme::WARN)
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# --------------------------------------------------------------- panels
|
|
570
|
+
|
|
571
|
+
def open_tags
|
|
572
|
+
list = @browser.tags.to_a.sort
|
|
573
|
+
modal = TagsModal.new(tags: list, sizer: @sizer)
|
|
574
|
+
@modal = modal
|
|
575
|
+
@tags_ref = list
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def open_jobs
|
|
579
|
+
if @jobs.empty?
|
|
580
|
+
notify("no jobs yet", Theme::DIM)
|
|
581
|
+
return
|
|
582
|
+
end
|
|
583
|
+
@focus_job = @jobs.reverse.find(&:running?) || @jobs.last
|
|
584
|
+
@mode = :job
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def open_help
|
|
588
|
+
keys = [
|
|
589
|
+
["j k ↑ ↓", "move"],
|
|
590
|
+
["l → enter", "enter folder"], ["h ←", "leave folder"],
|
|
591
|
+
["g g", "top"], ["G", "bottom"], ["ctrl-d ctrl-u", "half page"],
|
|
592
|
+
["g h", "home"], ["g r", "root"], ["~", "home"],
|
|
593
|
+
["space", "tag / untag, then move down"],
|
|
594
|
+
["a", "tag everything here"], ["d", "untag everything here"],
|
|
595
|
+
["D", "clear all tags"], ["T", "review tagged items"],
|
|
596
|
+
["t", "show / hide the queue pane"],
|
|
597
|
+
["/", "filter this folder"], [".", "show hidden"],
|
|
598
|
+
["ctrl-r", "reload"],
|
|
599
|
+
["p", "pack: archive the tagged items"],
|
|
600
|
+
["b", "watch running jobs"], ["q", "quit"]
|
|
601
|
+
]
|
|
602
|
+
w = keys.map { |k, _| Text.width(k) }.max
|
|
603
|
+
lines = keys.map { |k, v| "#{Text.pad(k, w)} #{v}" }
|
|
604
|
+
@modal = MessageModal.new(title: "keys", lines: lines, style: Theme::NORMAL)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def request_quit
|
|
608
|
+
running = @jobs.count(&:running?)
|
|
609
|
+
if running.positive?
|
|
610
|
+
@modal = ConfirmModal.new(
|
|
611
|
+
title: "jobs still running",
|
|
612
|
+
lines: [[:head, "#{running} job#{'s' unless running == 1} still running."],
|
|
613
|
+
[:key, ""],
|
|
614
|
+
[:key, "Quitting stops them and leaves partial archives behind."]],
|
|
615
|
+
footer: "enter quit anyway · esc stay"
|
|
616
|
+
)
|
|
617
|
+
@quit_confirm = true
|
|
618
|
+
else
|
|
619
|
+
@quit = true
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def report
|
|
624
|
+
done = @jobs.select(&:ok?)
|
|
625
|
+
return if done.empty?
|
|
626
|
+
|
|
627
|
+
done.each do |j|
|
|
628
|
+
# Printed after the TUI has gone, straight to the shell, so a folder
|
|
629
|
+
# or file name carrying an escape sequence must be defanged.
|
|
630
|
+
j.summary.each { |name, size| puts "#{Text.plain(File.join(j.plan.outdir, name))} #{Text.bytes(size)}" }
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# The option form follows the codec the user just picked: the level row
|
|
636
|
+
# takes that codec's range, and rows that only apply to a zip container
|
|
637
|
+
# (its switches) appear only while a container method is selected.
|
|
638
|
+
class DynamicForm < FormModal
|
|
639
|
+
def initialize(title:, rows:, side:, extra: [], extra_when_container: false)
|
|
640
|
+
super(title: title, rows: rows + extra)
|
|
641
|
+
@base = rows
|
|
642
|
+
@extra = extra
|
|
643
|
+
@side = side
|
|
644
|
+
@conditional = extra_when_container
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def draw(screen)
|
|
648
|
+
sync_rows
|
|
649
|
+
super
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def sync_rows
|
|
653
|
+
codec = @side.codec
|
|
654
|
+
@rows = !@conditional || codec.container? ? @base + @extra : @base
|
|
655
|
+
@index = @index.clamp(0, [@rows.size - 1, 0].max)
|
|
656
|
+
@rows[0].hint = codec.blurb.to_s
|
|
657
|
+
level = @rows[1]
|
|
658
|
+
level.values = codec.levels || (0..0)
|
|
659
|
+
level.hint = codec.levels ? "higher = smaller, slower" : "not adjustable"
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
end
|