rakpak 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +63 -9
- data/lib/rakpak/app.rb +153 -61
- data/lib/rakpak/browser.rb +89 -38
- data/lib/rakpak/dir_cache.rb +1 -3
- data/lib/rakpak/entry.rb +1 -4
- data/lib/rakpak/formats.rb +2 -16
- data/lib/rakpak/job.rb +50 -28
- data/lib/rakpak/modal.rb +72 -25
- data/lib/rakpak/plan.rb +96 -46
- data/lib/rakpak/screen.rb +2 -10
- data/lib/rakpak/sizer.rb +2 -6
- data/lib/rakpak/term.rb +3 -11
- data/lib/rakpak/text.rb +3 -12
- data/lib/rakpak/theme.rb +1 -2
- data/lib/rakpak/unpack.rb +201 -0
- data/lib/rakpak/version.rb +1 -1
- data/lib/rakpak.rb +83 -14
- metadata +10 -8
data/lib/rakpak/modal.rb
CHANGED
|
@@ -4,8 +4,6 @@ require_relative "theme"
|
|
|
4
4
|
require_relative "text"
|
|
5
5
|
|
|
6
6
|
module Rakpak
|
|
7
|
-
# Base class for the centred overlay panels. Subclasses fill the body;
|
|
8
|
-
# geometry, frame, title and footer are handled here.
|
|
9
7
|
class Modal
|
|
10
8
|
attr_reader :result
|
|
11
9
|
|
|
@@ -15,7 +13,6 @@ module Rakpak
|
|
|
15
13
|
@result = nil
|
|
16
14
|
end
|
|
17
15
|
|
|
18
|
-
# Desired [width, height] given the screen.
|
|
19
16
|
def dims(screen)
|
|
20
17
|
[[screen.w - 6, 72].min, [screen.h - 4, 20].min]
|
|
21
18
|
end
|
|
@@ -41,11 +38,9 @@ module Rakpak
|
|
|
41
38
|
|
|
42
39
|
def body(screen, x, y, w, h); end
|
|
43
40
|
|
|
44
|
-
# :done, :cancel or nil
|
|
45
41
|
def handle(_key) = nil
|
|
46
42
|
end
|
|
47
43
|
|
|
48
|
-
# A vertical list of choices, some of which may be unavailable.
|
|
49
44
|
class SelectModal < Modal
|
|
50
45
|
Item = Struct.new(:label, :value, :blurb, :enabled, :why, keyword_init: true)
|
|
51
46
|
|
|
@@ -122,7 +117,6 @@ module Rakpak
|
|
|
122
117
|
end
|
|
123
118
|
end
|
|
124
119
|
|
|
125
|
-
# A form of mixed rows: cycling choices, numeric ranges and toggles.
|
|
126
120
|
class FormModal < Modal
|
|
127
121
|
Row = Struct.new(:kind, :label, :hint, :get, :set, :values, keyword_init: true)
|
|
128
122
|
|
|
@@ -214,7 +208,6 @@ module Rakpak
|
|
|
214
208
|
end
|
|
215
209
|
end
|
|
216
210
|
|
|
217
|
-
# The reason the current settings cannot be run, or nil.
|
|
218
211
|
def unusable
|
|
219
212
|
@rows.each do |row|
|
|
220
213
|
next unless row.kind == :choice
|
|
@@ -269,8 +262,6 @@ module Rakpak
|
|
|
269
262
|
end
|
|
270
263
|
end
|
|
271
264
|
|
|
272
|
-
# Single-line text field with the editing keys people expect. `validate`
|
|
273
|
-
# is given the trimmed text and returns a reason to refuse it, or nil.
|
|
274
265
|
class InputModal < Modal
|
|
275
266
|
def initialize(title:, value: "", hint: "", footer: "enter accept · esc back", validate: nil)
|
|
276
267
|
super(title: title, footer: footer)
|
|
@@ -294,11 +285,10 @@ module Rakpak
|
|
|
294
285
|
field(screen, x, y + (@hint.empty? ? 0 : 2), w, active: true)
|
|
295
286
|
end
|
|
296
287
|
|
|
297
|
-
# The text box on its own, for embedding in another panel.
|
|
298
288
|
def field(screen, x, row, w, active: true)
|
|
299
289
|
screen.fill(x, row, w, 1, " ", Theme::SEL_BG)
|
|
300
290
|
width = w - 2
|
|
301
|
-
# Scroll
|
|
291
|
+
# Scroll by display columns, not characters.
|
|
302
292
|
off = 0
|
|
303
293
|
off += 1 while off < @cur && Text.width(@buf[off...@cur]) >= width
|
|
304
294
|
shown = +""
|
|
@@ -342,7 +332,9 @@ module Rakpak
|
|
|
342
332
|
when :ctrl_u then @buf.slice!(0, @cur) && (@cur = 0)
|
|
343
333
|
when :ctrl_k then @buf.slice!(@cur..)
|
|
344
334
|
when :ctrl_w
|
|
345
|
-
left = @buf[0, @cur].
|
|
335
|
+
left = @buf[0, @cur].rstrip
|
|
336
|
+
boundary = left.rindex(/\s/)
|
|
337
|
+
left = boundary ? left[0..boundary] : ""
|
|
346
338
|
@buf = left + (@buf[@cur..] || "")
|
|
347
339
|
@cur = left.length
|
|
348
340
|
when :space then insert(" ")
|
|
@@ -359,13 +351,11 @@ module Rakpak
|
|
|
359
351
|
end
|
|
360
352
|
end
|
|
361
353
|
|
|
362
|
-
# Where the archive goes: two ready-made folders and a field for any
|
|
363
|
-
# other. Typing anything moves to the field; 1, 2 and 3 pick directly.
|
|
364
354
|
class WhereModal < Modal
|
|
365
355
|
attr_reader :index
|
|
366
356
|
|
|
367
|
-
def initialize(here:, home:, index: 0, text: "", validate: nil)
|
|
368
|
-
super(title:
|
|
357
|
+
def initialize(here:, home:, index: 0, text: "", validate: nil, title: "save it where?")
|
|
358
|
+
super(title: title, footer: "1-3 or ↑↓ pick · enter choose · esc back")
|
|
369
359
|
@choices = [["This directory", here], ["Home directory", home], ["Specify", nil]]
|
|
370
360
|
@index = index
|
|
371
361
|
@field = InputModal.new(title: "", value: text, validate: validate)
|
|
@@ -417,8 +407,6 @@ module Rakpak
|
|
|
417
407
|
nil
|
|
418
408
|
end
|
|
419
409
|
|
|
420
|
-
# Once the field is active, j, k and digits are text like anything
|
|
421
|
-
# else; arrows and tab still move between the choices.
|
|
422
410
|
def field_key(key)
|
|
423
411
|
@index = 2
|
|
424
412
|
@field.handle(key)
|
|
@@ -444,9 +432,71 @@ module Rakpak
|
|
|
444
432
|
end
|
|
445
433
|
end
|
|
446
434
|
|
|
447
|
-
#
|
|
448
|
-
|
|
449
|
-
|
|
435
|
+
# Result is the typed names, or :tag to go mark exclusions in the file view.
|
|
436
|
+
class ExcludeModal < Modal
|
|
437
|
+
def initialize(text:, marked:)
|
|
438
|
+
super(title: "exclude", footer: "↑↓ pick · enter continue · esc back")
|
|
439
|
+
@field = InputModal.new(title: "", value: text)
|
|
440
|
+
@marked = marked
|
|
441
|
+
@index = 0
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def text = @field.text
|
|
445
|
+
|
|
446
|
+
def dims(screen)
|
|
447
|
+
[[[screen.w - 6, 84].min, 44].max, [9 + [@marked.size, 1].max, screen.h - 2].min]
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def body(screen, x, y, w, h)
|
|
451
|
+
choice(screen, x, y, w, "1. Names", 0)
|
|
452
|
+
@field.field(screen, x + 12, y, w - 12, active: @index.zero?)
|
|
453
|
+
screen.put(x + 3, y + 1, Text.fit("commas between; each matches at any depth, with all it holds", w - 3),
|
|
454
|
+
Theme::MODAL_BG + Theme::DIM)
|
|
455
|
+
choice(screen, x, y + 2, w, "2. Tag files and directories", 1)
|
|
456
|
+
if @marked.empty?
|
|
457
|
+
screen.put(x, y + 4, "nothing marked", Theme::MODAL_BG + Theme::FAINT)
|
|
458
|
+
return
|
|
459
|
+
end
|
|
460
|
+
screen.put(x, y + 4, "marked", Theme::MODAL_BG + Theme::TITLE)
|
|
461
|
+
rows = h - 5
|
|
462
|
+
return if rows < 1
|
|
463
|
+
|
|
464
|
+
shown = @marked.size > rows ? @marked.first(rows - 1) : @marked
|
|
465
|
+
shown.each_with_index do |m, i|
|
|
466
|
+
screen.put(x + 3, y + 5 + i, Text.fit_left(m, w - 3), Theme::MODAL_BG + Theme::NORMAL)
|
|
467
|
+
end
|
|
468
|
+
return if shown.size == @marked.size
|
|
469
|
+
|
|
470
|
+
screen.put(x + 3, y + 5 + shown.size, "… #{@marked.size - shown.size} more", Theme::MODAL_BG + Theme::FAINT)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def choice(screen, x, y, w, label, i)
|
|
474
|
+
sel = i == @index
|
|
475
|
+
bg = sel ? Theme::CUR_BG : Theme::MODAL_BG
|
|
476
|
+
screen.fill(x - 1, y, w + 2, 1, " ", bg)
|
|
477
|
+
screen.put(x, y, label, bg + (sel ? "\e[1;38;5;231m" : Theme::NORMAL))
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def handle(key)
|
|
481
|
+
case key
|
|
482
|
+
when :esc then return :cancel
|
|
483
|
+
when :enter
|
|
484
|
+
@result = @index.zero? ? text : :tag
|
|
485
|
+
return :done
|
|
486
|
+
when :up, :down, :tab then @index = 1 - @index
|
|
487
|
+
else
|
|
488
|
+
# On row 2, j/k move; anything else is typing, so it lands in the field.
|
|
489
|
+
if @index == 1 && %w[j k].include?(key) then @index = 0
|
|
490
|
+
else
|
|
491
|
+
@index = 0
|
|
492
|
+
@field.handle(key)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
nil
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Only enter runs it: a single letter (b reads as "back") is too easy to hit by mistake.
|
|
450
500
|
class ConfirmModal < Modal
|
|
451
501
|
def initialize(title:, lines:, warnings: [], errors: [], footer: nil)
|
|
452
502
|
super(title: title,
|
|
@@ -462,8 +512,7 @@ module Rakpak
|
|
|
462
512
|
[w, [h, screen.h - 2].min]
|
|
463
513
|
end
|
|
464
514
|
|
|
465
|
-
#
|
|
466
|
-
# panel is that you can read exactly what will run.
|
|
515
|
+
# Wrap, don't clip: the user must see exactly what will run.
|
|
467
516
|
def laid_out(w)
|
|
468
517
|
@lines.flat_map do |style, text|
|
|
469
518
|
if style == :cmd
|
|
@@ -514,8 +563,6 @@ module Rakpak
|
|
|
514
563
|
end
|
|
515
564
|
end
|
|
516
565
|
|
|
517
|
-
# Transient notice panel. Scrolls when the content is taller than the
|
|
518
|
-
# terminal, which the key list usually is on a short screen.
|
|
519
566
|
class MessageModal < Modal
|
|
520
567
|
def initialize(title:, lines:, style: Theme::ERR)
|
|
521
568
|
super(title: title, footer: "any key to dismiss")
|
data/lib/rakpak/plan.rb
CHANGED
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "formats"
|
|
4
|
+
require_relative "text"
|
|
4
5
|
|
|
5
6
|
module Rakpak
|
|
6
|
-
#
|
|
7
|
-
# command. Nothing here shells out; commands are spawned without a shell,
|
|
8
|
-
# so spaces and quotes in filenames are never a hazard.
|
|
9
|
-
#
|
|
10
|
-
# Three shapes of output, one file each:
|
|
11
|
-
# :both tar, then compress name.tar.gz, name.tar.zst, ...
|
|
12
|
-
# :tar plain tar, no compression name.tar
|
|
13
|
-
# :zip compression only name.zip, or name.txt.gz for one file
|
|
7
|
+
# :both tar then compress, :tar plain tar, :zip compress only (name.zip, or name.txt.gz for one file)
|
|
14
8
|
class Plan
|
|
15
9
|
TARGETS = %i[both tar zip].freeze
|
|
16
10
|
|
|
17
|
-
attr_reader :paths
|
|
18
|
-
attr_accessor :outdir, :basename, :target
|
|
11
|
+
attr_reader :paths, :excluded
|
|
12
|
+
attr_accessor :outdir, :basename, :target, :excludes
|
|
19
13
|
attr_accessor :tar_codec, :tar_level, :tar_flags, :compressor, :comp_level, :zip_flags
|
|
20
14
|
|
|
21
15
|
def initialize(paths:, outdir:, basename: "archive", target: :both)
|
|
@@ -23,7 +17,8 @@ module Rakpak
|
|
|
23
17
|
@outdir = outdir
|
|
24
18
|
@basename = basename
|
|
25
19
|
@target = target
|
|
26
|
-
|
|
20
|
+
@excludes = []
|
|
21
|
+
@excluded = []
|
|
27
22
|
@tar_codec = TAR_CODECS.find { |c| c.id == :gzip && c.available? } || tar_codec_fallback
|
|
28
23
|
@tar_level = @tar_codec.default
|
|
29
24
|
@tar_flags = Rakpak.tar_flags
|
|
@@ -32,12 +27,8 @@ module Rakpak
|
|
|
32
27
|
@zip_flags = Rakpak.zip_flags
|
|
33
28
|
end
|
|
34
29
|
|
|
35
|
-
# Drop anything already covered by another selection: tagging ~/docs and
|
|
36
|
-
# then ~/docs/notes would otherwise store notes twice, silently.
|
|
37
30
|
def self.prune(paths)
|
|
38
|
-
# Sorting by
|
|
39
|
-
# ancestor ("a/b" before "a-x"), so one pass with a stack of accepted
|
|
40
|
-
# ancestors is enough, however many paths there are.
|
|
31
|
+
# Sorting by components puts descendants right after their ancestor ("a/b" before "a-x").
|
|
41
32
|
sorted = paths.map { |p| File.expand_path(p) }.uniq.sort_by { |p| p.split("/") }
|
|
42
33
|
kept = []
|
|
43
34
|
stack = []
|
|
@@ -55,15 +46,29 @@ module Rakpak
|
|
|
55
46
|
path.start_with?(dir == "/" ? "/" : "#{dir}/")
|
|
56
47
|
end
|
|
57
48
|
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
def self.parse_excludes(text)
|
|
50
|
+
slash = "/".ord
|
|
51
|
+
text.split(",").map do |name|
|
|
52
|
+
name = name.strip
|
|
53
|
+
finish = name.bytesize
|
|
54
|
+
finish -= 1 while finish.positive? && name.getbyte(finish - 1) == slash
|
|
55
|
+
name.byteslice(0, finish)
|
|
56
|
+
end.reject(&:empty?).uniq
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Marked paths are exact, so glob characters in them must not match.
|
|
60
|
+
def self.literal(path) = path.gsub(/[\\*?\[\]]/) { |c| "\\#{c}" }
|
|
61
|
+
|
|
62
|
+
# Only marks strictly inside a packed folder count; a packed item itself is untagged instead.
|
|
63
|
+
def excluded=(list)
|
|
64
|
+
@excluded = list.map { |p| File.expand_path(p) }.uniq.sort
|
|
65
|
+
.select { |x| @paths.any? { |p| Plan.inside?(x, p) } }
|
|
66
|
+
end
|
|
67
|
+
|
|
60
68
|
def tar_codec_fallback
|
|
61
69
|
TAR_CODECS.find { |c| c.bin && c.available? } || Rakpak.tar_codec(:none)
|
|
62
70
|
end
|
|
63
71
|
|
|
64
|
-
# One format's knobs behind a uniform face, so the option form does not
|
|
65
|
-
# need to know whether it is editing tar or compressor settings. Picking
|
|
66
|
-
# a codec resets the level to that codec's default.
|
|
67
72
|
class Side
|
|
68
73
|
attr_reader :options, :flags
|
|
69
74
|
|
|
@@ -82,7 +87,7 @@ module Rakpak
|
|
|
82
87
|
@plan.public_send("#{@level_attr}=", v)
|
|
83
88
|
end
|
|
84
89
|
|
|
85
|
-
# [label, id, enabled, why] rows
|
|
90
|
+
# [label, id, enabled, why] rows.
|
|
86
91
|
def choices = @plan.public_send(@choices)
|
|
87
92
|
|
|
88
93
|
def codec=(id)
|
|
@@ -102,13 +107,10 @@ module Rakpak
|
|
|
102
107
|
flags: @zip_flags, choices: :compress_choices)
|
|
103
108
|
end
|
|
104
109
|
|
|
105
|
-
# "both" means compressed, so "none" is not on offer there.
|
|
106
110
|
def tar_choices
|
|
107
111
|
TAR_CODECS.reject { |c| c.id == :none }.map { |c| [c.label, c.id, c.available?, c.why_not] }
|
|
108
112
|
end
|
|
109
113
|
|
|
110
|
-
# Single-file compressors only make sense for exactly one file; zip can
|
|
111
|
-
# take anything.
|
|
112
114
|
def compress_choices
|
|
113
115
|
COMPRESSORS.map do |c|
|
|
114
116
|
ok = c.available? && (c.container? || single_file?)
|
|
@@ -119,13 +121,8 @@ module Rakpak
|
|
|
119
121
|
|
|
120
122
|
def single_file? = @paths.size == 1 && File.file?(@paths.first)
|
|
121
123
|
|
|
122
|
-
# True when the output is a bare compressed file (notes.txt.gz), where
|
|
123
|
-
# the original name should be kept whole.
|
|
124
124
|
def single_compress? = @target == :zip && !@compressor.container?
|
|
125
125
|
|
|
126
|
-
# Deepest directory containing every tagged path. Members are stored
|
|
127
|
-
# relative to it, so the archive has a sane shape no matter how far
|
|
128
|
-
# apart the selections were.
|
|
129
126
|
def base
|
|
130
127
|
@base ||= begin
|
|
131
128
|
dirs = @paths.map { |p| File.dirname(p) }
|
|
@@ -141,11 +138,11 @@ module Rakpak
|
|
|
141
138
|
end
|
|
142
139
|
end
|
|
143
140
|
|
|
144
|
-
def members
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
141
|
+
def members = @paths.map { |p| relative(p) }
|
|
142
|
+
|
|
143
|
+
def relative(path)
|
|
144
|
+
rel = path.delete_prefix(base == "/" ? "/" : "#{base}/")
|
|
145
|
+
rel.empty? ? File.basename(path) : rel
|
|
149
146
|
end
|
|
150
147
|
|
|
151
148
|
def ext
|
|
@@ -159,17 +156,41 @@ module Rakpak
|
|
|
159
156
|
def output = File.join(@outdir, ensure_ext(@basename, ext))
|
|
160
157
|
def outputs = [output]
|
|
161
158
|
|
|
162
|
-
|
|
159
|
+
def prepare; end
|
|
160
|
+
def commit; end
|
|
161
|
+
def rollback; end
|
|
162
|
+
|
|
163
|
+
def gerund = "archiving"
|
|
164
|
+
|
|
165
|
+
def outcome
|
|
166
|
+
outputs.map { |o| "#{File.basename(o)} #{Text.bytes(file_size(o))}" }.join(" · ")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def report_note = Text.bytes(file_size(output))
|
|
170
|
+
|
|
171
|
+
def file_size(path)
|
|
172
|
+
File.size(path)
|
|
173
|
+
rescue StandardError
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Excluded files are inside the count, so a total would stall the bar; show the count alone.
|
|
178
|
+
def total_members(sizer)
|
|
179
|
+
return nil if @excludes.any? || @excluded.any?
|
|
180
|
+
|
|
181
|
+
files = sizer.total(@paths).files
|
|
182
|
+
files.positive? ? files : nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def clobbers_output? = true
|
|
186
|
+
|
|
163
187
|
ARCHIVE_EXTS = (TAR_CODECS.map(&:ext) + TAR_CODECS.map(&:single_ext) + %w[.tgz .tbz2 .txz .zip])
|
|
164
188
|
.reject(&:empty?).uniq.sort_by { |e| -e.length }.freeze
|
|
165
189
|
|
|
166
190
|
def ensure_ext(name, ext)
|
|
167
191
|
return name if name.downcase.end_with?(ext)
|
|
168
|
-
# backup.tar gzipped on its own is backup.tar.gz; the name is the
|
|
169
|
-
# point, so nothing is stripped from it.
|
|
170
192
|
return "#{name}#{ext}" if single_compress?
|
|
171
193
|
|
|
172
|
-
# Strip a competing archive extension the user may have typed.
|
|
173
194
|
typed = ARCHIVE_EXTS.find { |e| name.downcase.end_with?(e) }
|
|
174
195
|
stripped = typed ? name[0...-typed.length] : name
|
|
175
196
|
stripped = name if stripped.empty?
|
|
@@ -182,10 +203,36 @@ module Rakpak
|
|
|
182
203
|
argv += ["--use-compress-program", filter]
|
|
183
204
|
end
|
|
184
205
|
@tar_flags.each { |f| argv.concat(f.args) if f.on }
|
|
206
|
+
argv += tar_exclude_args
|
|
185
207
|
argv += ["-f", output, "-C", base, "--"]
|
|
186
208
|
argv + members
|
|
187
209
|
end
|
|
188
210
|
|
|
211
|
+
# Names match at any depth. Marks are anchored: GNU tar takes --anchored, bsdtar a leading ^,
|
|
212
|
+
# busybox has neither (warnings says so).
|
|
213
|
+
def tar_exclude_args
|
|
214
|
+
args = @excludes.map { |n| "--exclude=#{n}" }
|
|
215
|
+
marked = @excluded.map { |p| Plan.literal(relative(p)) }
|
|
216
|
+
return args if marked.empty?
|
|
217
|
+
|
|
218
|
+
case Tools.tar_flavor
|
|
219
|
+
when *GNU then args + ["--anchored"] + marked.map { |m| "--exclude=#{m}" }
|
|
220
|
+
when :bsd then args + marked.map { |m| "--exclude=^#{m}" }
|
|
221
|
+
else args + marked.map { |m| "--exclude=#{m}" }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# zip matches -x against whole member paths, so a name needs each depth spelled out.
|
|
226
|
+
def zip_exclude_args
|
|
227
|
+
names = @excludes.map { |n| zipsafe(n) }.flat_map { |n| [n, "#{n}/*", "*/#{n}", "*/#{n}/*"] }
|
|
228
|
+
marks = @excluded.map { |p| zipsafe(Plan.literal(relative(p))) }.flat_map { |m| [m, "#{m}/*"] }
|
|
229
|
+
pats = names + marks
|
|
230
|
+
pats.empty? ? [] : ["-x", *pats]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# zip would read a leading dash as an option; [-] matches the same name.
|
|
234
|
+
def zipsafe(pat) = pat.start_with?("-") ? "[-]#{pat[1..]}" : pat
|
|
235
|
+
|
|
189
236
|
def zip_argv
|
|
190
237
|
argv = ["zip", "-r"]
|
|
191
238
|
argv << (zip_verbose? ? "-v" : "-q")
|
|
@@ -198,11 +245,9 @@ module Rakpak
|
|
|
198
245
|
argv.concat(f.args) if f.on
|
|
199
246
|
end
|
|
200
247
|
argv << output
|
|
201
|
-
argv + members.map { |m| dashsafe(m) }
|
|
248
|
+
argv + members.map { |m| dashsafe(m) } + zip_exclude_args
|
|
202
249
|
end
|
|
203
250
|
|
|
204
|
-
# gzip and friends read one file and write to stdout; the job redirects
|
|
205
|
-
# that into the output path.
|
|
206
251
|
def single_argv
|
|
207
252
|
@compressor.argv(@comp_level) + [dashsafe(members.first)]
|
|
208
253
|
end
|
|
@@ -230,8 +275,7 @@ module Rakpak
|
|
|
230
275
|
end
|
|
231
276
|
end
|
|
232
277
|
|
|
233
|
-
# Display
|
|
234
|
-
# this is for the reader's benefit; quote only what needs it.
|
|
278
|
+
# Display only; execution never goes through a shell.
|
|
235
279
|
def self.show_arg(arg)
|
|
236
280
|
arg.match?(%r{\A[\w@%+=:,./-]+\z}) ? arg : "'#{arg.gsub("'", %q('"'"'))}'"
|
|
237
281
|
end
|
|
@@ -245,7 +289,6 @@ module Rakpak
|
|
|
245
289
|
steps.map { |(label, argv, _, stdout)| [label, Plan.show_cmd(argv, stdout)] }
|
|
246
290
|
end
|
|
247
291
|
|
|
248
|
-
# Problems worth blocking on, checked right before the run.
|
|
249
292
|
def problems
|
|
250
293
|
errs = []
|
|
251
294
|
errs << "nothing selected" if @paths.empty?
|
|
@@ -267,7 +310,6 @@ module Rakpak
|
|
|
267
310
|
errs.compact.uniq
|
|
268
311
|
end
|
|
269
312
|
|
|
270
|
-
# Non-blocking things the confirm screen should say out loud.
|
|
271
313
|
def warnings
|
|
272
314
|
warn = []
|
|
273
315
|
o = output
|
|
@@ -275,6 +317,14 @@ module Rakpak
|
|
|
275
317
|
if @paths.any? { |p| Plan.inside?(o, p) }
|
|
276
318
|
warn << "output sits inside a selected folder, so it may archive itself"
|
|
277
319
|
end
|
|
320
|
+
@paths.each do |p|
|
|
321
|
+
name = File.basename(p)
|
|
322
|
+
hit = @excludes.find { |pat| File.fnmatch?(pat, name, File::FNM_DOTMATCH) }
|
|
323
|
+
warn << "#{name} matches #{hit}, so it is left out entirely" if hit
|
|
324
|
+
end
|
|
325
|
+
if @excluded.any? && @target != :zip && Tools.tar_flavor == :busybox
|
|
326
|
+
warn << "busybox tar cannot pin a mark to one spot; same-named paths deeper in go too"
|
|
327
|
+
end
|
|
278
328
|
warn
|
|
279
329
|
end
|
|
280
330
|
end
|
data/lib/rakpak/screen.rb
CHANGED
|
@@ -4,8 +4,6 @@ require_relative "text"
|
|
|
4
4
|
require_relative "theme"
|
|
5
5
|
|
|
6
6
|
module Rakpak
|
|
7
|
-
# A character grid. Everything draws into cells, so modals overlay the
|
|
8
|
-
# browser cleanly and the whole frame ships in one write.
|
|
9
7
|
class Screen
|
|
10
8
|
attr_reader :w, :h
|
|
11
9
|
|
|
@@ -25,7 +23,6 @@ module Rakpak
|
|
|
25
23
|
@st.fill(style)
|
|
26
24
|
end
|
|
27
25
|
|
|
28
|
-
# Writes `text` at (x, y). Returns the column just past the text.
|
|
29
26
|
def put(x, y, text, style = nil)
|
|
30
27
|
return x if y.negative? || y >= @h
|
|
31
28
|
|
|
@@ -50,17 +47,14 @@ module Rakpak
|
|
|
50
47
|
cx
|
|
51
48
|
end
|
|
52
49
|
|
|
53
|
-
# A
|
|
54
|
-
# it. Writing over either half must blank the other, or the row renders
|
|
55
|
-
# one column too wide and the terminal wraps it.
|
|
50
|
+
# A wide glyph owns two cells; overwriting either half must blank the other or the row wraps.
|
|
56
51
|
def clear_halves(idx)
|
|
57
52
|
col = idx % @w
|
|
58
53
|
@ch[idx - 1] = " " if @ch[idx] == "" && col.positive? && Text.gw(@ch[idx - 1]) == 2
|
|
59
54
|
@ch[idx + 1] = " " if col < @w - 1 && @ch[idx + 1] == "" && Text.gw(@ch[idx]) == 2
|
|
60
55
|
end
|
|
61
56
|
|
|
62
|
-
#
|
|
63
|
-
# whole frame fail to concatenate and take the app down mid-render.
|
|
57
|
+
# One binary byte in @ch would break frame concatenation mid-render.
|
|
64
58
|
def printable(text)
|
|
65
59
|
str = text.to_s
|
|
66
60
|
str = str.dup.force_encoding(Encoding::UTF_8) unless str.encoding == Encoding::UTF_8
|
|
@@ -104,8 +98,6 @@ module Rakpak
|
|
|
104
98
|
end
|
|
105
99
|
end
|
|
106
100
|
|
|
107
|
-
# Flatten everything to a faint monochrome so a modal reads as the
|
|
108
|
-
# foreground layer.
|
|
109
101
|
def veil(style = Theme::FAINT)
|
|
110
102
|
@st.fill(style)
|
|
111
103
|
end
|
data/lib/rakpak/sizer.rb
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Rakpak
|
|
4
|
-
# Walks tagged directories on a worker thread so the UI can show a real
|
|
5
|
-
# byte total and file count without ever blocking on a huge tree.
|
|
6
4
|
class Sizer
|
|
7
5
|
Result = Struct.new(:bytes, :files, :partial)
|
|
8
6
|
|
|
9
|
-
BUDGET = 4.0 # seconds per path before
|
|
7
|
+
BUDGET = 4.0 # seconds per path before reporting a partial figure
|
|
10
8
|
|
|
11
9
|
def initialize
|
|
12
10
|
@cache = {}
|
|
@@ -40,7 +38,6 @@ module Rakpak
|
|
|
40
38
|
Result.new(bytes, files, known.any?(&:nil?) || known.compact.any?(&:partial))
|
|
41
39
|
end
|
|
42
40
|
|
|
43
|
-
# Drops the figure, and any measurement in flight for it.
|
|
44
41
|
def forget(path)
|
|
45
42
|
@lock.synchronize do
|
|
46
43
|
@cache.delete(path)
|
|
@@ -60,8 +57,7 @@ module Rakpak
|
|
|
60
57
|
def work(path, token)
|
|
61
58
|
res = measure(path)
|
|
62
59
|
@lock.synchronize do
|
|
63
|
-
#
|
|
64
|
-
# only the walk that the current request started may answer it.
|
|
60
|
+
# Only the walk the current request started may answer it.
|
|
65
61
|
next unless @pending[path].equal?(token)
|
|
66
62
|
|
|
67
63
|
@pending.delete(path)
|
data/lib/rakpak/term.rb
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require "io/console"
|
|
4
4
|
|
|
5
5
|
module Rakpak
|
|
6
|
-
# Raw-mode terminal control and key decoding.
|
|
7
6
|
module Term
|
|
8
7
|
ARROWS = { "A" => :up, "B" => :down, "C" => :right, "D" => :left,
|
|
9
8
|
"H" => :home, "F" => :end }.freeze
|
|
@@ -52,8 +51,7 @@ module Rakpak
|
|
|
52
51
|
$stdout.flush
|
|
53
52
|
end
|
|
54
53
|
|
|
55
|
-
#
|
|
56
|
-
# String, or nil on timeout.
|
|
54
|
+
# Returns a key symbol, a printable String, or nil on timeout.
|
|
57
55
|
def wait_key(timeout = nil)
|
|
58
56
|
return nil unless IO.select([$stdin], nil, nil, timeout)
|
|
59
57
|
|
|
@@ -78,10 +76,7 @@ module Rakpak
|
|
|
78
76
|
end
|
|
79
77
|
end
|
|
80
78
|
|
|
81
|
-
#
|
|
82
|
-
# stray byte arrives as a one-byte invalid string. Gather the rest of
|
|
83
|
-
# the sequence when there is one; if the result is still not valid
|
|
84
|
-
# text, the key is dropped rather than raised on later.
|
|
79
|
+
# C locale getc yields single bytes; gather the sequence and drop it if still invalid.
|
|
85
80
|
def complete_utf8(c)
|
|
86
81
|
s = c.dup.force_encoding(Encoding::UTF_8)
|
|
87
82
|
return s if s.valid_encoding?
|
|
@@ -109,9 +104,7 @@ module Rakpak
|
|
|
109
104
|
nil
|
|
110
105
|
end
|
|
111
106
|
|
|
112
|
-
#
|
|
113
|
-
# split across reads, so allow a generous window before concluding the
|
|
114
|
-
# user pressed a bare Esc. The delay is only ever paid on a real Esc.
|
|
107
|
+
# Escape sequences can split across reads on slow links; only a real Esc pays this wait.
|
|
115
108
|
ESC_WINDOW = 0.05
|
|
116
109
|
|
|
117
110
|
def read_escape
|
|
@@ -139,7 +132,6 @@ module Rakpak
|
|
|
139
132
|
:esc
|
|
140
133
|
end
|
|
141
134
|
else
|
|
142
|
-
# Alt-<char>: the whole sequence is the character.
|
|
143
135
|
:"alt_#{seq}"
|
|
144
136
|
end
|
|
145
137
|
end
|
data/lib/rakpak/text.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Rakpak
|
|
4
|
-
# Grapheme-aware width math. Just enough Unicode to keep columns honest.
|
|
5
4
|
module Text
|
|
6
5
|
WIDE = [
|
|
7
6
|
0x1100..0x115F, 0x2E80..0x303E, 0x3041..0x33FF, 0x3400..0x4DBF,
|
|
@@ -14,12 +13,10 @@ module Rakpak
|
|
|
14
13
|
|
|
15
14
|
module_function
|
|
16
15
|
|
|
17
|
-
# Display width of a single grapheme cluster.
|
|
18
16
|
def gw(cluster)
|
|
19
17
|
cp = cluster.ord
|
|
20
|
-
return 1 if cp >= 0x20 && cp < 0x7F
|
|
21
|
-
# C0, DEL and
|
|
22
|
-
# CSI, so these must never reach the frame.
|
|
18
|
+
return 1 if cp >= 0x20 && cp < 0x7F
|
|
19
|
+
# C0, DEL and C1: a UTF-8 terminal executes U+009B as CSI.
|
|
23
20
|
return 0 if cp < 0x20 || cp == 0x7F || (cp >= 0x80 && cp <= 0x9F)
|
|
24
21
|
return 0 if ZERO.any? { |r| r.cover?(cp) }
|
|
25
22
|
return 2 if WIDE.any? { |r| r.cover?(cp) }
|
|
@@ -35,7 +32,6 @@ module Rakpak
|
|
|
35
32
|
w
|
|
36
33
|
end
|
|
37
34
|
|
|
38
|
-
# Truncate to `max` columns, appending an ellipsis when something was cut.
|
|
39
35
|
def fit(str, max)
|
|
40
36
|
return "" if max <= 0
|
|
41
37
|
return str if width(str) <= max
|
|
@@ -52,7 +48,6 @@ module Rakpak
|
|
|
52
48
|
out << "…"
|
|
53
49
|
end
|
|
54
50
|
|
|
55
|
-
# Keep the tail of a path visible instead of the head.
|
|
56
51
|
def fit_left(str, max)
|
|
57
52
|
return "" if max <= 0
|
|
58
53
|
return str if width(str) <= max
|
|
@@ -75,7 +70,6 @@ module Rakpak
|
|
|
75
70
|
w >= max ? str : str + (" " * (max - w))
|
|
76
71
|
end
|
|
77
72
|
|
|
78
|
-
# Greedy wrap on spaces, with a hanging indent for continuations.
|
|
79
73
|
def wrap(str, max, indent = 0)
|
|
80
74
|
return [str] if max <= indent + 4 || width(str) <= max
|
|
81
75
|
|
|
@@ -84,7 +78,6 @@ module Rakpak
|
|
|
84
78
|
out = []
|
|
85
79
|
line = +""
|
|
86
80
|
words = str[lead.length..].to_s.split(" ").flat_map do |word|
|
|
87
|
-
# A path with no spaces can still be wider than the panel; break it.
|
|
88
81
|
limit = max - indent
|
|
89
82
|
next word if width(word) <= limit || limit < 8
|
|
90
83
|
|
|
@@ -103,7 +96,6 @@ module Rakpak
|
|
|
103
96
|
out
|
|
104
97
|
end
|
|
105
98
|
|
|
106
|
-
# /home/me/x → ~/x. Only a real prefix counts: /home/me2 is left alone.
|
|
107
99
|
def tilde(path)
|
|
108
100
|
home = Dir.home
|
|
109
101
|
return path if home.nil? || home.empty? || home == "/"
|
|
@@ -112,8 +104,7 @@ module Rakpak
|
|
|
112
104
|
path.start_with?("#{home}/") ? "~#{path[home.length..]}" : path
|
|
113
105
|
end
|
|
114
106
|
|
|
115
|
-
#
|
|
116
|
-
# control and C1 bytes could otherwise be executed as escape sequences.
|
|
107
|
+
# Control and C1 bytes could be executed as escape sequences.
|
|
117
108
|
def plain(str)
|
|
118
109
|
str.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?").gsub(/[\u0000-\u001f\u007f-\u009f]/, "?")
|
|
119
110
|
end
|
data/lib/rakpak/theme.rb
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Rakpak
|
|
4
|
-
#
|
|
5
|
-
# concatenation without needing to be self-contained.
|
|
4
|
+
# Every style is emitted after a reset, so fragments needn't be self-contained.
|
|
6
5
|
module Theme
|
|
7
6
|
RESET = "\e[0m"
|
|
8
7
|
|