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,508 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require_relative "dir_cache"
5
+ require_relative "theme"
6
+ require_relative "text"
7
+
8
+ module Rakpak
9
+ # The yazi-style three-pane file browser: parent, current, preview.
10
+ # Owns navigation and the tag set; knows nothing about archiving.
11
+ class Browser
12
+ attr_reader :cwd, :tags, :filter
13
+
14
+ def initialize(start_dir = Dir.pwd, sizer: nil)
15
+ @dirs = DirCache.new
16
+ @sizer = sizer
17
+ @tags = Set.new
18
+ @cursor = {}
19
+ @show_hidden = false
20
+ @filter = nil
21
+ @filtering = false
22
+ @show_queue = true # on until someone presses t
23
+ @pending = nil
24
+ @preview_cache = {}
25
+ @cwd = resolve(start_dir)
26
+ end
27
+
28
+ def resolve(dir)
29
+ d = File.expand_path(dir)
30
+ d = File.dirname(d) until File.directory?(d) || d == "/"
31
+ d
32
+ end
33
+
34
+ # ---------------------------------------------------------------- state
35
+
36
+ def entries
37
+ list = @dirs.list(@cwd, hidden: @show_hidden) || []
38
+ return list unless @filter && !@filter.empty?
39
+
40
+ needle = @filter.downcase
41
+ list.select { |e| e.fold.include?(needle) }
42
+ end
43
+
44
+ def index
45
+ @cursor[@cwd] = (@cursor[@cwd] || 0).clamp(0, [entries.size - 1, 0].max)
46
+ end
47
+
48
+ def index=(i)
49
+ @cursor[@cwd] = i.clamp(0, [entries.size - 1, 0].max)
50
+ end
51
+
52
+ def current = entries[index]
53
+ def show_hidden? = @show_hidden
54
+ def show_queue? = @show_queue
55
+ def filtering? = @filtering
56
+
57
+ def tag(path)
58
+ path = File.expand_path(path)
59
+ return unless @tags.add?(path)
60
+
61
+ # A figure may linger from a cursor-only pack of this path; a new tag
62
+ # must not trust it.
63
+ @sizer&.forget(path)
64
+ @sizer&.request(path)
65
+ end
66
+
67
+ # Every path out of the tag set goes through here so the sizer drops
68
+ # its figure; re-tagging later must measure again, not serve a stale one.
69
+ def untag(path)
70
+ return unless @tags.delete?(path)
71
+
72
+ @sizer&.forget(path)
73
+ end
74
+
75
+ def toggle_tag(entry = current)
76
+ return unless entry
77
+
78
+ @tags.include?(entry.path) ? untag(entry.path) : tag(entry.path)
79
+ end
80
+
81
+ def tag_all = entries.each { |e| tag(e.path) }
82
+ def untag_all_here = entries.each { |e| untag(e.path) }
83
+ def clear_tags = @tags.to_a.each { |p| untag(p) }
84
+
85
+ def replace_tags(list)
86
+ (@tags.to_a - list).each { |p| untag(p) }
87
+ list.each { |p| tag(p) }
88
+ end
89
+
90
+ # Selections implied by the current state: explicit tags, else whatever
91
+ # the cursor is sitting on.
92
+ def selection
93
+ return @tags.to_a.sort if @tags.any?
94
+
95
+ current ? [current.path] : []
96
+ end
97
+
98
+ def refresh!
99
+ @dirs.invalidate!
100
+ @preview_cache.clear
101
+ return unless @sizer
102
+
103
+ @sizer.invalidate!
104
+ @tags.each { |p| @sizer.request(p) }
105
+ end
106
+
107
+ # ----------------------------------------------------------- navigation
108
+
109
+ def move(delta)
110
+ self.index = index + delta
111
+ end
112
+
113
+ # One step at a time wraps: up from the top lands on the last entry,
114
+ # down from the bottom on the first. Page moves still stop at the ends.
115
+ def step(delta)
116
+ n = entries.size
117
+ return if n.zero?
118
+
119
+ self.index = (index + delta) % n
120
+ end
121
+
122
+ def descend
123
+ e = current
124
+ return unless e&.dir?
125
+ return unless File.readable?(e.path)
126
+
127
+ @cwd = e.path
128
+ @cursor[@cwd] ||= 0
129
+ @filter = nil
130
+ end
131
+
132
+ def ascend
133
+ return if @cwd == "/"
134
+
135
+ child = @cwd
136
+ @cwd = File.dirname(@cwd)
137
+ @filter = nil
138
+ idx = (@dirs.list(@cwd, hidden: @show_hidden) || []).index { |e| e.path == child }
139
+ @cursor[@cwd] = idx if idx
140
+ end
141
+
142
+ def goto(dir)
143
+ d = File.expand_path(dir)
144
+ return false unless File.directory?(d) && File.readable?(d)
145
+
146
+ @cwd = d
147
+ @filter = nil
148
+ true
149
+ end
150
+
151
+ # Open the folder holding `path` with the cursor on it.
152
+ def jump_to(path)
153
+ path = File.expand_path(path)
154
+ return false unless goto(File.dirname(path))
155
+
156
+ @show_hidden = true if File.basename(path).start_with?(".")
157
+ idx = entries.index { |e| e.path == path }
158
+ self.index = idx if idx
159
+ !idx.nil?
160
+ end
161
+
162
+ # Returns :quit, :archive, :tags, :help, :jobs or nil.
163
+ def handle(key)
164
+ return handle_filter(key) if @filtering
165
+
166
+ if @pending == "g"
167
+ @pending = nil
168
+ case key
169
+ when "g" then return (self.index = 0) && nil
170
+ when "h" then return goto(Dir.home) && nil
171
+ when "r" then return goto("/") && nil
172
+ end
173
+ # Anything else was not a chord: treat it as its own keystroke, so
174
+ # a stray g never swallows q, p or space.
175
+ end
176
+
177
+ result = case key
178
+ when "j", :down then step(1)
179
+ when "k", :up then step(-1)
180
+ when "h", :left then ascend
181
+ when "l", :right, :enter then descend
182
+ when :ctrl_d then move(page / 2)
183
+ when :ctrl_u then move(-page / 2)
184
+ when :pgdn then move(page)
185
+ when :pgup then move(-page)
186
+ when "G" then self.index = entries.size - 1
187
+ when "g" then @pending = "g"
188
+ when :space
189
+ toggle_tag
190
+ move(1)
191
+ when "a" then tag_all
192
+ when "d" then untag_all_here
193
+ when "D" then clear_tags
194
+ when "." then toggle_hidden
195
+ when "t" then toggle_queue
196
+ when "/" then start_filter
197
+ when :ctrl_r then refresh!
198
+ when "~" then goto(Dir.home)
199
+ when "p" then :archive
200
+ when "T" then :tags
201
+ when "?" then :help
202
+ when "b" then :jobs
203
+ when "q", :ctrl_c then :quit
204
+ end
205
+ %i[archive tags help jobs quit].include?(result) ? result : nil
206
+ end
207
+
208
+ def toggle_hidden
209
+ @show_hidden = !@show_hidden
210
+ nil
211
+ end
212
+
213
+ def toggle_queue
214
+ @show_queue = !@show_queue
215
+ nil
216
+ end
217
+
218
+ def start_filter
219
+ @filtering = true
220
+ @filter = +""
221
+ nil
222
+ end
223
+
224
+ def handle_filter(key)
225
+ case key
226
+ when :enter then @filtering = false
227
+ when :esc
228
+ @filtering = false
229
+ @filter = nil
230
+ when :backspace then @filter = @filter.to_s[0..-2] || ""
231
+ when :space then @filter = "#{@filter} "
232
+ when String then @filter = "#{@filter}#{key}" if key.match?(/\A[[:print:]]\z/)
233
+ end
234
+ self.index = index
235
+ nil
236
+ end
237
+
238
+ def page = @page_size || 10
239
+
240
+ # -------------------------------------------------------------- drawing
241
+
242
+ def draw(screen, status_line)
243
+ @page_size = screen.h - 5
244
+ draw_header(screen)
245
+ body_y = 1
246
+ body_h = screen.h - 3
247
+ layout(screen.w).each { |pane| draw_pane(screen, pane, body_y, body_h) }
248
+ draw_status(screen, screen.h - 2)
249
+ draw_footer(screen, screen.h - 1, status_line)
250
+ end
251
+
252
+ # Left to right: the queue (when shown), parent, current, preview. The
253
+ # queue takes its slice first and the usual three share what is left.
254
+ def layout(w)
255
+ panes = []
256
+ x = 0
257
+ if @show_queue && w >= 60
258
+ qw = (w * 0.3).to_i.clamp(24, 60)
259
+ panes << { kind: :queue, x: 0, w: qw }
260
+ x = qw + 1
261
+ end
262
+ rest = w - x
263
+ pw, prw =
264
+ if rest >= 100 then [24, ((rest - 24) * 0.42).to_i]
265
+ elsif rest >= 76 then [18, ((rest - 18) * 0.40).to_i]
266
+ elsif rest >= 54 then [0, (rest * 0.38).to_i]
267
+ else [0, 0]
268
+ end
269
+ if pw.positive?
270
+ panes << { kind: :parent, x: x, w: pw }
271
+ x += pw + 1
272
+ end
273
+ cw = w - x - (prw.positive? ? prw + 1 : 0)
274
+ panes << { kind: :current, x: x, w: cw }
275
+ x += cw + 1
276
+ panes << { kind: :preview, x: x, w: prw } if prw.positive?
277
+ panes
278
+ end
279
+
280
+ def draw_header(screen)
281
+ screen.fill(0, 0, screen.w, 1, " ", Theme::HEAD)
282
+ screen.put(1, 0, "rakpak", Theme::HEAD + "\e[1;38;5;39m")
283
+ right = tag_summary
284
+ avail = screen.w - 10 - Text.width(right) - 3
285
+ screen.put(9, 0, Text.fit_left(Text.tilde(@cwd), [avail, 1].max), Theme::HEAD + "\e[38;5;231m")
286
+ screen.put(screen.w - Text.width(right) - 1, 0, right,
287
+ @tags.empty? ? Theme::HEAD : Theme::HEAD + Theme::TAG)
288
+ end
289
+
290
+ def tag_summary
291
+ return "nothing tagged" if @tags.empty?
292
+
293
+ t = @sizer&.total(@tags.to_a)
294
+ count = "#{@tags.size} tagged"
295
+ return count unless t
296
+
297
+ size = t.bytes.zero? && t.partial ? "…" : "#{t.partial ? '≥' : ''}#{Text.bytes(t.bytes)}"
298
+ "#{count} · #{size}"
299
+ end
300
+
301
+ def draw_pane(screen, pane, y, h)
302
+ screen.vline(pane[:x] - 1, y, h, Theme::BORDER) if pane[:x].positive?
303
+ case pane[:kind]
304
+ when :queue then draw_queue(screen, pane, y, h)
305
+ when :parent then draw_parent(screen, pane, y, h)
306
+ when :current then draw_list(screen, pane, y, h, entries, index, true)
307
+ when :preview then draw_preview(screen, pane, y, h)
308
+ end
309
+ end
310
+
311
+ # Everything queued for the next pack, wherever on the disk it lives:
312
+ # the name, then where it is, with the size at the edge and the running
313
+ # total on top. Stays put while you keep browsing.
314
+ def draw_queue(screen, pane, y, h)
315
+ x = pane[:x]
316
+ w = pane[:w]
317
+ screen.put(x + 1, y, Text.fit("QUEUE · #{tag_summary}", w - 2), Theme::TITLE)
318
+ list = @tags.to_a.sort
319
+ return if list.empty? || h < 2
320
+
321
+ rows = h - 1
322
+ shown = list.size > rows ? list.first(rows - 1) : list
323
+ names = shown.map { |p| File.basename(p) }
324
+ name_w = [names.map { |n| Text.width(n) }.max || 0, w / 3].min
325
+ shown.each_with_index do |path, i|
326
+ row = y + 1 + i
327
+ res = @sizer&.[](path)
328
+ meta = res ? "#{res.partial ? '≥' : ''}#{Text.bytes(res.bytes)}" : "…"
329
+ here = current&.path == path
330
+ bg = here ? Theme::SEL_BG : ""
331
+ screen.fill(x, row, w, 1, " ", Theme::SEL_BG) if here
332
+ screen.put(x, row, " ▌", bg + Theme::TAG)
333
+ nx = screen.put(x + 2, row, Text.pad(Text.fit(names[i], name_w), name_w), bg + (File.directory?(path) ? Theme::DIR : Theme::NORMAL))
334
+ avail = w - (nx - x) - Text.width(meta) - 4
335
+ screen.put(nx + 2, row, Text.fit_left(Text.tilde(File.dirname(path)), avail), bg + Theme::DIM) if avail > 3
336
+ screen.put(x + w - Text.width(meta) - 1, row, meta, bg + Theme::DIM)
337
+ end
338
+ return unless list.size > shown.size
339
+
340
+ screen.put(x + 1, y + h - 1, "… #{list.size - shown.size} more", Theme::FAINT)
341
+ end
342
+
343
+ def draw_parent(screen, pane, y, h)
344
+ return if @cwd == "/"
345
+
346
+ list = @dirs.list(File.dirname(@cwd), hidden: @show_hidden) || []
347
+ idx = list.index { |e| e.path == @cwd } || 0
348
+ draw_list(screen, pane, y, h, list, idx, false)
349
+ end
350
+
351
+ def draw_list(screen, pane, y, h, list, cur, active)
352
+ x = pane[:x]
353
+ w = pane[:w]
354
+ if list.nil? || list.empty?
355
+ msg = @filter && !@filter.to_s.empty? ? "no match" : "empty"
356
+ screen.put(x + 1, y, msg, Theme::FAINT)
357
+ return
358
+ end
359
+
360
+ top = [[cur - (h / 2), list.size - h].min, 0].max
361
+ list[top, h].to_a.each_with_index do |e, i|
362
+ row = y + i
363
+ sel = (top + i) == cur
364
+ tagged = @tags.include?(e.path)
365
+ bg = if sel && active then Theme::CUR_BG
366
+ elsif sel then Theme::SEL_BG
367
+ end
368
+ screen.fill(x, row, w, 1, " ", bg) if bg
369
+ mark_style = tagged ? (bg.to_s + Theme::TAG) : (bg.to_s + Theme::FAINT)
370
+ screen.put(x, row, tagged ? " ▌" : " ", mark_style)
371
+ name_style = bg ? bg + (sel && active ? "\e[1;38;5;231m" : e.style) : e.style
372
+ avail = w - 3
373
+ avail -= 5 unless pane[:kind] == :parent
374
+ screen.put(x + 2, row, Text.fit(e.display, [avail, 1].max), name_style)
375
+ next if pane[:kind] == :parent || w < 22
376
+
377
+ meta = e.dir? ? "" : Text.bytes(e.size)
378
+ screen.put(x + w - Text.width(meta) - 1, row, meta,
379
+ bg ? bg + Theme::DIM : Theme::DIM)
380
+ end
381
+
382
+ return unless list.size > h
383
+
384
+ pos = (top.to_f / (list.size - h) * (h - 1)).round
385
+ screen.put(x + w - 1, y + pos, "▐", Theme::ACCENT)
386
+ end
387
+
388
+ def draw_preview(screen, pane, y, h)
389
+ e = current
390
+ x = pane[:x]
391
+ w = pane[:w]
392
+ unless e
393
+ screen.put(x + 1, y, "nothing to preview", Theme::FAINT)
394
+ return
395
+ end
396
+
397
+ preview_lines(e, w - 2, h).each_with_index do |(txt, st), i|
398
+ screen.put(x + 1, y + i, Text.fit(txt, w - 2), st)
399
+ end
400
+ end
401
+
402
+ def preview_lines(entry, w, h)
403
+ key = [entry.path, entry.mtime, w, h]
404
+ @preview_cache.delete(@preview_cache.keys.first) if @preview_cache.size > 40
405
+ @preview_cache[key] ||= build_preview(entry, w, h)
406
+ end
407
+
408
+ def build_preview(entry, w, h)
409
+ return [["permission denied", Theme::ERR]] unless entry.readable?
410
+
411
+ if entry.dir?
412
+ kids = @dirs.list(entry.path, hidden: @show_hidden)
413
+ return [["permission denied", Theme::ERR]] if kids.nil?
414
+ return [["empty folder", Theme::FAINT]] if kids.empty?
415
+
416
+ head = kids.first(h - 1).map { |k| [" #{k.display}", k.style] }
417
+ head << [" … #{kids.size - head.size} more", Theme::FAINT] if kids.size > head.size
418
+ head
419
+ else
420
+ file_preview(entry, w, h)
421
+ end
422
+ end
423
+
424
+ def file_preview(entry, w, h)
425
+ # Reading a FIFO or a device blocks until someone writes to it, which
426
+ # would freeze the UI with the cursor merely resting on the entry.
427
+ kind = entry.target_stat
428
+ return [["not a regular file", Theme::FAINT]] unless kind&.file?
429
+
430
+ size = entry.size.to_i
431
+ head = File.binread(entry.path, 8192) || ""
432
+ if head.empty?
433
+ return [["empty file", Theme::FAINT]]
434
+ end
435
+
436
+ if binary?(head)
437
+ [["binary · #{Text.bytes(size)}", Theme::TAG], ["", nil]] +
438
+ hexdump(head, h - 3, w)
439
+ else
440
+ head.force_encoding("UTF-8").scrub("·").lines.first(h).map { |l| [l.chomp.tr("\t", " "), Theme::NORMAL] }
441
+ end
442
+ rescue StandardError => e
443
+ [[e.class.name.split("::").last, Theme::ERR]]
444
+ end
445
+
446
+ # Bytes per row adapts to the pane so the ASCII column always fits.
447
+ def hexdump(bytes, rows, width)
448
+ return [] if rows <= 0
449
+
450
+ per = ((width - 2) / 4).clamp(4, 16)
451
+ hex_w = (per * 3) - 1
452
+ bytes.bytes.each_slice(per).first([rows, 0].max).map do |slice|
453
+ hex = slice.map { |b| format("%02x", b) }.join(" ")
454
+ asc = slice.map { |b| b.between?(32, 126) ? b.chr : "." }.join
455
+ ["#{hex.ljust(hex_w)} #{asc}", Theme::DIM]
456
+ end
457
+ end
458
+
459
+ def binary?(sample)
460
+ return false if sample.empty?
461
+
462
+ sample.byteslice(0, 1024).bytes.any? { |b| b < 9 }
463
+ end
464
+
465
+ def draw_status(screen, y)
466
+ screen.fill(0, y, screen.w, 1, " ", Theme::SEL_BG)
467
+ e = current
468
+ if @filtering || (@filter && !@filter.empty?)
469
+ screen.put(1, y, "/#{@filter}", Theme::SEL_BG + Theme::KEY)
470
+ screen.put(2 + Text.width(@filter.to_s), y, @filtering ? "▏" : "", Theme::SEL_BG + Theme::ACCENT)
471
+ return
472
+ end
473
+ return unless e
474
+
475
+ st = e.stat
476
+ left = if st
477
+ mode = format("%04o", st.mode & 0o7777)
478
+ time = st.mtime.strftime("%Y-%m-%d %H:%M")
479
+ kind = e.symlink? ? "→ #{e.link_target}" : (e.dir? ? "dir" : Text.bytes(e.size))
480
+ " #{mode} #{time} #{kind}"
481
+ else
482
+ " unreadable"
483
+ end
484
+ screen.put(0, y, Text.fit(left, screen.w - 14), Theme::SEL_BG + Theme::DIM)
485
+ pos = " #{index + 1}/#{entries.size} "
486
+ screen.put(screen.w - Text.width(pos), y, pos, Theme::SEL_BG + Theme::DIM)
487
+ end
488
+
489
+ HINTS = [["space", "tag"], ["←→", "nav"], ["a", "all"], ["t", "queue"], ["/", "find"],
490
+ [".", "hidden"], ["p", "pack"], ["?", "help"]].freeze
491
+
492
+ def draw_footer(screen, y, status_line)
493
+ screen.fill(0, y, screen.w, 1, " ", nil)
494
+ if status_line
495
+ screen.put(1, y, Text.fit(status_line[0], screen.w - 2), status_line[1])
496
+ return
497
+ end
498
+ x = 1
499
+ HINTS.each do |k, label|
500
+ break if x + Text.width(k) + Text.width(label) + 3 > screen.w
501
+
502
+ x = screen.put(x, y, k, Theme::KEY)
503
+ x = screen.put(x + 1, y, label, Theme::DIM)
504
+ x += 2
505
+ end
506
+ end
507
+ end
508
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "entry"
4
+
5
+ module Rakpak
6
+ # Directory listings, memoised per (path, show_hidden). Cheap enough that
7
+ # the parent/preview panes can ask on every keystroke.
8
+ class DirCache
9
+ MAX_ENTRIES = 20_000
10
+
11
+ def initialize
12
+ @cache = {}
13
+ end
14
+
15
+ def invalidate!
16
+ @cache.clear
17
+ end
18
+
19
+ def list(path, hidden: false)
20
+ key = [path, hidden]
21
+ # Cache failures too, or an EACCES parent is re-walked every frame.
22
+ return @cache[key] if @cache.key?(key)
23
+
24
+ @cache[key] = read(path, hidden)
25
+ end
26
+
27
+ private
28
+
29
+ def read(path, hidden)
30
+ names = Dir.children(path)
31
+ names.reject! { |n| n.start_with?(".") } unless hidden
32
+ names = names.first(MAX_ENTRIES)
33
+ names.map { |n| Entry.new(File.join(path, n), n) }.sort_by(&:sort_key)
34
+ rescue StandardError # EACCES, ENOENT, ENOTDIR, ELOOP and the rest
35
+ nil
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rakpak
4
+ # One filesystem row. Stats are taken once, lazily, and never raise.
5
+ class Entry
6
+ attr_reader :path, :name
7
+
8
+ def initialize(path, name = nil)
9
+ @path = path
10
+ @name = name || File.basename(path)
11
+ end
12
+
13
+ def stat
14
+ return @stat if defined?(@stat)
15
+
16
+ @stat = begin
17
+ File.lstat(@path)
18
+ rescue StandardError
19
+ nil
20
+ end
21
+ end
22
+
23
+ def target_stat
24
+ return @target_stat if defined?(@target_stat)
25
+
26
+ @target_stat = begin
27
+ File.stat(@path)
28
+ rescue StandardError
29
+ nil
30
+ end
31
+ end
32
+
33
+ def symlink? = stat&.symlink? ? true : false
34
+ def dir? = (symlink? ? target_stat : stat)&.directory? ? true : false
35
+ def exec? = !dir? && (stat&.mode.to_i & 0o111).positive?
36
+ def size = stat&.size
37
+ def mtime = stat&.mtime
38
+
39
+ def readable?
40
+ return @readable if defined?(@readable)
41
+
42
+ @readable = File.readable?(@path)
43
+ end
44
+
45
+ def link_target
46
+ return nil unless symlink?
47
+
48
+ @link_target ||= begin
49
+ File.readlink(@path)
50
+ rescue StandardError
51
+ "?"
52
+ end
53
+ end
54
+
55
+ def display
56
+ dir? ? "#{@name}/" : @name
57
+ end
58
+
59
+ def style
60
+ return Theme::LINK if symlink?
61
+ return Theme::DIR if dir?
62
+ return Theme::EXEC if exec?
63
+
64
+ Theme::NORMAL
65
+ end
66
+
67
+ # The name as comparable text: lowercased, with bytes that are not
68
+ # valid UTF-8 replaced so downcase cannot raise on them.
69
+ def fold
70
+ @fold ||= (@name.valid_encoding? ? @name : @name.scrub("?")).downcase
71
+ end
72
+
73
+ # Dirs first, then case-insensitive natural order.
74
+ def sort_key
75
+ [dir? ? 0 : 1, fold, @name]
76
+ end
77
+ end
78
+ end