rakpak 1.1.0 → 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98de68901620153b4b19a5ff3ccf6b9757f8e718db3a4bcef3d70b81b75e5a76
4
- data.tar.gz: 5b7a9592e10e1b4181d4ba5aceaba0dc8fc70e6b3286f6660e4e10cdc9b4c287
3
+ metadata.gz: 2eb31aeb19c23cee757195d888132a931ea62030b0df07e88585c9e15fa5eea1
4
+ data.tar.gz: 9edb48577409aa19d2f1cb03115c881b2a7a1c755ccc8005d419e95428266db3
5
5
  SHA512:
6
- metadata.gz: 544d49d201309db5d5d90fb692f78e7e103280b06f7373ad872f21cc90374763c650da2f02fb5f006303e7dbd296df8d32f2f6d8eb7924b096d15d3960c5ef8b
7
- data.tar.gz: 802d440f759bf5648199f07e6b98b934eda43e94b08133cdb3c07da9bed16b900cb2c0456c436ef9f2932bc4a3bcc26dbdd70b6c13b31722007864ee622c047f
6
+ metadata.gz: d629766dd61b2c108c34e500a38e73cb465262ec36454d1e8714572f6104359cecc8712fbef2314a784d2070681a15fa821c081e39e292eb42794bdd508e0f10
7
+ data.tar.gz: cd9fae98590a0d321f061588eebece2f015a74937a58c345a46d4f6b18b1a04492c85b7e1c11a474397d9fded85173bbb4d4fe1032974f03c3c33550eb08ffc4
data/README.md CHANGED
@@ -76,13 +76,18 @@ exit status is non-zero if any of them failed.
76
76
  3. **Set the flags.** Compression method, level, and the switches for that
77
77
  format. Compressed tarballs default to gzip, the one every system can
78
78
  read.
79
- 4. **Say where.** This directory, your home directory, or one you type
79
+ 4. **Leave things out.** Type names separated by commas, like
80
+ `node_modules, .cache, *.log`. Each one is left out wherever it turns
81
+ up, along with everything inside it. Or pick *Tag files and directories*
82
+ to walk the folders you are packing and press `space` on the exact spots
83
+ to skip; `p` brings you back. Skipped when only files are packed.
84
+ 5. **Say where.** This directory, your home directory, or one you type
80
85
  into the field right there. `~`, `$HOME` and absolute paths all work,
81
86
  and typing anything jumps to the field.
82
- 5. **Name it.** The extension is added for you.
83
- 6. **Confirm.** You see the exact command before anything runs. Only `enter`
87
+ 6. **Name it.** The extension is added for you.
88
+ 7. **Confirm.** You see the exact command before anything runs. Only `enter`
84
89
  starts it.
85
- 7. **Watch it, or press `b`** to drop it into the background and keep browsing.
90
+ 8. **Watch it, or press `b`** to drop it into the background and keep browsing.
86
91
 
87
92
  If nothing is tagged, `p` archives whatever the cursor is on.
88
93
 
@@ -115,8 +120,9 @@ files that were already there are never touched.
115
120
  | `ctrl-d` `ctrl-u` | half page |
116
121
  | `gh` `~` / `gr` | home / root |
117
122
  | `space` | tag or untag, then move down |
123
+ | `x` | exclude or include, then move down: left out of anything packed around it |
118
124
  | `a` / `d` | tag / untag everything in this folder |
119
- | `D` | clear all tags |
125
+ | `D` | clear all tags and exclusions |
120
126
  | `T` | review tagged items (`space` removes) |
121
127
  | `t` | show or hide the queue pane |
122
128
  | `/` | filter this folder |
data/lib/rakpak/app.rb CHANGED
@@ -11,8 +11,6 @@ require_relative "job"
11
11
  require_relative "sizer"
12
12
 
13
13
  module Rakpak
14
- # Review panel for the tag set: the one place to see everything picked up
15
- # across the filesystem, and drop entries without walking back to them.
16
14
  class TagsModal < Modal
17
15
  def initialize(tags:, sizer:)
18
16
  super(title: "tagged", footer: "space/d remove · D clear all · esc back")
@@ -63,11 +61,9 @@ module Rakpak
63
61
 
64
62
  class App
65
63
  SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
66
- WIZARD = %i[target options where output confirm].freeze
64
+ WIZARD = %i[target options exclude where output confirm].freeze
67
65
  UNPACK = %i[dest_where dest_name unpack_confirm].freeze
68
66
 
69
- # `pack` is a list of paths to tag before the first frame; when it is
70
- # non-empty the archive prompts open immediately (rakpak -p).
71
67
  def initialize(start_dir = Dir.home, pack: [])
72
68
  @sizer = Sizer.new
73
69
  @browser = Browser.new(start_dir, sizer: @sizer)
@@ -113,8 +109,7 @@ module Rakpak
113
109
 
114
110
  private
115
111
 
116
- # The quit dialog promises running jobs are stopped. Give each a moment
117
- # to remove its half-written archive before the terminal is handed back.
112
+ # Give jobs a moment to remove half-written archives before the terminal is restored.
118
113
  def stop_jobs
119
114
  running = @jobs.select(&:running?)
120
115
  return if running.empty?
@@ -160,8 +155,6 @@ module Rakpak
160
155
  "#{Text.bytes(j.output_size)} · #{Text.duration(j.elapsed)}#{more} [b] jobs", Theme::ACCENT]
161
156
  end
162
157
 
163
- # ------------------------------------------------------------- drawing
164
-
165
158
  MIN_W = 40
166
159
  MIN_H = 10
167
160
 
@@ -197,8 +190,6 @@ module Rakpak
197
190
  @screen.put([(@screen.w - Text.width(want)) / 2, 0].max, (@screen.h / 2) + 1, want, Theme::DIM)
198
191
  end
199
192
 
200
- # Only the running state says which direction the work goes; the rest
201
- # read the same either way.
202
193
  def job_title(job, state)
203
194
  case state
204
195
  when :running then job.plan.gerund
@@ -218,7 +209,6 @@ module Rakpak
218
209
 
219
210
  y = 2
220
211
  job.plan.outputs.each do |out|
221
- # A folder being extracted into has no size worth showing.
222
212
  size = begin
223
213
  File.directory?(out) ? nil : File.size(out)
224
214
  rescue StandardError
@@ -260,7 +250,7 @@ module Rakpak
260
250
 
261
251
  s.hline(0, y, s.w, Theme::BORDER)
262
252
  y += 1
263
- rows = [s.h - y - 1, 0].max # a very short terminal leaves no room at all
253
+ rows = [s.h - y - 1, 0].max
264
254
  job.tail(rows).each_with_index do |line, i|
265
255
  style = if line.start_with?("▸") then Theme::ACCENT
266
256
  elsif line.match?(/error|cannot|denied|warning/i) then Theme::WARN
@@ -287,8 +277,6 @@ module Rakpak
287
277
  end
288
278
  end
289
279
 
290
- # ------------------------------------------------------------ dispatch
291
-
292
280
  def dispatch(key)
293
281
  return modal_key(key) if @modal
294
282
  return job_key(key) if @mode == :job
@@ -300,6 +288,8 @@ module Rakpak
300
288
  when :tags then open_tags
301
289
  when :help then open_help
302
290
  when :jobs then open_jobs
291
+ when :resume then resume_wizard
292
+ when :unmarkable then notify("that one is being packed; untag it instead", Theme::WARN)
303
293
  end
304
294
  end
305
295
 
@@ -316,12 +306,12 @@ module Rakpak
316
306
  end
317
307
  when :enter
318
308
  @mode = :browse unless job.running?
319
- when "q" then request_quit # the footer says quit, so it quits
309
+ when "q" then request_quit
320
310
  end
321
311
  end
322
312
 
323
313
  def modal_key(key)
324
- # ctrl-c backs out of any panel; raw mode delivers it as a keystroke.
314
+ # Raw mode delivers ctrl-c as a keystroke.
325
315
  res = key == :ctrl_c ? :cancel : @modal.handle(key)
326
316
  return if res.nil?
327
317
 
@@ -340,13 +330,12 @@ module Rakpak
340
330
  return
341
331
  end
342
332
 
343
- return unless @wizard_pos
333
+ # While exclusions are being marked the wizard is paused; help closing must not move it.
334
+ return if @wizard_pos.nil? || @browser.scoped?
344
335
 
345
336
  res == :done ? wizard_forward(modal) : wizard_back
346
337
  end
347
338
 
348
- # -------------------------------------------------------------- wizard
349
-
350
339
  def start_wizard
351
340
  sel = @browser.selection
352
341
  if sel.empty?
@@ -359,6 +348,7 @@ module Rakpak
359
348
  sel.each { |p| @sizer.request(p) }
360
349
  @plan = Plan.new(paths: sel, outdir: @browser.cwd)
361
350
  @name_edited = false
351
+ @exclude_text = ""
362
352
  @where = 0
363
353
  @where_text = ""
364
354
  @wizard = WIZARD
@@ -366,8 +356,6 @@ module Rakpak
366
356
  open_wizard_step
367
357
  end
368
358
 
369
- # Unpacking follows the cursor rather than the tag set: an archive is one
370
- # thing with one destination, not a pile to gather up.
371
359
  def start_unpack
372
360
  e = @browser.current
373
361
  unless e && File.file?(e.path) && Unpack.archive?(e.path)
@@ -391,10 +379,6 @@ module Rakpak
391
379
  open_wizard_step
392
380
  end
393
381
 
394
- # A lone file loses its extension ("notes.txt" becomes notes.tar.gz)
395
- # unless the output is that file compressed on its own (notes.txt.gz).
396
- # A folder keeps its name whole, dots and all. Several items take the
397
- # folder name.
398
382
  def default_basename(sel)
399
383
  name = if sel.size > 1 then File.basename(@browser.cwd)
400
384
  elsif File.directory?(sel.first) || @plan&.single_compress? then File.basename(sel.first)
@@ -419,8 +403,8 @@ module Rakpak
419
403
  end
420
404
 
421
405
  def wizard_forward(modal)
422
- apply_step(@wizard[@wizard_pos], modal)
423
- return if @wizard_pos.nil? # a step may have ended the wizard itself
406
+ # A step may have ended the wizard itself, or paused it.
407
+ return if apply_step(@wizard[@wizard_pos], modal) == :paused || @wizard_pos.nil?
424
408
 
425
409
  @wizard_pos += 1
426
410
  open_wizard_step
@@ -446,6 +430,7 @@ module Rakpak
446
430
  case step
447
431
  when :target then target_modal
448
432
  when :options then options_modal
433
+ when :exclude then exclude_modal
449
434
  when :where then where_modal
450
435
  when :output then output_modal
451
436
  when :confirm then confirm_modal
@@ -458,11 +443,15 @@ module Rakpak
458
443
  def apply_step(step, modal)
459
444
  case step
460
445
  when :target then @plan.target = modal.result
446
+ when :exclude
447
+ @exclude_text = modal.text
448
+ return pick_excludes if modal.result == :tag
449
+
450
+ @plan.excludes = Plan.parse_excludes(modal.text)
461
451
  when :where
462
452
  @where = modal.index
463
453
  @where_text = modal.text
464
- # ~ and $HOME are for typed text; a browsed folder is taken as is,
465
- # even one with a dollar sign in its name.
454
+ # Only typed text expands ~ and $HOME; a browsed folder may contain a literal $.
466
455
  @plan.outdir = @where == 2 ? Rakpak.expand_dir(modal.result) : modal.result
467
456
  when :output
468
457
  @name_edited = true
@@ -518,7 +507,6 @@ module Rakpak
518
507
  end
519
508
  end
520
509
 
521
- # A codec, its level, then that format's switches.
522
510
  def codec_form(side, title:, label:, flags_label:, flags_when_container: false)
523
511
  rows = [
524
512
  FormModal::Row.new(kind: :choice, label: label, hint: "", values: side.choices,
@@ -540,6 +528,25 @@ module Rakpak
540
528
  end
541
529
  end
542
530
 
531
+ # Nothing to exclude from inside a lone file.
532
+ def exclude_modal
533
+ return nil unless @plan.paths.any? { |p| File.directory?(p) }
534
+
535
+ @plan.excluded = @browser.excludes.to_a
536
+ ExcludeModal.new(text: @exclude_text,
537
+ marked: @plan.excluded.map { |p| @plan.relative(p) })
538
+ end
539
+
540
+ def pick_excludes
541
+ @browser.scope_to(@plan.paths, @plan.base)
542
+ :paused
543
+ end
544
+
545
+ def resume_wizard
546
+ @browser.leave_scope
547
+ open_wizard_step
548
+ end
549
+
543
550
  def where_modal
544
551
  WhereModal.new(here: @browser.cwd, home: Dir.home, index: @where, text: @where_text,
545
552
  validate: method(:writable_folder))
@@ -557,7 +564,6 @@ module Rakpak
557
564
  validate: method(:writable_folder), title: "unpack it where?")
558
565
  end
559
566
 
560
- # nil when there is no folder to name, which open_wizard_step skips over.
561
567
  def dest_name_modal
562
568
  return nil if @plan.single?
563
569
 
@@ -565,16 +571,12 @@ module Rakpak
565
571
  value: @dest_name,
566
572
  hint: "a new folder in #{Text.tilde(@dest_parent)} · . unpacks straight in",
567
573
  validate: lambda { |name|
568
- # The folder was chosen on the previous screen, so this
569
- # is a bare name and can never reach outside it.
570
574
  if name.include?("/") then "just a name, no slashes; the folder was picked already"
571
575
  elsif name.include?("\0") || name == ".." then "not a valid name"
572
576
  end
573
577
  })
574
578
  end
575
579
 
576
- # "." is how you say "no subfolder, straight into the folder I picked",
577
- # and a lone compressed file never has one to begin with.
578
580
  def sync_dest
579
581
  @plan.dest = @dest_name == "." ? @dest_parent : File.join(@dest_parent, @dest_name)
580
582
  end
@@ -599,8 +601,6 @@ module Rakpak
599
601
  value: @plan.basename,
600
602
  hint: "in #{Text.tilde(@plan.outdir)} · #{@plan.ext} added automatically",
601
603
  validate: lambda { |name|
602
- # The folder was chosen on the previous screen; this is
603
- # a bare filename, so it can never reach outside it.
604
604
  if name.include?("/") then "just a name, no slashes; the folder was picked already"
605
605
  elsif name.include?("\0") || [".", ".."].include?(name) then "not a valid name"
606
606
  end
@@ -611,9 +611,12 @@ module Rakpak
611
611
  lines = []
612
612
  lines << [:head, "#{@plan.paths.size} item#{'s' unless @plan.paths.size == 1} " \
613
613
  "from #{Text.tilde(@plan.base)}"]
614
- @plan.members.first(6).each { |m| lines << [:key, " #{m}"] }
615
- lines << [:key, " … #{@plan.members.size - 6} more"] if @plan.members.size > 6
616
- lines << [:key, ""]
614
+ lines.concat(listing(@plan.members))
615
+ if @plan.excludes.any? || @plan.excluded.any?
616
+ lines << [:head, "leaves out"]
617
+ lines << [:key, " #{@plan.excludes.join(', ')}"] if @plan.excludes.any?
618
+ lines.concat(listing(@plan.excluded.map { |p| @plan.relative(p) }))
619
+ end
617
620
  lines << [:head, "writes #{Text.tilde(@plan.output)}"]
618
621
  lines << [:key, ""]
619
622
  @plan.preview.each do |label, cmd|
@@ -625,11 +628,16 @@ module Rakpak
625
628
  warnings: @plan.warnings, errors: @plan.problems)
626
629
  end
627
630
 
631
+ def listing(names)
632
+ lines = names.first(6).map { |m| [:key, " #{m}"] }
633
+ lines << [:key, " … #{names.size - 6} more"] if names.size > 6
634
+ lines << [:key, ""]
635
+ end
636
+
628
637
  def finish_wizard
629
638
  @wizard_pos = nil
630
639
  end
631
640
 
632
- # Always opens the job view; b from there sends it to the background.
633
641
  def launch
634
642
  job = Job.new(@plan, total_files: @plan.total_members(@sizer)).start
635
643
  @jobs << job
@@ -654,8 +662,6 @@ module Rakpak
654
662
  end
655
663
  end
656
664
 
657
- # --------------------------------------------------------------- panels
658
-
659
665
  def open_tags
660
666
  list = @browser.tags.to_a.sort
661
667
  modal = TagsModal.new(tags: list, sizer: @sizer)
@@ -679,8 +685,9 @@ module Rakpak
679
685
  ["g g", "top"], ["G", "bottom"], ["ctrl-d ctrl-u", "half page"],
680
686
  ["g h", "home"], ["g r", "root"], ["~", "home"],
681
687
  ["space", "tag / untag, then move down"],
688
+ ["x", "exclude / include, then move down"],
682
689
  ["a", "tag everything here"], ["d", "untag everything here"],
683
- ["D", "clear all tags"], ["T", "review tagged items"],
690
+ ["D", "clear all tags and exclusions"], ["T", "review tagged items"],
684
691
  ["t", "show / hide the queue pane"],
685
692
  ["/", "filter this folder"], [".", "show hidden"],
686
693
  ["ctrl-r", "reload"],
@@ -714,16 +721,12 @@ module Rakpak
714
721
  return if done.empty?
715
722
 
716
723
  done.each do |j|
717
- # Printed after the TUI has gone, straight to the shell, so a folder
718
- # or file name carrying an escape sequence must be defanged.
724
+ # Printed to the shell after the TUI exits, so defang escape sequences in names.
719
725
  puts "#{Text.plain(j.plan.outputs.first)} #{Text.plain(j.plan.report_note)}"
720
726
  end
721
727
  end
722
728
  end
723
729
 
724
- # The option form follows the codec the user just picked: the level row
725
- # takes that codec's range, and rows that only apply to a zip container
726
- # (its switches) appear only while a container method is selected.
727
730
  class DynamicForm < FormModal
728
731
  def initialize(title:, rows:, side:, extra: [], extra_when_container: false)
729
732
  super(title: title, rows: rows + extra)
@@ -2,24 +2,28 @@
2
2
 
3
3
  require "set"
4
4
  require_relative "dir_cache"
5
+ require_relative "plan"
5
6
  require_relative "theme"
6
7
  require_relative "text"
7
8
 
8
9
  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
10
  class Browser
12
- attr_reader :cwd, :tags, :filter
11
+ # Marking exclusions mid-pack: walking stays under root, back is where to return.
12
+ Scope = Struct.new(:root, :paths, :back)
13
+
14
+ attr_reader :cwd, :tags, :excludes, :filter
13
15
 
14
16
  def initialize(start_dir = Dir.pwd, sizer: nil)
15
17
  @dirs = DirCache.new
16
18
  @sizer = sizer
17
19
  @tags = Set.new
20
+ @excludes = Set.new
21
+ @scope = nil
18
22
  @cursor = {}
19
23
  @show_hidden = false
20
24
  @filter = nil
21
25
  @filtering = false
22
- @show_queue = true # on until someone presses t
26
+ @show_queue = true
23
27
  @pending = nil
24
28
  @preview_cache = {}
25
29
  @cwd = resolve(start_dir)
@@ -31,10 +35,12 @@ module Rakpak
31
35
  d
32
36
  end
33
37
 
34
- # ---------------------------------------------------------------- state
35
-
36
38
  def entries
37
39
  list = @dirs.list(@cwd, hidden: @show_hidden) || []
40
+ # Above the packed items, only they are reachable.
41
+ if @scope && @cwd == @scope.root && !@scope.paths.include?(@cwd)
42
+ list = list.select { |e| @scope.paths.include?(e.path) }
43
+ end
38
44
  return list unless @filter && !@filter.empty?
39
45
 
40
46
  needle = @filter.downcase
@@ -54,18 +60,19 @@ module Rakpak
54
60
  def show_queue? = @show_queue
55
61
  def filtering? = @filtering
56
62
 
63
+ def scoped? = !@scope.nil?
64
+
57
65
  def tag(path)
58
66
  path = File.expand_path(path)
67
+ @excludes.delete(path)
59
68
  return unless @tags.add?(path)
60
69
 
61
- # A figure may linger from a cursor-only pack of this path; a new tag
62
- # must not trust it.
70
+ # A figure may linger from a cursor-only pack of this path; don't trust it.
63
71
  @sizer&.forget(path)
64
72
  @sizer&.request(path)
65
73
  end
66
74
 
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.
75
+ # All tag removals must pass here so the sizer drops its now-stale figure.
69
76
  def untag(path)
70
77
  return unless @tags.delete?(path)
71
78
 
@@ -78,17 +85,55 @@ module Rakpak
78
85
  @tags.include?(entry.path) ? untag(entry.path) : tag(entry.path)
79
86
  end
80
87
 
88
+ def exclude(path)
89
+ path = File.expand_path(path)
90
+ untag(path)
91
+ @excludes << path
92
+ end
93
+
94
+ def toggle_exclude(entry = current)
95
+ return unless entry
96
+
97
+ @excludes.delete?(entry.path) || exclude(entry.path)
98
+ end
99
+
81
100
  def tag_all = entries.each { |e| tag(e.path) }
82
101
  def untag_all_here = entries.each { |e| untag(e.path) }
83
- def clear_tags = @tags.to_a.each { |p| untag(p) }
102
+
103
+ def clear_tags
104
+ @tags.to_a.each { |p| untag(p) }
105
+ @excludes.clear
106
+ end
107
+
108
+ # One packed folder opens inside it; several open at their common parent.
109
+ def scope_to(paths, base)
110
+ root = paths.size == 1 && File.directory?(paths.first) ? paths.first : base
111
+ @scope = Scope.new(root, paths, [@cwd, @cursor[@cwd], @filter])
112
+ @cwd = root
113
+ @filter = nil
114
+ end
115
+
116
+ def leave_scope
117
+ @cwd, cursor, @filter = @scope.back
118
+ @cursor[@cwd] = cursor
119
+ @scope = nil
120
+ end
121
+
122
+ def mark_in_scope
123
+ e = current
124
+ return unless e
125
+ return :unmarkable unless @scope.paths.any? { |p| Plan.inside?(e.path, p) }
126
+
127
+ toggle_exclude(e)
128
+ move(1)
129
+ nil
130
+ end
84
131
 
85
132
  def replace_tags(list)
86
133
  (@tags.to_a - list).each { |p| untag(p) }
87
134
  list.each { |p| tag(p) }
88
135
  end
89
136
 
90
- # Selections implied by the current state: explicit tags, else whatever
91
- # the cursor is sitting on.
92
137
  def selection
93
138
  return @tags.to_a.sort if @tags.any?
94
139
 
@@ -104,14 +149,10 @@ module Rakpak
104
149
  @tags.each { |p| @sizer.request(p) }
105
150
  end
106
151
 
107
- # ----------------------------------------------------------- navigation
108
-
109
152
  def move(delta)
110
153
  self.index = index + delta
111
154
  end
112
155
 
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
156
  def step(delta)
116
157
  n = entries.size
117
158
  return if n.zero?
@@ -130,7 +171,7 @@ module Rakpak
130
171
  end
131
172
 
132
173
  def ascend
133
- return if @cwd == "/"
174
+ return if @cwd == "/" || @cwd == @scope&.root
134
175
 
135
176
  child = @cwd
136
177
  @cwd = File.dirname(@cwd)
@@ -142,13 +183,13 @@ module Rakpak
142
183
  def goto(dir)
143
184
  d = File.expand_path(dir)
144
185
  return false unless File.directory?(d) && File.readable?(d)
186
+ return false if @scope && d != @scope.root && !Plan.inside?(d, @scope.root)
145
187
 
146
188
  @cwd = d
147
189
  @filter = nil
148
190
  true
149
191
  end
150
192
 
151
- # Open the folder holding `path` with the cursor on it.
152
193
  def jump_to(path)
153
194
  path = File.expand_path(path)
154
195
  return false unless goto(File.dirname(path))
@@ -159,7 +200,6 @@ module Rakpak
159
200
  !idx.nil?
160
201
  end
161
202
 
162
- # Returns :quit, :archive, :unpack, :tags, :help, :jobs or nil.
163
203
  def handle(key)
164
204
  return handle_filter(key) if @filtering
165
205
 
@@ -170,8 +210,15 @@ module Rakpak
170
210
  when "h" then return goto(Dir.home) && nil
171
211
  when "r" then return goto("/") && nil
172
212
  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.
213
+ # Not a chord: handle it as its own key so a stray g never swallows q, p or space.
214
+ end
215
+
216
+ if @scope
217
+ case key
218
+ when :space, "x" then return mark_in_scope
219
+ when "p", :esc then return :resume
220
+ when "a", "d", "D", "u", "T", "b" then return nil
221
+ end
175
222
  end
176
223
 
177
224
  result = case key
@@ -188,6 +235,9 @@ module Rakpak
188
235
  when :space
189
236
  toggle_tag
190
237
  move(1)
238
+ when "x"
239
+ toggle_exclude
240
+ move(1)
191
241
  when "a" then tag_all
192
242
  when "d" then untag_all_here
193
243
  when "D" then clear_tags
@@ -238,8 +288,6 @@ module Rakpak
238
288
 
239
289
  def page = @page_size || 10
240
290
 
241
- # -------------------------------------------------------------- drawing
242
-
243
291
  def draw(screen, status_line)
244
292
  @page_size = screen.h - 5
245
293
  draw_header(screen)
@@ -250,8 +298,6 @@ module Rakpak
250
298
  draw_footer(screen, screen.h - 1, status_line)
251
299
  end
252
300
 
253
- # Left to right: the queue (when shown), parent, current, preview. The
254
- # queue takes its slice first and the usual three share what is left.
255
301
  def layout(w)
256
302
  panes = []
257
303
  x = 0
@@ -281,7 +327,10 @@ module Rakpak
281
327
  def draw_header(screen)
282
328
  screen.fill(0, 0, screen.w, 1, " ", Theme::HEAD)
283
329
  screen.put(1, 0, "rakpak", Theme::HEAD + "\e[1;38;5;39m")
284
- right = tag_summary
330
+ right = if @scope then "marking exclusions"
331
+ elsif @excludes.any? then "#{tag_summary} · #{@excludes.size} excluded"
332
+ else tag_summary
333
+ end
285
334
  avail = screen.w - 10 - Text.width(right) - 3
286
335
  screen.put(9, 0, Text.fit_left(Text.tilde(@cwd), [avail, 1].max), Theme::HEAD + "\e[38;5;231m")
287
336
  screen.put(screen.w - Text.width(right) - 1, 0, right,
@@ -309,9 +358,6 @@ module Rakpak
309
358
  end
310
359
  end
311
360
 
312
- # Everything queued for the next pack, wherever on the disk it lives:
313
- # the name, then where it is, with the size at the edge and the running
314
- # total on top. Stays put while you keep browsing.
315
361
  def draw_queue(screen, pane, y, h)
316
362
  x = pane[:x]
317
363
  w = pane[:w]
@@ -363,12 +409,16 @@ module Rakpak
363
409
  row = y + i
364
410
  sel = (top + i) == cur
365
411
  tagged = @tags.include?(e.path)
412
+ excluded = @excludes.include?(e.path)
366
413
  bg = if sel && active then Theme::CUR_BG
367
414
  elsif sel then Theme::SEL_BG
368
415
  end
369
416
  screen.fill(x, row, w, 1, " ", bg) if bg
370
- mark_style = tagged ? (bg.to_s + Theme::TAG) : (bg.to_s + Theme::FAINT)
371
- screen.put(x, row, tagged ? " ▌" : " ", mark_style)
417
+ mark, mark_style = if tagged then [" ▌", Theme::TAG]
418
+ elsif excluded then [" ✗", Theme::ERR]
419
+ else [" ", Theme::FAINT]
420
+ end
421
+ screen.put(x, row, mark, bg.to_s + mark_style)
372
422
  name_style = bg ? bg + (sel && active ? "\e[1;38;5;231m" : e.style) : e.style
373
423
  avail = w - 3
374
424
  avail -= 5 unless pane[:kind] == :parent
@@ -423,8 +473,7 @@ module Rakpak
423
473
  end
424
474
 
425
475
  def file_preview(entry, w, h)
426
- # Reading a FIFO or a device blocks until someone writes to it, which
427
- # would freeze the UI with the cursor merely resting on the entry.
476
+ # Reading a FIFO or device blocks and would freeze the UI.
428
477
  kind = entry.target_stat
429
478
  return [["not a regular file", Theme::FAINT]] unless kind&.file?
430
479
 
@@ -444,7 +493,6 @@ module Rakpak
444
493
  [[e.class.name.split("::").last, Theme::ERR]]
445
494
  end
446
495
 
447
- # Bytes per row adapts to the pane so the ASCII column always fits.
448
496
  def hexdump(bytes, rows, width)
449
497
  return [] if rows <= 0
450
498
 
@@ -489,6 +537,8 @@ module Rakpak
489
537
 
490
538
  HINTS = [["space", "tag"], ["←→", "nav"], ["a", "all"], ["t", "queue"], ["/", "find"],
491
539
  [".", "hidden"], ["p", "pack"], ["u", "unpack"], ["?", "help"]].freeze
540
+ SCOPE_HINTS = [["space", "exclude"], ["←→", "nav"], ["/", "find"], [".", "hidden"],
541
+ ["p", "back to pack"], ["?", "help"]].freeze
492
542
 
493
543
  def draw_footer(screen, y, status_line)
494
544
  screen.fill(0, y, screen.w, 1, " ", nil)
@@ -497,7 +547,7 @@ module Rakpak
497
547
  return
498
548
  end
499
549
  x = 1
500
- HINTS.each do |k, label|
550
+ (@scope ? SCOPE_HINTS : HINTS).each do |k, label|
501
551
  break if x + Text.width(k) + Text.width(label) + 3 > screen.w
502
552
 
503
553
  x = screen.put(x, y, k, Theme::KEY)
@@ -3,8 +3,6 @@
3
3
  require_relative "entry"
4
4
 
5
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
6
  class DirCache
9
7
  MAX_ENTRIES = 20_000
10
8
 
@@ -31,7 +29,7 @@ module Rakpak
31
29
  names.reject! { |n| n.start_with?(".") } unless hidden
32
30
  names = names.first(MAX_ENTRIES)
33
31
  names.map { |n| Entry.new(File.join(path, n), n) }.sort_by(&:sort_key)
34
- rescue StandardError # EACCES, ENOENT, ENOTDIR, ELOOP and the rest
32
+ rescue StandardError
35
33
  nil
36
34
  end
37
35
  end