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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2eb31aeb19c23cee757195d888132a931ea62030b0df07e88585c9e15fa5eea1
|
|
4
|
+
data.tar.gz: 9edb48577409aa19d2f1cb03115c881b2a7a1c755ccc8005d419e95428266db3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d629766dd61b2c108c34e500a38e73cb465262ec36454d1e8714572f6104359cecc8712fbef2314a784d2070681a15fa821c081e39e292eb42794bdd508e0f10
|
|
7
|
+
data.tar.gz: cd9fae98590a0d321f061588eebece2f015a74937a58c345a46d4f6b18b1a04492c85b7e1c11a474397d9fded85173bbb4d4fe1032974f03c3c33550eb08ffc4
|
data/README.md
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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. **
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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,
|
|
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
|
|
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
|
|
309
|
+
when "q" then request_quit
|
|
310
310
|
end
|
|
311
311
|
end
|
|
312
312
|
|
|
313
313
|
def modal_key(key)
|
|
314
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
387
|
-
return if @wizard_pos
|
|
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(
|
|
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
|
|
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:
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
|
629
|
-
#
|
|
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)
|