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
|
@@ -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
|
data/lib/rakpak/entry.rb
ADDED
|
@@ -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
|