rakpak 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/bin/rakpak +10 -0
- data/lib/rakpak/app.rb +662 -0
- data/lib/rakpak/browser.rb +508 -0
- data/lib/rakpak/dir_cache.rb +38 -0
- data/lib/rakpak/entry.rb +78 -0
- data/lib/rakpak/formats.rb +190 -0
- data/lib/rakpak/job.rb +264 -0
- data/lib/rakpak/modal.rb +562 -0
- data/lib/rakpak/plan.rb +281 -0
- data/lib/rakpak/screen.rb +135 -0
- data/lib/rakpak/sizer.rb +114 -0
- data/lib/rakpak/term.rb +147 -0
- data/lib/rakpak/text.rb +142 -0
- data/lib/rakpak/theme.rb +32 -0
- data/lib/rakpak/version.rb +5 -0
- data/lib/rakpak.rb +100 -0
- metadata +66 -0
data/lib/rakpak/plan.rb
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "formats"
|
|
4
|
+
|
|
5
|
+
module Rakpak
|
|
6
|
+
# Turns a set of tagged paths plus the wizard's answers into one concrete
|
|
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
|
|
14
|
+
class Plan
|
|
15
|
+
TARGETS = %i[both tar zip].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :paths
|
|
18
|
+
attr_accessor :outdir, :basename, :target
|
|
19
|
+
attr_accessor :tar_codec, :tar_level, :tar_flags, :compressor, :comp_level, :zip_flags
|
|
20
|
+
|
|
21
|
+
def initialize(paths:, outdir:, basename: "archive", target: :both)
|
|
22
|
+
@paths = self.class.prune(paths)
|
|
23
|
+
@outdir = outdir
|
|
24
|
+
@basename = basename
|
|
25
|
+
@target = target
|
|
26
|
+
# gzip is the one every system can read; anything else is opt-in.
|
|
27
|
+
@tar_codec = TAR_CODECS.find { |c| c.id == :gzip && c.available? } || tar_codec_fallback
|
|
28
|
+
@tar_level = @tar_codec.default
|
|
29
|
+
@tar_flags = Rakpak.tar_flags
|
|
30
|
+
@compressor = COMPRESSORS.find { |c| c.container? && c.available? } || COMPRESSORS.first
|
|
31
|
+
@comp_level = @compressor.default
|
|
32
|
+
@zip_flags = Rakpak.zip_flags
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Drop anything already covered by another selection: tagging ~/docs and
|
|
36
|
+
# then ~/docs/notes would otherwise store notes twice, silently.
|
|
37
|
+
def self.prune(paths)
|
|
38
|
+
# Sorting by path components puts every descendant right after its
|
|
39
|
+
# ancestor ("a/b" before "a-x"), so one pass with a stack of accepted
|
|
40
|
+
# ancestors is enough, however many paths there are.
|
|
41
|
+
sorted = paths.map { |p| File.expand_path(p) }.uniq.sort_by { |p| p.split("/") }
|
|
42
|
+
kept = []
|
|
43
|
+
stack = []
|
|
44
|
+
sorted.each do |p|
|
|
45
|
+
stack.pop while stack.any? && !inside?(p, stack.last)
|
|
46
|
+
next if stack.any?
|
|
47
|
+
|
|
48
|
+
kept << p
|
|
49
|
+
stack << p
|
|
50
|
+
end
|
|
51
|
+
kept
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.inside?(path, dir)
|
|
55
|
+
path.start_with?(dir == "/" ? "/" : "#{dir}/")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# "none" needs no tool so it is always available; it is the last
|
|
59
|
+
# resort, not the first pick, when gzip is missing.
|
|
60
|
+
def tar_codec_fallback
|
|
61
|
+
TAR_CODECS.find { |c| c.bin && c.available? } || Rakpak.tar_codec(:none)
|
|
62
|
+
end
|
|
63
|
+
|
|
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
|
+
class Side
|
|
68
|
+
attr_reader :options, :flags
|
|
69
|
+
|
|
70
|
+
def initialize(plan, options:, codec:, level:, flags:, choices:)
|
|
71
|
+
@plan = plan
|
|
72
|
+
@options = options
|
|
73
|
+
@codec_attr = codec
|
|
74
|
+
@level_attr = level
|
|
75
|
+
@flags = flags
|
|
76
|
+
@choices = choices
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def codec = @plan.public_send(@codec_attr)
|
|
80
|
+
def level = @plan.public_send(@level_attr)
|
|
81
|
+
def level=(v)
|
|
82
|
+
@plan.public_send("#{@level_attr}=", v)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# [label, id, enabled, why] rows for the form's choice list.
|
|
86
|
+
def choices = @plan.public_send(@choices)
|
|
87
|
+
|
|
88
|
+
def codec=(id)
|
|
89
|
+
c = @options.find { |o| o.id == id } or raise ArgumentError, "unknown codec #{id}"
|
|
90
|
+
@plan.public_send("#{@codec_attr}=", c)
|
|
91
|
+
self.level = c.default
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def tar
|
|
96
|
+
Side.new(self, options: TAR_CODECS, codec: :tar_codec, level: :tar_level,
|
|
97
|
+
flags: @tar_flags, choices: :tar_choices)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def compress
|
|
101
|
+
Side.new(self, options: COMPRESSORS, codec: :compressor, level: :comp_level,
|
|
102
|
+
flags: @zip_flags, choices: :compress_choices)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# "both" means compressed, so "none" is not on offer there.
|
|
106
|
+
def tar_choices
|
|
107
|
+
TAR_CODECS.reject { |c| c.id == :none }.map { |c| [c.label, c.id, c.available?, c.why_not] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Single-file compressors only make sense for exactly one file; zip can
|
|
111
|
+
# take anything.
|
|
112
|
+
def compress_choices
|
|
113
|
+
COMPRESSORS.map do |c|
|
|
114
|
+
ok = c.available? && (c.container? || single_file?)
|
|
115
|
+
why = c.why_not || "compresses one file only; choose both for folders"
|
|
116
|
+
[c.label, c.id, ok, ok ? nil : why]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def single_file? = @paths.size == 1 && File.file?(@paths.first)
|
|
121
|
+
|
|
122
|
+
# True when the output is a bare compressed file (notes.txt.gz), where
|
|
123
|
+
# the original name should be kept whole.
|
|
124
|
+
def single_compress? = @target == :zip && !@compressor.container?
|
|
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
|
+
def base
|
|
130
|
+
@base ||= begin
|
|
131
|
+
dirs = @paths.map { |p| File.dirname(p) }
|
|
132
|
+
common = dirs.first.to_s.split("/")
|
|
133
|
+
dirs.each do |d|
|
|
134
|
+
parts = d.split("/")
|
|
135
|
+
i = 0
|
|
136
|
+
i += 1 while i < common.size && i < parts.size && common[i] == parts[i]
|
|
137
|
+
common = common[0...i]
|
|
138
|
+
end
|
|
139
|
+
c = common.join("/")
|
|
140
|
+
c.empty? ? "/" : c
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def members
|
|
145
|
+
@paths.map do |p|
|
|
146
|
+
rel = p.delete_prefix(base == "/" ? "/" : "#{base}/")
|
|
147
|
+
rel.empty? ? File.basename(p) : rel
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def ext
|
|
152
|
+
case @target
|
|
153
|
+
when :both then @tar_codec.ext
|
|
154
|
+
when :tar then ".tar"
|
|
155
|
+
else @compressor.container? ? ".zip" : @compressor.single_ext
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def output = File.join(@outdir, ensure_ext(@basename, ext))
|
|
160
|
+
def outputs = [output]
|
|
161
|
+
|
|
162
|
+
# Extensions a user might type that we would otherwise double up.
|
|
163
|
+
ARCHIVE_EXTS = (TAR_CODECS.map(&:ext) + TAR_CODECS.map(&:single_ext) + %w[.tgz .tbz2 .txz .zip])
|
|
164
|
+
.reject(&:empty?).uniq.sort_by { |e| -e.length }.freeze
|
|
165
|
+
|
|
166
|
+
def ensure_ext(name, ext)
|
|
167
|
+
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
|
+
return "#{name}#{ext}" if single_compress?
|
|
171
|
+
|
|
172
|
+
# Strip a competing archive extension the user may have typed.
|
|
173
|
+
typed = ARCHIVE_EXTS.find { |e| name.downcase.end_with?(e) }
|
|
174
|
+
stripped = typed ? name[0...-typed.length] : name
|
|
175
|
+
stripped = name if stripped.empty?
|
|
176
|
+
"#{stripped}#{ext}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def tar_argv
|
|
180
|
+
argv = ["tar", "-c"]
|
|
181
|
+
if @target == :both && (filter = @tar_codec.filter(@tar_level))
|
|
182
|
+
argv += ["--use-compress-program", filter]
|
|
183
|
+
end
|
|
184
|
+
@tar_flags.each { |f| argv.concat(f.args) if f.on }
|
|
185
|
+
argv += ["-f", output, "-C", base, "--"]
|
|
186
|
+
argv + members
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def zip_argv
|
|
190
|
+
argv = ["zip", "-r"]
|
|
191
|
+
argv << (zip_verbose? ? "-v" : "-q")
|
|
192
|
+
argv << "-Z" << @compressor.flag if @compressor.flag != "deflate"
|
|
193
|
+
argv << "-#{@comp_level.clamp(0, 9)}" if @compressor.levels && @comp_level
|
|
194
|
+
argv << "-D" unless flag_on?(@zip_flags, :dirs)
|
|
195
|
+
@zip_flags.each do |f|
|
|
196
|
+
next if %i[verbose dirs].include?(f.id)
|
|
197
|
+
|
|
198
|
+
argv.concat(f.args) if f.on
|
|
199
|
+
end
|
|
200
|
+
argv << output
|
|
201
|
+
argv + members.map { |m| dashsafe(m) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# gzip and friends read one file and write to stdout; the job redirects
|
|
205
|
+
# that into the output path.
|
|
206
|
+
def single_argv
|
|
207
|
+
@compressor.argv(@comp_level) + [dashsafe(members.first)]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def dashsafe(name) = name.start_with?("-") ? "./#{name}" : name
|
|
211
|
+
|
|
212
|
+
def zip_verbose? = flag_on?(@zip_flags, :verbose)
|
|
213
|
+
def tar_verbose? = flag_on?(@tar_flags, :verbose)
|
|
214
|
+
|
|
215
|
+
def flag_on?(list, id)
|
|
216
|
+
f = list.find { |x| x.id == id }
|
|
217
|
+
f ? f.on : false
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# [label, argv, expects_verbose_output, stdout_path]
|
|
221
|
+
def steps
|
|
222
|
+
case @target
|
|
223
|
+
when :both, :tar then [["tar", tar_argv, tar_verbose?, nil]]
|
|
224
|
+
else
|
|
225
|
+
if @compressor.container?
|
|
226
|
+
[["zip", zip_argv, zip_verbose?, nil]]
|
|
227
|
+
else
|
|
228
|
+
[[@compressor.label, single_argv, false, output]]
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Display form of the command. Execution never goes through a shell, so
|
|
234
|
+
# this is for the reader's benefit; quote only what needs it.
|
|
235
|
+
def self.show_arg(arg)
|
|
236
|
+
arg.match?(%r{\A[\w@%+=:,./-]+\z}) ? arg : "'#{arg.gsub("'", %q('"'"'))}'"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def self.show_cmd(argv, stdout = nil)
|
|
240
|
+
cmd = argv.map { |a| show_arg(a) }.join(" ")
|
|
241
|
+
stdout ? "#{cmd} > #{show_arg(stdout)}" : cmd
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def preview
|
|
245
|
+
steps.map { |(label, argv, _, stdout)| [label, Plan.show_cmd(argv, stdout)] }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Problems worth blocking on, checked right before the run.
|
|
249
|
+
def problems
|
|
250
|
+
errs = []
|
|
251
|
+
errs << "nothing selected" if @paths.empty?
|
|
252
|
+
errs << "destination is not a folder: #{@outdir}" unless File.directory?(@outdir)
|
|
253
|
+
errs << "destination is not writable: #{@outdir}" if File.directory?(@outdir) && !File.writable?(@outdir)
|
|
254
|
+
case @target
|
|
255
|
+
when :both
|
|
256
|
+
errs << "tar is not installed" unless Tools.available?("tar")
|
|
257
|
+
errs << @tar_codec.why_not if @tar_codec.why_not
|
|
258
|
+
errs << "no compressor installed; choose tarball" if @tar_codec.id == :none
|
|
259
|
+
when :tar
|
|
260
|
+
errs << "tar is not installed" unless Tools.available?("tar")
|
|
261
|
+
else
|
|
262
|
+
errs << @compressor.why_not if @compressor.why_not
|
|
263
|
+
if !@compressor.container? && !single_file?
|
|
264
|
+
errs << "#{@compressor.label} compresses one file only; choose both for folders"
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
errs.compact.uniq
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Non-blocking things the confirm screen should say out loud.
|
|
271
|
+
def warnings
|
|
272
|
+
warn = []
|
|
273
|
+
o = output
|
|
274
|
+
warn << "#{File.basename(o)} already exists and will be replaced" if File.exist?(o)
|
|
275
|
+
if @paths.any? { |p| Plan.inside?(o, p) }
|
|
276
|
+
warn << "output sits inside a selected folder, so it may archive itself"
|
|
277
|
+
end
|
|
278
|
+
warn
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "text"
|
|
4
|
+
require_relative "theme"
|
|
5
|
+
|
|
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
|
+
class Screen
|
|
10
|
+
attr_reader :w, :h
|
|
11
|
+
|
|
12
|
+
def initialize(w, h)
|
|
13
|
+
resize(w, h)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resize(w, h)
|
|
17
|
+
@w = [w, 20].max
|
|
18
|
+
@h = [h, 6].max
|
|
19
|
+
@ch = Array.new(@w * @h, " ")
|
|
20
|
+
@st = Array.new(@w * @h, nil)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def clear(style = nil)
|
|
24
|
+
@ch.fill(" ")
|
|
25
|
+
@st.fill(style)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Writes `text` at (x, y). Returns the column just past the text.
|
|
29
|
+
def put(x, y, text, style = nil)
|
|
30
|
+
return x if y.negative? || y >= @h
|
|
31
|
+
|
|
32
|
+
row = y * @w
|
|
33
|
+
cx = x
|
|
34
|
+
printable(text).each_grapheme_cluster do |g|
|
|
35
|
+
cw = Text.gw(g)
|
|
36
|
+
break if cx >= @w
|
|
37
|
+
|
|
38
|
+
if cx >= 0 && cw.positive?
|
|
39
|
+
clear_halves(row + cx)
|
|
40
|
+
clear_halves(row + cx + 1) if cw == 2 && cx + 1 < @w
|
|
41
|
+
@ch[row + cx] = g
|
|
42
|
+
@st[row + cx] = style
|
|
43
|
+
if cw == 2 && cx + 1 < @w
|
|
44
|
+
@ch[row + cx + 1] = ""
|
|
45
|
+
@st[row + cx + 1] = style
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
cx += cw
|
|
49
|
+
end
|
|
50
|
+
cx
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A double-width glyph owns two cells: itself and an empty marker after
|
|
54
|
+
# it. Writing over either half must blank the other, or the row renders
|
|
55
|
+
# one column too wide and the terminal wraps it.
|
|
56
|
+
def clear_halves(idx)
|
|
57
|
+
col = idx % @w
|
|
58
|
+
@ch[idx - 1] = " " if @ch[idx] == "" && col.positive? && Text.gw(@ch[idx - 1]) == 2
|
|
59
|
+
@ch[idx + 1] = " " if col < @w - 1 && @ch[idx + 1] == "" && Text.gw(@ch[idx]) == 2
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Last line of defence: a single binary byte reaching @ch would make the
|
|
63
|
+
# whole frame fail to concatenate and take the app down mid-render.
|
|
64
|
+
def printable(text)
|
|
65
|
+
str = text.to_s
|
|
66
|
+
str = str.dup.force_encoding(Encoding::UTF_8) unless str.encoding == Encoding::UTF_8
|
|
67
|
+
str.valid_encoding? ? str : str.scrub("·")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fill(x, y, w, h, char = " ", style = nil)
|
|
71
|
+
h.times do |dy|
|
|
72
|
+
yy = y + dy
|
|
73
|
+
next if yy.negative? || yy >= @h
|
|
74
|
+
|
|
75
|
+
row = yy * @w
|
|
76
|
+
w.times do |dx|
|
|
77
|
+
xx = x + dx
|
|
78
|
+
next if xx.negative? || xx >= @w
|
|
79
|
+
|
|
80
|
+
clear_halves(row + xx)
|
|
81
|
+
@ch[row + xx] = char
|
|
82
|
+
@st[row + xx] = style
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def hline(x, y, w, style = nil, char = "─")
|
|
88
|
+
fill(x, y, w, 1, char, style)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def vline(x, y, h, style = nil, char = "│")
|
|
92
|
+
fill(x, y, 1, h, char, style)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def box(x, y, w, h, style = nil, fill_style = nil)
|
|
96
|
+
return if w < 2 || h < 2
|
|
97
|
+
|
|
98
|
+
fill(x, y, w, h, " ", fill_style) if fill_style
|
|
99
|
+
put(x, y, "╭#{'─' * (w - 2)}╮", style)
|
|
100
|
+
put(x, y + h - 1, "╰#{'─' * (w - 2)}╯", style)
|
|
101
|
+
(1...(h - 1)).each do |dy|
|
|
102
|
+
put(x, y + dy, "│", style)
|
|
103
|
+
put(x + w - 1, y + dy, "│", style)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Flatten everything to a faint monochrome so a modal reads as the
|
|
108
|
+
# foreground layer.
|
|
109
|
+
def veil(style = Theme::FAINT)
|
|
110
|
+
@st.fill(style)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def render
|
|
114
|
+
out = +"\e[H"
|
|
115
|
+
cur = :none
|
|
116
|
+
@h.times do |y|
|
|
117
|
+
row = y * @w
|
|
118
|
+
@w.times do |x|
|
|
119
|
+
st = @st[row + x]
|
|
120
|
+
if st != cur
|
|
121
|
+
out << Theme::RESET
|
|
122
|
+
out << st if st
|
|
123
|
+
cur = st
|
|
124
|
+
end
|
|
125
|
+
out << @ch[row + x]
|
|
126
|
+
end
|
|
127
|
+
out << Theme::RESET
|
|
128
|
+
cur = nil
|
|
129
|
+
out << "\r\n" unless y == @h - 1
|
|
130
|
+
end
|
|
131
|
+
out << Theme::RESET
|
|
132
|
+
out
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
data/lib/rakpak/sizer.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
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
|
+
class Sizer
|
|
7
|
+
Result = Struct.new(:bytes, :files, :partial)
|
|
8
|
+
|
|
9
|
+
BUDGET = 4.0 # seconds per path before we report a partial figure
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@cache = {}
|
|
13
|
+
@queue = Queue.new
|
|
14
|
+
@lock = Mutex.new
|
|
15
|
+
@pending = {}
|
|
16
|
+
@worker = Thread.new { loop { work(*@queue.pop) } }
|
|
17
|
+
@worker.abort_on_exception = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# nil means "still counting".
|
|
21
|
+
def [](path)
|
|
22
|
+
@lock.synchronize { @cache[path] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def request(path)
|
|
26
|
+
token = nil
|
|
27
|
+
@lock.synchronize do
|
|
28
|
+
return if @cache.key?(path) || @pending[path]
|
|
29
|
+
|
|
30
|
+
token = Object.new
|
|
31
|
+
@pending[path] = token
|
|
32
|
+
end
|
|
33
|
+
@queue << [path, token]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def total(paths)
|
|
37
|
+
known = paths.map { |p| self[p] }
|
|
38
|
+
bytes = known.compact.sum { |r| r.bytes }
|
|
39
|
+
files = known.compact.sum { |r| r.files }
|
|
40
|
+
Result.new(bytes, files, known.any?(&:nil?) || known.compact.any?(&:partial))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Drops the figure, and any measurement in flight for it.
|
|
44
|
+
def forget(path)
|
|
45
|
+
@lock.synchronize do
|
|
46
|
+
@cache.delete(path)
|
|
47
|
+
@pending.delete(path)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def invalidate!
|
|
52
|
+
@lock.synchronize do
|
|
53
|
+
@cache.clear
|
|
54
|
+
@pending.clear
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def work(path, token)
|
|
61
|
+
res = measure(path)
|
|
62
|
+
@lock.synchronize do
|
|
63
|
+
# Forgotten, invalidated, or re-requested while we were walking:
|
|
64
|
+
# only the walk that the current request started may answer it.
|
|
65
|
+
next unless @pending[path].equal?(token)
|
|
66
|
+
|
|
67
|
+
@pending.delete(path)
|
|
68
|
+
@cache[path] = res
|
|
69
|
+
end
|
|
70
|
+
rescue StandardError
|
|
71
|
+
@lock.synchronize { @pending.delete(path) if @pending[path].equal?(token) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def measure(path)
|
|
75
|
+
st = File.lstat(path)
|
|
76
|
+
return Result.new(st.size, 1, false) unless st.directory?
|
|
77
|
+
|
|
78
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BUDGET
|
|
79
|
+
bytes = 0
|
|
80
|
+
files = 1 # the folder itself is an archive member
|
|
81
|
+
partial = false
|
|
82
|
+
stack = [path]
|
|
83
|
+
until stack.empty?
|
|
84
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
85
|
+
partial = true
|
|
86
|
+
break
|
|
87
|
+
end
|
|
88
|
+
dir = stack.pop
|
|
89
|
+
begin
|
|
90
|
+
Dir.children(dir).each do |name|
|
|
91
|
+
child = File.join(dir, name)
|
|
92
|
+
s = begin
|
|
93
|
+
File.lstat(child)
|
|
94
|
+
rescue StandardError
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
if s.directory?
|
|
98
|
+
stack << child
|
|
99
|
+
files += 1 # tar and zip both record directory entries
|
|
100
|
+
else
|
|
101
|
+
bytes += s.size
|
|
102
|
+
files += 1
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
rescue StandardError
|
|
106
|
+
next
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
Result.new(bytes, files, partial)
|
|
110
|
+
rescue StandardError
|
|
111
|
+
Result.new(0, 0, true)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/rakpak/term.rb
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Rakpak
|
|
6
|
+
# Raw-mode terminal control and key decoding.
|
|
7
|
+
module Term
|
|
8
|
+
ARROWS = { "A" => :up, "B" => :down, "C" => :right, "D" => :left,
|
|
9
|
+
"H" => :home, "F" => :end }.freeze
|
|
10
|
+
|
|
11
|
+
TILDE = { "1" => :home, "3" => :delete, "4" => :end,
|
|
12
|
+
"5" => :pgup, "6" => :pgdn, "7" => :home, "8" => :end }.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
attr_accessor :resized
|
|
16
|
+
end
|
|
17
|
+
self.resized = false
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def size
|
|
22
|
+
h, w = $stdout.winsize
|
|
23
|
+
[w.to_i.positive? ? w : 80, h.to_i.positive? ? h : 24]
|
|
24
|
+
rescue StandardError
|
|
25
|
+
[80, 24]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start
|
|
29
|
+
@stty = `stty -g 2>/dev/null`.chomp
|
|
30
|
+
$stdin.raw!
|
|
31
|
+
$stdout.write("\e[?1049h\e[?25l\e[2J")
|
|
32
|
+
$stdout.flush
|
|
33
|
+
trap("WINCH") { Term.resized = true }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stop
|
|
37
|
+
$stdout.write("\e[?25h\e[?1049l")
|
|
38
|
+
$stdout.flush
|
|
39
|
+
if @stty && !@stty.empty?
|
|
40
|
+
system("stty", @stty, out: File::NULL, err: File::NULL)
|
|
41
|
+
else
|
|
42
|
+
begin
|
|
43
|
+
$stdin.cooked!
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def flush_frame(str)
|
|
51
|
+
$stdout.write(str)
|
|
52
|
+
$stdout.flush
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Blocks up to `timeout` seconds. Returns a key symbol, a printable
|
|
56
|
+
# String, or nil on timeout.
|
|
57
|
+
def wait_key(timeout = nil)
|
|
58
|
+
return nil unless IO.select([$stdin], nil, nil, timeout)
|
|
59
|
+
|
|
60
|
+
read_key
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def read_key
|
|
64
|
+
c = getc_raw
|
|
65
|
+
return nil if c.nil?
|
|
66
|
+
|
|
67
|
+
c = complete_utf8(c)
|
|
68
|
+
return nil if c.nil?
|
|
69
|
+
|
|
70
|
+
case c
|
|
71
|
+
when "\e" then read_escape
|
|
72
|
+
when "\r", "\n" then :enter
|
|
73
|
+
when "\t" then :tab
|
|
74
|
+
when "\x7f", "\b" then :backspace
|
|
75
|
+
when " " then :space
|
|
76
|
+
when "\x00".."\x1f" then :"ctrl_#{(c.ord + 96).chr}"
|
|
77
|
+
else c
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Under a C locale getc yields one byte at a time, and under UTF-8 a
|
|
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.
|
|
85
|
+
def complete_utf8(c)
|
|
86
|
+
s = c.dup.force_encoding(Encoding::UTF_8)
|
|
87
|
+
return s if s.valid_encoding?
|
|
88
|
+
|
|
89
|
+
lead = s.getbyte(0)
|
|
90
|
+
need = if lead.between?(0xC2, 0xDF) then 1
|
|
91
|
+
elsif lead.between?(0xE0, 0xEF) then 2
|
|
92
|
+
elsif lead.between?(0xF0, 0xF4) then 3
|
|
93
|
+
else 0
|
|
94
|
+
end
|
|
95
|
+
need.times do
|
|
96
|
+
more = getc_raw(ESC_WINDOW)
|
|
97
|
+
break if more.nil?
|
|
98
|
+
|
|
99
|
+
s = (s.b + more.b).force_encoding(Encoding::UTF_8)
|
|
100
|
+
end
|
|
101
|
+
s.valid_encoding? ? s : nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def getc_raw(timeout = nil)
|
|
105
|
+
return nil if timeout && !IO.select([$stdin], nil, nil, timeout)
|
|
106
|
+
|
|
107
|
+
$stdin.getc
|
|
108
|
+
rescue IOError, Errno::EINTR
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# An arrow key arrives as several bytes. Over a slow link they can be
|
|
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.
|
|
115
|
+
ESC_WINDOW = 0.05
|
|
116
|
+
|
|
117
|
+
def read_escape
|
|
118
|
+
seq = +""
|
|
119
|
+
12.times do
|
|
120
|
+
ch = getc_raw(ESC_WINDOW)
|
|
121
|
+
break if ch.nil?
|
|
122
|
+
|
|
123
|
+
seq << ch
|
|
124
|
+
break if seq.match?(/\A\[[0-9;]*[A-Za-z~]\z/) || seq.match?(/\AO[A-Za-z]\z/)
|
|
125
|
+
end
|
|
126
|
+
return :esc if seq.empty?
|
|
127
|
+
|
|
128
|
+
body = seq[1..] || ""
|
|
129
|
+
if seq.start_with?("O")
|
|
130
|
+
ARROWS[body] || :esc
|
|
131
|
+
elsif seq.start_with?("[")
|
|
132
|
+
if (m = body.match(/\A([0-9;]*)([A-Za-z~])\z/))
|
|
133
|
+
num, fin = m[1], m[2]
|
|
134
|
+
return ARROWS[fin] if ARROWS.key?(fin)
|
|
135
|
+
return TILDE[num.split(";").first.to_s] || :esc if fin == "~"
|
|
136
|
+
|
|
137
|
+
:esc
|
|
138
|
+
else
|
|
139
|
+
:esc
|
|
140
|
+
end
|
|
141
|
+
else
|
|
142
|
+
# Alt-<char>: the whole sequence is the character.
|
|
143
|
+
:"alt_#{seq}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|