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.
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rakpak
4
+ VERSION = "1.0.0"
5
+ 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: []