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/text.rb
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rakpak
|
|
4
|
+
# Grapheme-aware width math. Just enough Unicode to keep columns honest.
|
|
5
|
+
module Text
|
|
6
|
+
WIDE = [
|
|
7
|
+
0x1100..0x115F, 0x2E80..0x303E, 0x3041..0x33FF, 0x3400..0x4DBF,
|
|
8
|
+
0x4E00..0x9FFF, 0xA000..0xA4CF, 0xAC00..0xD7A3, 0xF900..0xFAFF,
|
|
9
|
+
0xFE10..0xFE19, 0xFE30..0xFE6F, 0xFF00..0xFF60, 0xFFE0..0xFFE6,
|
|
10
|
+
0x1F300..0x1F64F, 0x1F900..0x1F9FF, 0x20000..0x2FFFD
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
ZERO = [0x0300..0x036F, 0x200B..0x200F, 0xFE00..0xFE0F, 0xFEFF..0xFEFF].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Display width of a single grapheme cluster.
|
|
18
|
+
def gw(cluster)
|
|
19
|
+
cp = cluster.ord
|
|
20
|
+
return 1 if cp >= 0x20 && cp < 0x7F # the common case, checked first
|
|
21
|
+
# C0, DEL and the C1 controls: a UTF-8 terminal executes U+009B as
|
|
22
|
+
# CSI, so these must never reach the frame.
|
|
23
|
+
return 0 if cp < 0x20 || cp == 0x7F || (cp >= 0x80 && cp <= 0x9F)
|
|
24
|
+
return 0 if ZERO.any? { |r| r.cover?(cp) }
|
|
25
|
+
return 2 if WIDE.any? { |r| r.cover?(cp) }
|
|
26
|
+
|
|
27
|
+
1
|
|
28
|
+
rescue StandardError
|
|
29
|
+
1
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def width(str)
|
|
33
|
+
w = 0
|
|
34
|
+
str.each_grapheme_cluster { |g| w += gw(g) }
|
|
35
|
+
w
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Truncate to `max` columns, appending an ellipsis when something was cut.
|
|
39
|
+
def fit(str, max)
|
|
40
|
+
return "" if max <= 0
|
|
41
|
+
return str if width(str) <= max
|
|
42
|
+
|
|
43
|
+
out = +""
|
|
44
|
+
used = 0
|
|
45
|
+
str.each_grapheme_cluster do |g|
|
|
46
|
+
cw = gw(g)
|
|
47
|
+
break if used + cw > max - 1
|
|
48
|
+
|
|
49
|
+
out << g
|
|
50
|
+
used += cw
|
|
51
|
+
end
|
|
52
|
+
out << "…"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Keep the tail of a path visible instead of the head.
|
|
56
|
+
def fit_left(str, max)
|
|
57
|
+
return "" if max <= 0
|
|
58
|
+
return str if width(str) <= max
|
|
59
|
+
|
|
60
|
+
clusters = str.grapheme_clusters
|
|
61
|
+
out = []
|
|
62
|
+
used = 0
|
|
63
|
+
clusters.reverse_each do |g|
|
|
64
|
+
cw = gw(g)
|
|
65
|
+
break if used + cw > max - 1
|
|
66
|
+
|
|
67
|
+
out.unshift(g)
|
|
68
|
+
used += cw
|
|
69
|
+
end
|
|
70
|
+
"…#{out.join}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def pad(str, max)
|
|
74
|
+
w = width(str)
|
|
75
|
+
w >= max ? str : str + (" " * (max - w))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Greedy wrap on spaces, with a hanging indent for continuations.
|
|
79
|
+
def wrap(str, max, indent = 0)
|
|
80
|
+
return [str] if max <= indent + 4 || width(str) <= max
|
|
81
|
+
|
|
82
|
+
lead = str[/\A[ \t]*/]
|
|
83
|
+
pad = " " * indent
|
|
84
|
+
out = []
|
|
85
|
+
line = +""
|
|
86
|
+
words = str[lead.length..].to_s.split(" ").flat_map do |word|
|
|
87
|
+
# A path with no spaces can still be wider than the panel; break it.
|
|
88
|
+
limit = max - indent
|
|
89
|
+
next word if width(word) <= limit || limit < 8
|
|
90
|
+
|
|
91
|
+
word.grapheme_clusters.each_slice(limit).map(&:join)
|
|
92
|
+
end
|
|
93
|
+
words.each do |word|
|
|
94
|
+
candidate = line.empty? ? word : "#{line} #{word}"
|
|
95
|
+
if width(out.empty? ? lead + candidate : pad + candidate) <= max
|
|
96
|
+
line = candidate
|
|
97
|
+
else
|
|
98
|
+
out << (out.empty? ? lead + line : pad + line)
|
|
99
|
+
line = word
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
out << (out.empty? ? lead + line : pad + line) unless line.empty?
|
|
103
|
+
out
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# /home/me/x → ~/x. Only a real prefix counts: /home/me2 is left alone.
|
|
107
|
+
def tilde(path)
|
|
108
|
+
home = Dir.home
|
|
109
|
+
return path if home.nil? || home.empty? || home == "/"
|
|
110
|
+
return "~" if path == home
|
|
111
|
+
|
|
112
|
+
path.start_with?("#{home}/") ? "~#{path[home.length..]}" : path
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# For text that goes to the terminal without passing through Screen:
|
|
116
|
+
# control and C1 bytes could otherwise be executed as escape sequences.
|
|
117
|
+
def plain(str)
|
|
118
|
+
str.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?").gsub(/[\u0000-\u001f\u007f-\u009f]/, "?")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
HUMAN = %w[B K M G T P].freeze
|
|
122
|
+
|
|
123
|
+
def bytes(n)
|
|
124
|
+
return "-" if n.nil?
|
|
125
|
+
|
|
126
|
+
f = n.to_f
|
|
127
|
+
i = 0
|
|
128
|
+
while f >= 1024 && i < HUMAN.size - 1
|
|
129
|
+
f /= 1024
|
|
130
|
+
i += 1
|
|
131
|
+
end
|
|
132
|
+
i.zero? ? "#{n}B" : format("%.1f%s", f, HUMAN[i])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def duration(secs)
|
|
136
|
+
s = secs.to_i
|
|
137
|
+
return format("%d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60) if s >= 3600
|
|
138
|
+
|
|
139
|
+
format("%d:%02d", s / 60, s % 60)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/rakpak/theme.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rakpak
|
|
4
|
+
# SGR fragments. Every style is emitted after a reset, so they compose by
|
|
5
|
+
# concatenation without needing to be self-contained.
|
|
6
|
+
module Theme
|
|
7
|
+
RESET = "\e[0m"
|
|
8
|
+
|
|
9
|
+
NORMAL = "\e[38;5;252m"
|
|
10
|
+
DIM = "\e[38;5;243m"
|
|
11
|
+
FAINT = "\e[38;5;239m"
|
|
12
|
+
BORDER = "\e[38;5;238m"
|
|
13
|
+
|
|
14
|
+
ACCENT = "\e[38;5;39m"
|
|
15
|
+
TITLE = "\e[1;38;5;39m"
|
|
16
|
+
DIR = "\e[1;38;5;75m"
|
|
17
|
+
EXEC = "\e[38;5;114m"
|
|
18
|
+
LINK = "\e[38;5;141m"
|
|
19
|
+
TAG = "\e[38;5;215m"
|
|
20
|
+
|
|
21
|
+
OK = "\e[38;5;114m"
|
|
22
|
+
WARN = "\e[38;5;215m"
|
|
23
|
+
ERR = "\e[38;5;203m"
|
|
24
|
+
|
|
25
|
+
SEL_BG = "\e[48;5;236m"
|
|
26
|
+
CUR_BG = "\e[48;5;24m"
|
|
27
|
+
HEAD = "\e[48;5;236m\e[38;5;250m"
|
|
28
|
+
MODAL_BG = "\e[48;5;234m"
|
|
29
|
+
|
|
30
|
+
KEY = "\e[1;38;5;215m"
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/rakpak.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rakpak/version"
|
|
4
|
+
require_relative "rakpak/app"
|
|
5
|
+
|
|
6
|
+
module Rakpak
|
|
7
|
+
USAGE = <<~USAGE
|
|
8
|
+
rakpak: tag files anywhere, archive them once.
|
|
9
|
+
|
|
10
|
+
usage: rakpak [folder]
|
|
11
|
+
rakpak -p path [path ...]
|
|
12
|
+
|
|
13
|
+
folder start browsing there (default: your home folder)
|
|
14
|
+
-p, --pack tag the given files or folders and go straight to the
|
|
15
|
+
archive prompts, starting with the kind of compression
|
|
16
|
+
-h, --help this text
|
|
17
|
+
-v, --version print the version
|
|
18
|
+
|
|
19
|
+
space tag / untag p pack what is tagged
|
|
20
|
+
h l ← → navigate / filter . hidden
|
|
21
|
+
enter enter folder T review tags
|
|
22
|
+
? all keys q quit
|
|
23
|
+
USAGE
|
|
24
|
+
|
|
25
|
+
Options = Struct.new(:dir, :pack, :action, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# A folder as a person would type it: ~, ~/x, $HOME/x, ${HOME}/x, or an
|
|
28
|
+
# absolute or relative path.
|
|
29
|
+
def self.expand_dir(text)
|
|
30
|
+
text = text.to_s.strip
|
|
31
|
+
text = text.gsub(/\$\{(\w+)\}|\$(\w+)/) { ENV.fetch(::Regexp.last_match(1) || ::Regexp.last_match(2), "") }
|
|
32
|
+
text = "~" if text.empty?
|
|
33
|
+
File.expand_path(text)
|
|
34
|
+
rescue ArgumentError
|
|
35
|
+
text # ~nosuchuser and the like; the caller reports it is not a folder
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns Options. `action` is :browse, :help or :version. Raises
|
|
39
|
+
# ArgumentError on anything it does not understand.
|
|
40
|
+
def self.parse(argv)
|
|
41
|
+
pack_mode = false
|
|
42
|
+
positional = []
|
|
43
|
+
rest = argv.dup
|
|
44
|
+
until rest.empty?
|
|
45
|
+
arg = rest.shift
|
|
46
|
+
case arg
|
|
47
|
+
when "-h", "--help" then return Options.new(action: :help)
|
|
48
|
+
when "-v", "--version" then return Options.new(action: :version)
|
|
49
|
+
when "-p", "--pack" then pack_mode = true
|
|
50
|
+
when "--" then positional.concat(rest) && rest.clear
|
|
51
|
+
when /\A-./ then raise ArgumentError, "unknown option: #{arg}"
|
|
52
|
+
else positional << arg
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if pack_mode
|
|
57
|
+
raise ArgumentError, "-p needs at least one path" if positional.empty?
|
|
58
|
+
|
|
59
|
+
paths = positional.map { |p| File.expand_path(p) }
|
|
60
|
+
paths.each do |p|
|
|
61
|
+
raise ArgumentError, "no such file or folder: #{p}" unless File.exist?(p) || File.symlink?(p)
|
|
62
|
+
end
|
|
63
|
+
# Open the browser on the folder holding the first target, with that
|
|
64
|
+
# target under the cursor, so the archive lands next to it.
|
|
65
|
+
Options.new(action: :browse, dir: File.dirname(paths.first), pack: paths)
|
|
66
|
+
else
|
|
67
|
+
raise ArgumentError, "expected one folder, got #{positional.size}" if positional.size > 1
|
|
68
|
+
|
|
69
|
+
dir = File.expand_path(positional.first || Dir.home)
|
|
70
|
+
raise ArgumentError, "not a folder: #{dir}" unless File.directory?(dir)
|
|
71
|
+
|
|
72
|
+
Options.new(action: :browse, dir: dir, pack: [])
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Exit status.
|
|
77
|
+
def self.start(argv = ARGV)
|
|
78
|
+
begin
|
|
79
|
+
opts = parse(argv)
|
|
80
|
+
rescue ArgumentError => e
|
|
81
|
+
warn "rakpak: #{e.message}"
|
|
82
|
+
warn "try: rakpak --help"
|
|
83
|
+
return 2
|
|
84
|
+
end
|
|
85
|
+
case opts.action
|
|
86
|
+
when :help
|
|
87
|
+
puts USAGE
|
|
88
|
+
return 0
|
|
89
|
+
when :version
|
|
90
|
+
puts "rakpak #{VERSION}"
|
|
91
|
+
return 0
|
|
92
|
+
end
|
|
93
|
+
unless $stdout.tty? && $stdin.tty?
|
|
94
|
+
warn "rakpak: needs an interactive terminal"
|
|
95
|
+
return 1
|
|
96
|
+
end
|
|
97
|
+
App.new(opts.dir, pack: opts.pack).run
|
|
98
|
+
0
|
|
99
|
+
end
|
|
100
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rakpak
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ryan Wilson
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: A terminal file browser for building archives. Walk around, tag files
|
|
13
|
+
and folders wherever they live, then pack them into one compressed tarball, plain
|
|
14
|
+
tarball or zip. No dependencies beyond Ruby and the archive tools on your machine.
|
|
15
|
+
email:
|
|
16
|
+
- ryan@ryanwilson.io
|
|
17
|
+
executables:
|
|
18
|
+
- rakpak
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- LICENSE
|
|
23
|
+
- README.md
|
|
24
|
+
- bin/rakpak
|
|
25
|
+
- lib/rakpak.rb
|
|
26
|
+
- lib/rakpak/app.rb
|
|
27
|
+
- lib/rakpak/browser.rb
|
|
28
|
+
- lib/rakpak/dir_cache.rb
|
|
29
|
+
- lib/rakpak/entry.rb
|
|
30
|
+
- lib/rakpak/formats.rb
|
|
31
|
+
- lib/rakpak/job.rb
|
|
32
|
+
- lib/rakpak/modal.rb
|
|
33
|
+
- lib/rakpak/plan.rb
|
|
34
|
+
- lib/rakpak/screen.rb
|
|
35
|
+
- lib/rakpak/sizer.rb
|
|
36
|
+
- lib/rakpak/term.rb
|
|
37
|
+
- lib/rakpak/text.rb
|
|
38
|
+
- lib/rakpak/theme.rb
|
|
39
|
+
- lib/rakpak/version.rb
|
|
40
|
+
homepage: https://github.com/rywils/rakpak
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata:
|
|
44
|
+
homepage_uri: https://github.com/rywils/rakpak
|
|
45
|
+
source_code_uri: https://github.com/rywils/rakpak
|
|
46
|
+
bug_tracker_uri: https://github.com/rywils/rakpak/issues
|
|
47
|
+
rubygems_mfa_required: 'true'
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '3.0'
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 3.6.9
|
|
63
|
+
specification_version: 4
|
|
64
|
+
summary: Tag files anywhere on your filesystem and archive them all at once from the
|
|
65
|
+
terminal.
|
|
66
|
+
test_files: []
|