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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5bddb74dac931f5f6744b5a1c820c1f5452b3f938e3b3a1e928157926c40ce45
4
- data.tar.gz: b4391576a37aa27785f58ddc5ed24237f6f346b67b252d9fb4537f2a1a122406
3
+ metadata.gz: 2eb31aeb19c23cee757195d888132a931ea62030b0df07e88585c9e15fa5eea1
4
+ data.tar.gz: 9edb48577409aa19d2f1cb03115c881b2a7a1c755ccc8005d419e95428266db3
5
5
  SHA512:
6
- metadata.gz: 93794a7b3228f6b4064880ebdc637a12194c7eac375e27a5daa3b102dedbdf42fefc998ad9e8854fa22f0393e85c0e590757e1ef8ceb562655920ce4a6a721a6
7
- data.tar.gz: '049724035061aa5da75235a1424cbc3927bd24e8738c01c85a4ee60810ffff738f9976a307424086eb41a04f7a804ff1bca0d06f79a73bbd9ae71085f30feabe'
6
+ metadata.gz: d629766dd61b2c108c34e500a38e73cb465262ec36454d1e8714572f6104359cecc8712fbef2314a784d2070681a15fa821c081e39e292eb42794bdd508e0f10
7
+ data.tar.gz: cd9fae98590a0d321f061588eebece2f015a74937a58c345a46d4f6b18b1a04492c85b7e1c11a474397d9fded85173bbb4d4fe1032974f03c3c33550eb08ffc4
data/README.md CHANGED
@@ -1,10 +1,20 @@
1
- # rakpak
1
+ <p align="center">
2
+ <img src="https://res.cloudinary.com/noqoikpl/image/upload/f_auto,q_auto/rakpak-upper" alt="rakpak" width="520">
3
+ </p>
2
4
 
3
- Tag files and folders anywhere on your filesystem, then archive them all at once.
5
+ <p align="center">
6
+ <a href="https://github.com/eof0/rakpak/actions/workflows/ci.yml"><img src="https://github.com/eof0/rakpak/actions/workflows/ci.yml/badge.svg" alt="tests"></a>
7
+ <a href="https://rubygems.org/gems/rakpak"><img src="https://img.shields.io/gem/v/rakpak" alt="gem version"></a>
8
+ <a href="https://rubygems.org/gems/rakpak"><img src="https://img.shields.io/gem/dt/rakpak" alt="downloads"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/eof0/rakpak" alt="license"></a>
10
+ </p>
11
+
12
+ <p align="center">Tag files and folders anywhere on your filesystem, then archive them all at once, or unpack one you already have.</p>
4
13
 
5
14
  You walk around, press `space` on anything you want, press `p`, answer a few
6
15
  questions, and it builds the archive. The progress view can be sent to the
7
- background while you keep browsing.
16
+ background while you keep browsing. Press `u` on an archive instead and it
17
+ unpacks, wherever you want it.
8
18
 
9
19
  Needs Ruby 3.0 or newer and nothing else.
10
20
 
@@ -30,6 +40,8 @@ rakpak ~/Documents browse from there instead
30
40
  rakpak . browse from the current folder
31
41
  rakpak -p ~/Documents pack that folder: opens on the archive prompts
32
42
  rakpak -p a.txt b/ c pack several things at once
43
+ rakpak -d site.tar.gz unpack it here and now, into site/
44
+ rakpak -d a.tgz b.zip unpack several, each into its own folder
33
45
  ```
34
46
 
35
47
  ## Starting up
@@ -44,6 +56,13 @@ it, and the archive prompts appear at once, starting with the kind of archive
44
56
  and its compression. The archive is written next to that first path. Press
45
57
  `esc` on any prompt and you are back in the browser with the tags still set.
46
58
 
59
+ `-d` (or `--depack`) does not open the browser at all. Each archive is
60
+ unpacked straight into the folder you are standing in, the way `tar` would,
61
+ each one into a folder of its own name: `site.tar.gz` gives you `site/`.
62
+ A lone compressed file has nothing to wrap, so `notes.txt.gz` lands
63
+ beside you as `notes.txt`. One bad archive does not stop the rest, and the
64
+ exit status is non-zero if any of them failed.
65
+
47
66
  ## The flow
48
67
 
49
68
  1. **Browse and tag.** Vim keys or arrows. `space` tags whatever is under the
@@ -57,16 +76,39 @@ and its compression. The archive is written next to that first path. Press
57
76
  3. **Set the flags.** Compression method, level, and the switches for that
58
77
  format. Compressed tarballs default to gzip, the one every system can
59
78
  read.
60
- 4. **Say where.** This directory, your home directory, or one you type
79
+ 4. **Leave things out.** Type names separated by commas, like
80
+ `node_modules, .cache, *.log`. Each one is left out wherever it turns
81
+ up, along with everything inside it. Or pick *Tag files and directories*
82
+ to walk the folders you are packing and press `space` on the exact spots
83
+ to skip; `p` brings you back. Skipped when only files are packed.
84
+ 5. **Say where.** This directory, your home directory, or one you type
61
85
  into the field right there. `~`, `$HOME` and absolute paths all work,
62
86
  and typing anything jumps to the field.
63
- 5. **Name it.** The extension is added for you.
64
- 6. **Confirm.** You see the exact command before anything runs. Only `enter`
87
+ 6. **Name it.** The extension is added for you.
88
+ 7. **Confirm.** You see the exact command before anything runs. Only `enter`
65
89
  starts it.
66
- 7. **Watch it, or press `b`** to drop it into the background and keep browsing.
90
+ 8. **Watch it, or press `b`** to drop it into the background and keep browsing.
67
91
 
68
92
  If nothing is tagged, `p` archives whatever the cursor is on.
69
93
 
94
+ ## Unpacking
95
+
96
+ `u` on an archive opens three prompts: which folder to unpack into, what to
97
+ call the folder it makes there, and the usual confirm screen showing the
98
+ exact command.
99
+
100
+ Unlike `p`, this follows the cursor rather than the tag set: one archive, one
101
+ destination. The folder is offered as the archive's own name with the
102
+ extension taken off, so `site.tar.gz` suggests `site/`, which means nothing
103
+ is ever sprayed across the folder you are standing in. Type `.` as
104
+ the name to unpack straight into the folder you picked instead, and the
105
+ folder prompt takes `~`, `$HOME` and any absolute path, so the contents can
106
+ go anywhere. A lone compressed file skips the folder entirely.
107
+
108
+ If the destination already has files in it, the confirm screen says so before
109
+ anything runs. A failed extraction leaves whatever it managed to write; the
110
+ files that were already there are never touched.
111
+
70
112
  ## Keys
71
113
 
72
114
  | | |
@@ -78,14 +120,16 @@ If nothing is tagged, `p` archives whatever the cursor is on.
78
120
  | `ctrl-d` `ctrl-u` | half page |
79
121
  | `gh` `~` / `gr` | home / root |
80
122
  | `space` | tag or untag, then move down |
123
+ | `x` | exclude or include, then move down: left out of anything packed around it |
81
124
  | `a` / `d` | tag / untag everything in this folder |
82
- | `D` | clear all tags |
125
+ | `D` | clear all tags and exclusions |
83
126
  | `T` | review tagged items (`space` removes) |
84
127
  | `t` | show or hide the queue pane |
85
128
  | `/` | filter this folder |
86
129
  | `.` | show hidden files |
87
130
  | `ctrl-r` | reload |
88
131
  | `p` | pack: archive what is tagged |
132
+ | `u` | unpack the archive under the cursor |
89
133
  | `b` | watch a running job |
90
134
  | `?` | all keys |
91
135
  | `q` | quit |
@@ -110,9 +154,16 @@ here too, so `notes.txt` becomes `notes.txt.gz` or `notes.txt.zst` without a
110
154
  tarball around it. For a folder those are greyed out with the reason, since
111
155
  `gzip` alone cannot take a folder.
112
156
 
157
+ Every one of those can be read back: `.tar`, `.tar.gz`, `.tar.zst`, `.tar.xz`,
158
+ `.tar.bz2`, `.tar.lz4`, `.tar.br`, the `.tgz`, `.tzst`, `.txz` and `.tbz2`
159
+ short forms, `.zip` via `unzip`, and `.gz`, `.zst`, `.xz`, `.bz2`, `.lz4` or
160
+ `.br` on their own.
161
+
113
162
  Everything is probed at startup: `PATH` for the compressors, `tar --version` to
114
163
  tell GNU tar from bsdtar from busybox, and `zip -v` for its compiled-in methods.
115
164
  Anything that will not work on this machine is greyed out with the reason.
165
+ Unpacking a `.zip` wants `unzip`, which is a different program from the `zip`
166
+ used to write one, so it is checked on its own.
116
167
 
117
168
  ## How the archive is shaped
118
169
 
@@ -124,7 +175,10 @@ archive holds `a/b` and `c`.
124
175
 
125
176
  - Commands are run directly, not in a shell. Filenames containing spaces,
126
177
  quotes, `$`, `;` or a leading `-` are safe.
127
- - Cancelling kills the whole process group, so the compressor goes too.
178
+ - Cancelling kills the whole process group, so the compressor goes too. A
179
+ cancelled pack removes its half-written archive; a cancelled unpack leaves
180
+ the files it had already extracted, since deleting a folder is not
181
+ rakpak's call to make.
128
182
  - Folder sizes are counted in the background. A folder too large to finish
129
183
  counting in a few seconds shows `≥`, meaning the real size is at least that.
130
184
 
data/lib/rakpak/app.rb CHANGED
@@ -6,12 +6,11 @@ require_relative "browser"
6
6
  require_relative "modal"
7
7
  require_relative "formats"
8
8
  require_relative "plan"
9
+ require_relative "unpack"
9
10
  require_relative "job"
10
11
  require_relative "sizer"
11
12
 
12
13
  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
14
  class TagsModal < Modal
16
15
  def initialize(tags:, sizer:)
17
16
  super(title: "tagged", footer: "space/d remove · D clear all · esc back")
@@ -62,10 +61,9 @@ module Rakpak
62
61
 
63
62
  class App
64
63
  SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
65
- WIZARD = %i[target options where output confirm].freeze
64
+ WIZARD = %i[target options exclude where output confirm].freeze
65
+ UNPACK = %i[dest_where dest_name unpack_confirm].freeze
66
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
67
  def initialize(start_dir = Dir.home, pack: [])
70
68
  @sizer = Sizer.new
71
69
  @browser = Browser.new(start_dir, sizer: @sizer)
@@ -81,6 +79,7 @@ module Rakpak
81
79
  @notice = nil
82
80
  @notice_until = nil
83
81
  @plan = nil
82
+ @wizard = WIZARD
84
83
  @wizard_pos = nil
85
84
  @quit = false
86
85
  return if pack.empty?
@@ -110,8 +109,7 @@ module Rakpak
110
109
 
111
110
  private
112
111
 
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.
112
+ # Give jobs a moment to remove half-written archives before the terminal is restored.
115
113
  def stop_jobs
116
114
  running = @jobs.select(&:running?)
117
115
  return if running.empty?
@@ -157,8 +155,6 @@ module Rakpak
157
155
  "#{Text.bytes(j.output_size)} · #{Text.duration(j.elapsed)}#{more} [b] jobs", Theme::ACCENT]
158
156
  end
159
157
 
160
- # ------------------------------------------------------------- drawing
161
-
162
158
  MIN_W = 40
163
159
  MIN_H = 10
164
160
 
@@ -194,24 +190,27 @@ module Rakpak
194
190
  @screen.put([(@screen.w - Text.width(want)) / 2, 0].max, (@screen.h / 2) + 1, want, Theme::DIM)
195
191
  end
196
192
 
193
+ def job_title(job, state)
194
+ case state
195
+ when :running then job.plan.gerund
196
+ when :done then "finished"
197
+ when :failed then "failed"
198
+ when :cancelled then "cancelled"
199
+ else "queued"
200
+ end
201
+ end
202
+
197
203
  def draw_job(job)
198
204
  s = @screen
199
205
  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
206
  s.put(1, 0, "rakpak", Theme::HEAD + "\e[1;38;5;39m")
208
- s.put(9, 0, title, Theme::HEAD + "\e[38;5;231m")
207
+ s.put(9, 0, job_title(job, job.state), Theme::HEAD + "\e[38;5;231m")
209
208
  s.put(s.w - 20, 0, Text.duration(job.elapsed), Theme::HEAD + Theme::DIM)
210
209
 
211
210
  y = 2
212
211
  job.plan.outputs.each do |out|
213
212
  size = begin
214
- File.size(out)
213
+ File.directory?(out) ? nil : File.size(out)
215
214
  rescue StandardError
216
215
  nil
217
216
  end
@@ -251,7 +250,7 @@ module Rakpak
251
250
 
252
251
  s.hline(0, y, s.w, Theme::BORDER)
253
252
  y += 1
254
- rows = [s.h - y - 1, 0].max # a very short terminal leaves no room at all
253
+ rows = [s.h - y - 1, 0].max
255
254
  job.tail(rows).each_with_index do |line, i|
256
255
  style = if line.start_with?("▸") then Theme::ACCENT
257
256
  elsif line.match?(/error|cannot|denied|warning/i) then Theme::WARN
@@ -278,8 +277,6 @@ module Rakpak
278
277
  end
279
278
  end
280
279
 
281
- # ------------------------------------------------------------ dispatch
282
-
283
280
  def dispatch(key)
284
281
  return modal_key(key) if @modal
285
282
  return job_key(key) if @mode == :job
@@ -287,9 +284,12 @@ module Rakpak
287
284
  case @browser.handle(key)
288
285
  when :quit then request_quit
289
286
  when :archive then start_wizard
287
+ when :unpack then start_unpack
290
288
  when :tags then open_tags
291
289
  when :help then open_help
292
290
  when :jobs then open_jobs
291
+ when :resume then resume_wizard
292
+ when :unmarkable then notify("that one is being packed; untag it instead", Theme::WARN)
293
293
  end
294
294
  end
295
295
 
@@ -306,12 +306,12 @@ module Rakpak
306
306
  end
307
307
  when :enter
308
308
  @mode = :browse unless job.running?
309
- when "q" then request_quit # the footer says quit, so it quits
309
+ when "q" then request_quit
310
310
  end
311
311
  end
312
312
 
313
313
  def modal_key(key)
314
- # ctrl-c backs out of any panel; raw mode delivers it as a keystroke.
314
+ # Raw mode delivers ctrl-c as a keystroke.
315
315
  res = key == :ctrl_c ? :cancel : @modal.handle(key)
316
316
  return if res.nil?
317
317
 
@@ -330,13 +330,12 @@ module Rakpak
330
330
  return
331
331
  end
332
332
 
333
- return unless @wizard_pos
333
+ # While exclusions are being marked the wizard is paused; help closing must not move it.
334
+ return if @wizard_pos.nil? || @browser.scoped?
334
335
 
335
336
  res == :done ? wizard_forward(modal) : wizard_back
336
337
  end
337
338
 
338
- # -------------------------------------------------------------- wizard
339
-
340
339
  def start_wizard
341
340
  sel = @browser.selection
342
341
  if sel.empty?
@@ -349,16 +348,37 @@ module Rakpak
349
348
  sel.each { |p| @sizer.request(p) }
350
349
  @plan = Plan.new(paths: sel, outdir: @browser.cwd)
351
350
  @name_edited = false
351
+ @exclude_text = ""
352
352
  @where = 0
353
353
  @where_text = ""
354
+ @wizard = WIZARD
355
+ @wizard_pos = 0
356
+ open_wizard_step
357
+ end
358
+
359
+ def start_unpack
360
+ e = @browser.current
361
+ unless e && File.file?(e.path) && Unpack.archive?(e.path)
362
+ @modal = MessageModal.new(
363
+ title: "not an archive",
364
+ lines: ["rakpak unpacks tarballs, zips and single",
365
+ "compressed files: .tar, .tar.gz, .tar.zst,",
366
+ ".tar.xz, .tar.bz2, .tar.lz4, .tar.br, .zip,",
367
+ "and .gz, .zst, .xz, .bz2, .lz4, .br on their own."],
368
+ style: Theme::WARN
369
+ )
370
+ return
371
+ end
372
+ @plan = Unpack.new(archive: e.path, dest: @browser.cwd)
373
+ @dest_parent = @browser.cwd
374
+ @dest_name = Unpack.default_subdir(e.path) || "."
375
+ @where = 0
376
+ @where_text = ""
377
+ @wizard = UNPACK
354
378
  @wizard_pos = 0
355
379
  open_wizard_step
356
380
  end
357
381
 
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
382
  def default_basename(sel)
363
383
  name = if sel.size > 1 then File.basename(@browser.cwd)
364
384
  elsif File.directory?(sel.first) || @plan&.single_compress? then File.basename(sel.first)
@@ -369,7 +389,7 @@ module Rakpak
369
389
 
370
390
  def open_wizard_step
371
391
  loop do
372
- step = WIZARD[@wizard_pos]
392
+ step = @wizard[@wizard_pos]
373
393
  return finish_wizard if step.nil?
374
394
 
375
395
  modal = build_step(step)
@@ -383,8 +403,8 @@ module Rakpak
383
403
  end
384
404
 
385
405
  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
406
+ # A step may have ended the wizard itself, or paused it.
407
+ return if apply_step(@wizard[@wizard_pos], modal) == :paused || @wizard_pos.nil?
388
408
 
389
409
  @wizard_pos += 1
390
410
  open_wizard_step
@@ -398,7 +418,7 @@ module Rakpak
398
418
  @plan = nil
399
419
  return
400
420
  end
401
- modal = build_step(WIZARD[@wizard_pos])
421
+ modal = build_step(@wizard[@wizard_pos])
402
422
  next if modal.nil?
403
423
 
404
424
  @modal = modal
@@ -410,25 +430,42 @@ module Rakpak
410
430
  case step
411
431
  when :target then target_modal
412
432
  when :options then options_modal
433
+ when :exclude then exclude_modal
413
434
  when :where then where_modal
414
435
  when :output then output_modal
415
436
  when :confirm then confirm_modal
437
+ when :dest_where then dest_where_modal
438
+ when :dest_name then dest_name_modal
439
+ when :unpack_confirm then unpack_confirm_modal
416
440
  end
417
441
  end
418
442
 
419
443
  def apply_step(step, modal)
420
444
  case step
421
445
  when :target then @plan.target = modal.result
446
+ when :exclude
447
+ @exclude_text = modal.text
448
+ return pick_excludes if modal.result == :tag
449
+
450
+ @plan.excludes = Plan.parse_excludes(modal.text)
422
451
  when :where
423
452
  @where = modal.index
424
453
  @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.
454
+ # Only typed text expands ~ and $HOME; a browsed folder may contain a literal $.
427
455
  @plan.outdir = @where == 2 ? Rakpak.expand_dir(modal.result) : modal.result
428
456
  when :output
429
457
  @name_edited = true
430
458
  @plan.basename = modal.result
431
459
  when :confirm then launch
460
+ when :dest_where
461
+ @where = modal.index
462
+ @where_text = modal.text
463
+ @dest_parent = @where == 2 ? Rakpak.expand_dir(modal.result) : modal.result
464
+ sync_dest
465
+ when :dest_name
466
+ @dest_name = modal.result
467
+ sync_dest
468
+ when :unpack_confirm then launch
432
469
  end
433
470
  end
434
471
 
@@ -470,7 +507,6 @@ module Rakpak
470
507
  end
471
508
  end
472
509
 
473
- # A codec, its level, then that format's switches.
474
510
  def codec_form(side, title:, label:, flags_label:, flags_when_container: false)
475
511
  rows = [
476
512
  FormModal::Row.new(kind: :choice, label: label, hint: "", values: side.choices,
@@ -492,24 +528,79 @@ module Rakpak
492
528
  end
493
529
  end
494
530
 
531
+ # Nothing to exclude from inside a lone file.
532
+ def exclude_modal
533
+ return nil unless @plan.paths.any? { |p| File.directory?(p) }
534
+
535
+ @plan.excluded = @browser.excludes.to_a
536
+ ExcludeModal.new(text: @exclude_text,
537
+ marked: @plan.excluded.map { |p| @plan.relative(p) })
538
+ end
539
+
540
+ def pick_excludes
541
+ @browser.scope_to(@plan.paths, @plan.base)
542
+ :paused
543
+ end
544
+
545
+ def resume_wizard
546
+ @browser.leave_scope
547
+ open_wizard_step
548
+ end
549
+
495
550
  def where_modal
496
551
  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}"
552
+ validate: method(:writable_folder))
553
+ end
554
+
555
+ def writable_folder(text)
556
+ d = Rakpak.expand_dir(text)
557
+ if !File.directory?(d) then "not a folder: #{d}"
558
+ elsif !File.writable?(d) then "not writable: #{d}"
559
+ end
560
+ end
561
+
562
+ def dest_where_modal
563
+ WhereModal.new(here: @browser.cwd, home: Dir.home, index: @where, text: @where_text,
564
+ validate: method(:writable_folder), title: "unpack it where?")
565
+ end
566
+
567
+ def dest_name_modal
568
+ return nil if @plan.single?
569
+
570
+ InputModal.new(title: "unpack into",
571
+ value: @dest_name,
572
+ hint: "a new folder in #{Text.tilde(@dest_parent)} · . unpacks straight in",
573
+ validate: lambda { |name|
574
+ if name.include?("/") then "just a name, no slashes; the folder was picked already"
575
+ elsif name.include?("\0") || name == ".." then "not a valid name"
501
576
  end
502
577
  })
503
578
  end
504
579
 
580
+ def sync_dest
581
+ @plan.dest = @dest_name == "." ? @dest_parent : File.join(@dest_parent, @dest_name)
582
+ end
583
+
584
+ def unpack_confirm_modal
585
+ lines = [[:head, "unpacks #{Text.tilde(@plan.archive)}"],
586
+ [:key, ""],
587
+ [:head, "into #{Text.tilde(@plan.dest)}"],
588
+ [:key, ""]]
589
+ @plan.preview.each do |label, cmd|
590
+ lines << [:head, "#{label}:"]
591
+ lines << [:cmd, " #{cmd}"]
592
+ end
593
+ lines << [:key, ""]
594
+ ConfirmModal.new(title: "ready", lines: lines,
595
+ warnings: @plan.warnings, errors: @plan.problems)
596
+ end
597
+
505
598
  def output_modal
506
599
  @plan.basename = default_basename(@plan.paths) unless @name_edited
507
600
  InputModal.new(title: "output name",
508
601
  value: @plan.basename,
509
602
  hint: "in #{Text.tilde(@plan.outdir)} · #{@plan.ext} added automatically",
510
603
  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
604
  if name.include?("/") then "just a name, no slashes; the folder was picked already"
514
605
  elsif name.include?("\0") || [".", ".."].include?(name) then "not a valid name"
515
606
  end
@@ -520,9 +611,12 @@ module Rakpak
520
611
  lines = []
521
612
  lines << [:head, "#{@plan.paths.size} item#{'s' unless @plan.paths.size == 1} " \
522
613
  "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, ""]
614
+ lines.concat(listing(@plan.members))
615
+ if @plan.excludes.any? || @plan.excluded.any?
616
+ lines << [:head, "leaves out"]
617
+ lines << [:key, " #{@plan.excludes.join(', ')}"] if @plan.excludes.any?
618
+ lines.concat(listing(@plan.excluded.map { |p| @plan.relative(p) }))
619
+ end
526
620
  lines << [:head, "writes #{Text.tilde(@plan.output)}"]
527
621
  lines << [:key, ""]
528
622
  @plan.preview.each do |label, cmd|
@@ -534,14 +628,18 @@ module Rakpak
534
628
  warnings: @plan.warnings, errors: @plan.problems)
535
629
  end
536
630
 
631
+ def listing(names)
632
+ lines = names.first(6).map { |m| [:key, " #{m}"] }
633
+ lines << [:key, " … #{names.size - 6} more"] if names.size > 6
634
+ lines << [:key, ""]
635
+ end
636
+
537
637
  def finish_wizard
538
638
  @wizard_pos = nil
539
639
  end
540
640
 
541
- # Always opens the job view; b from there sends it to the background.
542
641
  def launch
543
- total = @sizer.total(@plan.paths)
544
- job = Job.new(@plan, total_files: total.files.positive? ? total.files : nil).start
642
+ job = Job.new(@plan, total_files: @plan.total_members(@sizer)).start
545
643
  @jobs << job
546
644
  @focus_job = job
547
645
  @wizard_pos = nil
@@ -557,17 +655,13 @@ module Rakpak
557
655
  next if @mode == :job && @focus_job == j
558
656
 
559
657
  case j.state
560
- when :done
561
- sizes = j.summary.map { |n, sz| "#{n} #{Text.bytes(sz)}" }.join(" · ")
562
- notify("✓ #{sizes}", Theme::OK, 8)
658
+ when :done then notify("✓ #{j.plan.outcome}", Theme::OK, 8)
563
659
  when :failed then notify("✗ #{j.error}", Theme::ERR, 10)
564
660
  when :cancelled then notify("cancelled", Theme::WARN)
565
661
  end
566
662
  end
567
663
  end
568
664
 
569
- # --------------------------------------------------------------- panels
570
-
571
665
  def open_tags
572
666
  list = @browser.tags.to_a.sort
573
667
  modal = TagsModal.new(tags: list, sizer: @sizer)
@@ -591,12 +685,14 @@ module Rakpak
591
685
  ["g g", "top"], ["G", "bottom"], ["ctrl-d ctrl-u", "half page"],
592
686
  ["g h", "home"], ["g r", "root"], ["~", "home"],
593
687
  ["space", "tag / untag, then move down"],
688
+ ["x", "exclude / include, then move down"],
594
689
  ["a", "tag everything here"], ["d", "untag everything here"],
595
- ["D", "clear all tags"], ["T", "review tagged items"],
690
+ ["D", "clear all tags and exclusions"], ["T", "review tagged items"],
596
691
  ["t", "show / hide the queue pane"],
597
692
  ["/", "filter this folder"], [".", "show hidden"],
598
693
  ["ctrl-r", "reload"],
599
694
  ["p", "pack: archive the tagged items"],
695
+ ["u", "unpack the archive under the cursor"],
600
696
  ["b", "watch running jobs"], ["q", "quit"]
601
697
  ]
602
698
  w = keys.map { |k, _| Text.width(k) }.max
@@ -625,16 +721,12 @@ module Rakpak
625
721
  return if done.empty?
626
722
 
627
723
  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)}" }
724
+ # Printed to the shell after the TUI exits, so defang escape sequences in names.
725
+ puts "#{Text.plain(j.plan.outputs.first)} #{Text.plain(j.plan.report_note)}"
631
726
  end
632
727
  end
633
728
  end
634
729
 
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
730
  class DynamicForm < FormModal
639
731
  def initialize(title:, rows:, side:, extra: [], extra_when_container: false)
640
732
  super(title: title, rows: rows + extra)