rakpak 1.1.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.
data/lib/rakpak/entry.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- # One filesystem row. Stats are taken once, lazily, and never raise.
5
4
  class Entry
6
5
  attr_reader :path, :name
7
6
 
@@ -64,13 +63,11 @@ module Rakpak
64
63
  Theme::NORMAL
65
64
  end
66
65
 
67
- # The name as comparable text: lowercased, with bytes that are not
68
- # valid UTF-8 replaced so downcase cannot raise on them.
66
+ # Scrub invalid UTF-8 first, or downcase raises.
69
67
  def fold
70
68
  @fold ||= (@name.valid_encoding? ? @name : @name.scrub("?")).downcase
71
69
  end
72
70
 
73
- # Dirs first, then case-insensitive natural order.
74
71
  def sort_key
75
72
  [dir? ? 0 : 1, fold, @name]
76
73
  end
@@ -1,9 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- # What this machine can actually do. Nothing here is assumed; every codec
5
- # is probed against PATH so the UI can grey out what is missing instead of
6
- # failing halfway through a job.
7
4
  module Tools
8
5
  module_function
9
6
 
@@ -27,8 +24,7 @@ module Rakpak
27
24
  @zip_bz2 = false
28
25
  end
29
26
 
30
- # GNU tar, bsdtar (macOS) and busybox tar disagree about flags. Detect
31
- # once so the UI only offers switches this tar actually understands.
27
+ # GNU tar, bsdtar (macOS) and busybox tar disagree about flags.
32
28
  def tar_flavor
33
29
  return @tar_flavor if defined?(@tar_flavor)
34
30
 
@@ -54,7 +50,6 @@ module Rakpak
54
50
 
55
51
  end
56
52
 
57
- # A tar compression backend.
58
53
  Codec = Struct.new(:id, :label, :ext, :bin, :levels, :default, :threads, :blurb,
59
54
  :level_opt, keyword_init: true) do
60
55
  def available? = bin.nil? || Tools.available?(bin)
@@ -62,10 +57,8 @@ module Rakpak
62
57
  def why_not = available? ? nil : "#{bin} not installed"
63
58
  def container? = false
64
59
 
65
- # ".tar.gz" for a tarball, ".gz" when compressing a lone file.
66
60
  def single_ext = ext.delete_prefix(".tar")
67
61
 
68
- # The compressor reading a named file and writing to stdout.
69
62
  def argv(level)
70
63
  return nil if bin.nil?
71
64
 
@@ -76,8 +69,7 @@ module Rakpak
76
69
  parts
77
70
  end
78
71
 
79
- # tar's --use-compress-program string; tar splits it on spaces itself.
80
- # The long form is understood by both GNU tar and bsdtar.
72
+ # tar splits --use-compress-program on spaces itself; GNU tar and bsdtar both accept it.
81
73
  def filter(level) = argv(level)&.join(" ")
82
74
  end
83
75
 
@@ -102,7 +94,6 @@ module Rakpak
102
94
 
103
95
  def self.tar_codec(id) = TAR_CODECS.find { |c| c.id == id }
104
96
 
105
- # A zip container written by Info-ZIP, with one of its entry methods.
106
97
  ZipMethod = Struct.new(:id, :label, :flag, :levels, :default, :blurb, keyword_init: true) do
107
98
  def available?
108
99
  flag == "bzip2" ? Tools.zip_has_bzip2? : Tools.available?("zip")
@@ -127,15 +118,11 @@ module Rakpak
127
118
  blurb: ".zip with no compression")
128
119
  ].freeze
129
120
 
130
- # Everything the zip target can produce: a .zip of anything, or a lone
131
- # file run through one compressor (notes.txt.gz).
132
121
  COMPRESSORS = (ZIP_METHODS + TAR_CODECS.reject { |c| c.id == :none }).freeze
133
122
 
134
123
  def self.compressor(id) = COMPRESSORS.find { |c| c.id == id }
135
124
 
136
- # Toggleable switches, rendered as a checklist.
137
125
  Flag = Struct.new(:id, :label, :args, :on, :blurb, :needs, keyword_init: true) do
138
- # `needs` lists the tar flavours that understand this switch.
139
126
  def supported?(flavor) = needs.nil? || needs.include?(flavor)
140
127
  end
141
128
 
@@ -143,7 +130,6 @@ module Rakpak
143
130
  BOTH = %i[gnu bsd unknown].freeze
144
131
  ALL = %i[gnu bsd busybox unknown].freeze
145
132
 
146
- # Only flags this machine's tar understands are offered.
147
133
  def self.tar_flags(flavor = Tools.tar_flavor)
148
134
  [
149
135
  Flag.new(id: :verbose, label: "verbose", args: ["-v"], on: true, needs: ALL,
data/lib/rakpak/job.rb CHANGED
@@ -3,8 +3,6 @@
3
3
  require_relative "plan"
4
4
 
5
5
  module Rakpak
6
- # Runs a plan's steps on a worker thread, streaming output into a ring
7
- # buffer. The UI polls it; backgrounding is just "stop looking at it".
8
6
  class Job
9
7
  KEEP = 400
10
8
 
@@ -24,7 +22,7 @@ module Rakpak
24
22
  @verbose = true
25
23
  @cancel = false
26
24
  @pid = nil
27
- @spawned = nil # output path of the step currently being written
25
+ @spawned = nil
28
26
  @started_at = nil
29
27
  @finished_at = nil
30
28
  end
@@ -57,8 +55,6 @@ module Rakpak
57
55
 
58
56
  def total_files = @total_files
59
57
 
60
- # nil when there is nothing to measure progress against: no file total,
61
- # or a step whose tool is not listing members (verbose off).
62
58
  def fraction
63
59
  return nil unless @total_files&.positive? && @verbose
64
60
 
@@ -67,8 +63,7 @@ module Rakpak
67
63
  [base + (per * [@file_count.to_f / @total_files, 1.0].min), 1.0].min
68
64
  end
69
65
 
70
- # Bytes on disk for whatever this step is writing right now. A folder
71
- # being extracted into has only its own inode size, which means nothing.
66
+ # A directory's own size means nothing mid-extraction.
72
67
  def output_size
73
68
  out = writing_now
74
69
  return nil if out.nil? || File.directory?(out)
@@ -78,8 +73,6 @@ module Rakpak
78
73
  nil
79
74
  end
80
75
 
81
- # A step redirected to a file is writing that file; otherwise the tool is
82
- # writing the plan's output itself.
83
76
  def writing_now
84
77
  i = [@step_index, @steps.size - 1].min
85
78
  @steps[i]&.[](3) || @plan.outputs[[i, @plan.outputs.size - 1].min]
@@ -101,7 +94,6 @@ module Rakpak
101
94
  kill_current
102
95
  end
103
96
 
104
- # Blocks until the worker is finished, for at most `secs`.
105
97
  def wait(secs = nil) = @thread&.join(secs)
106
98
 
107
99
  private
@@ -113,7 +105,7 @@ module Rakpak
113
105
  return unless pid
114
106
 
115
107
  begin
116
- Process.kill("TERM", -pid) # the whole group: tar and its compressor
108
+ Process.kill("TERM", -pid) # whole group: tar and its compressor
117
109
  rescue StandardError
118
110
  nil
119
111
  end
@@ -145,7 +137,7 @@ module Rakpak
145
137
  @step_index = idx
146
138
  @file_count = 0
147
139
  @verbose = verbose
148
- # A cancel that lands between steps must not start the next one.
140
+ # A cancel between steps must not start the next one.
149
141
  return finish(:cancelled) if @cancel
150
142
 
151
143
  push("▸ #{label}: #{Plan.show_cmd(argv, stdout)}")
@@ -166,8 +158,7 @@ module Rakpak
166
158
  end
167
159
 
168
160
  def failure_message(label, status)
169
- # status is nil when the command could not be spawned at all; in that
170
- # case run_step already recorded the useful message.
161
+ # nil status: spawn failed and run_step already set @error.
171
162
  return @error || "#{label} could not be started" if status.nil?
172
163
 
173
164
  if status.signaled?
@@ -187,14 +178,8 @@ module Rakpak
187
178
  @finished_at = now
188
179
  end
189
180
 
190
- # A half-written archive is worse than no archive: it opens, lists a few
191
- # members, then fails. Remove it, but only the output of the step that
192
- # was actually interrupted. Archives finished by earlier steps are whole
193
- # and must survive, and a step that never spawned wrote nothing.
194
- #
195
- # An extraction is the other way round: its output is a folder full of
196
- # files that were there before, or are the part of the job that did
197
- # work. Those are not ours to throw away.
181
+ # Remove only the interrupted step's half-written archive; earlier ones are whole.
182
+ # Extraction output may hold pre-existing files, so it is never removed.
198
183
  def cleanup_incomplete
199
184
  return unless @plan.clobbers_output?
200
185
 
@@ -207,15 +192,9 @@ module Rakpak
207
192
  push("could not remove #{File.basename(path)}: #{e.message}")
208
193
  end
209
194
 
210
- # `stdout` names a file the command's output is the archive for (gzip -c);
211
- # otherwise stdout joins stderr in the log.
212
195
  def run_step(argv, stdout = nil)
213
196
  out = stdout || @plan.outputs[@step_index]
214
- # Every tool asked to create an archive gets a clear path to write to,
215
- # and the confirm screen has said an existing one will be overwritten.
216
- # zip would otherwise update it in place, keeping members that no
217
- # longer exist. An extraction writes into a folder instead, which must
218
- # be left exactly as it is.
197
+ # zip would otherwise update an existing archive in place, keeping stale members.
219
198
  clear_path(out) if @plan.clobbers_output?
220
199
  @spawned = out
221
200
 
@@ -250,8 +229,7 @@ module Rakpak
250
229
  rescue EOFError
251
230
  break
252
231
  end
253
- # A pipe hands back binary. Filenames are arbitrary bytes, so this
254
- # has to be made printable before it can meet the UTF-8 frame.
232
+ # Filenames are arbitrary bytes; scrub before they meet the UTF-8 frame.
255
233
  buf << chunk.force_encoding(Encoding::UTF_8).scrub("·")
256
234
  # tar emits newlines, zip rewrites lines with \r.
257
235
  while (m = buf.match(/[\r\n]/))
@@ -269,11 +247,7 @@ module Rakpak
269
247
  status
270
248
  end
271
249
 
272
- # Never a directory: that is a destination, not something we wrote.
273
- #
274
- # lstat, not exist?: a symlink whose target is missing does not "exist",
275
- # and leaving one here would mean opening the path later and writing
276
- # through it to wherever it points. Remove the link itself.
250
+ # lstat: a dangling symlink is not exist?, and writing through it would escape the folder.
277
251
  def clear_path(path)
278
252
  return unless path
279
253
 
@@ -285,14 +259,11 @@ module Rakpak
285
259
  File.unlink(path) unless st.directory?
286
260
  end
287
261
 
288
- # The tool must never open its own output: between clearing the path and
289
- # the spawn, anything already sitting there could be a symlink out of the
290
- # folder. O_EXCL means we either create the file or refuse to write at
291
- # all, and O_NOFOLLOW refuses a link even if one appears first.
262
+ # O_EXCL|O_NOFOLLOW: never write through a symlink planted after clear_path.
292
263
  SINK_FLAGS = File::WRONLY | File::CREAT | File::EXCL |
293
264
  (defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0)
294
265
 
295
- # 0666 so the result lands on the user's umask, as a shell redirect would.
266
+ # 0666 so the result respects umask, like a shell redirect.
296
267
  def open_sink(path) = File.open(path, SINK_FLAGS, 0o666)
297
268
 
298
269
  def record(line)
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 so the cursor is visible, counting columns, not characters.
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].sub(/\S*\s*\z/, "")
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,8 +351,6 @@ 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
 
@@ -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
- # Read-only panel: the exact commands, warnings, and a go/no-go. Only
448
- # enter runs it: a single letter is too easy to hit while meaning
449
- # something else, and b in particular reads as "back".
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
- # Commands are wrapped rather than clipped: the whole point of this
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")