rtfm-filemanager 8.2.0 → 8.2.1

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: ef7c28629f7646f129d96e76afd871a7ab6030e0adb9983055a247ccc3992ecc
4
- data.tar.gz: b1c7f85c145f532f5e3175c5fb2f4642d134149d147fc06b4a26389f25d1f42c
3
+ metadata.gz: eb02bbf2f12ccc66afd6d2fbbef68f6e7a1a05f3d932e48a8143ae8df1a70d39
4
+ data.tar.gz: e1a1583ccab118308ecf16d898631dff1fcddedb3b46f7462ba2a8486cfad5d1
5
5
  SHA512:
6
- metadata.gz: 9207a4adaeed8a14f3a7a78a70fafdcba2318635605406168679aefe6f2e859cedc7c4053ebcf0d08f610415874df41f061fedad1fd41955cbcc7ae1eb2206a9
7
- data.tar.gz: 1c8b70f11a2436b5beedebf1b0a510395e62e93a99ed1050192f381097693816731864f46c86a22eeebb8139ae9bb09367625c01ad9856142cffb2b271fdb53d
6
+ metadata.gz: 8eaeb2806d03dd15601d705bc409b677cbbb3bbe08aa69a1cd8b9135bbf4b5ecfc95c8140b4c9d9e526eb4652fa9057f4102aec05058d6c94272b39027d3e33a
7
+ data.tar.gz: f9cb7747b1c773a2c545bb1afaa600ceff1a04d13149d9a98cae029687a37cbeccb0b330951659463fd666206104234d5ae0f6298c5cef2ff01d3950be9063ae
data/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to RTFM will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [8.2.1] - 2026-03-16
9
+
10
+ ### Added
11
+ - **Trash browser** (`D` key) - Browse trash contents with timestamps and original paths. Restore files, permanently delete individual items, or empty all trash
12
+ - **Disk usage plugin** (`S` key) - Interactive size-sorted directory analyzer with visual bars and cancellable scanning
13
+ - **Duplicate finder plugin** (`F7`) - Find duplicate files by content hash with tag-to-keep workflow and cancellable scanning
14
+
15
+ ### Fixed
16
+ - **Right pane scrolling** - Shift-Down/Up now works after displaying content via `say` (marks, help, system info, etc.)
17
+ - **rcurses pane refresh** - No longer queries cursor position via stdin (uses ANSI save/restore), preventing keypresses from being consumed during progress updates
18
+
8
19
  ## [8.2.0] - 2026-03-15
9
20
 
10
21
  ### Added
data/README.md CHANGED
@@ -211,7 +211,7 @@ For complete reference: `man rtfm` or press `?` in RTFM
211
211
  | `c` | Rename item |
212
212
  | `E` | Bulk rename (patterns) |
213
213
  | `d` | Delete (→ trash if enabled) |
214
- | `D` | Empty trash |
214
+ | `D` | Trash browser (browse, restore, delete) |
215
215
  | `Ctrl-d` | Toggle trash on/off |
216
216
  | `U` | Undo last operation |
217
217
 
@@ -515,6 +515,8 @@ cp examples/git.rb ~/.rtfm/plugins/
515
515
  cp examples/bookmarks.rb ~/.rtfm/plugins/
516
516
  cp examples/notes.rb ~/.rtfm/plugins/
517
517
  cp examples/opener.rb ~/.rtfm/plugins/
518
+ cp examples/diskusage.rb ~/.rtfm/plugins/
519
+ cp examples/dupes.rb ~/.rtfm/plugins/
518
520
  ```
519
521
 
520
522
  | Plugin | Key | Description |
@@ -524,6 +526,8 @@ cp examples/opener.rb ~/.rtfm/plugins/
524
526
  | **Bookmarks** | `F6` | Unlimited directory bookmarks with fuzzy filtering. Add, delete, and jump to bookmarked directories. Complements the built-in single-letter marks (m/') |
525
527
  | **Notes** | `F5` | Attach text notes to any file or directory. View, edit, or delete notes. Stored in `~/.rtfm/notes/` |
526
528
  | **Opener** | `RIGHT`/`l` | Custom file openers by extension. Configure a hash of extension-to-command mappings (e.g., `.hl` files open in hyperlist) |
529
+ | **Disk Usage** | `S` | Interactive disk usage analyzer. Extends system info with a browseable size-sorted directory view with visual bars |
530
+ | **Dupes** | `F7` | Find duplicate files by content hash. Two-pass scan (size then SHA256) with in-place deletion of duplicates |
527
531
 
528
532
  ### Writing Your Own Plugins
529
533
 
@@ -692,7 +696,8 @@ Best image experience with: kitty, urxvt, xterm, mlterm, foot
692
696
  ### Version 8.2 Highlights
693
697
 
694
698
  - **Plugin system with live enable/disable** - Plugins in `~/.rtfm/plugins/` are auto-loaded on startup. Toggle them on and off at runtime with the plugin manager (`V` key), no restart needed. Each plugin can register help text viewable with `?`.
695
- - **Five example plugins** - Settings editor, git operations, directory bookmarks, file notes, and custom file openers. Copy from `examples/` to `~/.rtfm/plugins/` to use.
699
+ - **Seven example plugins** - Settings editor, git operations, directory bookmarks, file notes, custom file openers, disk usage analyzer, and duplicate file finder. Copy from `examples/` to `~/.rtfm/plugins/` to use.
700
+ - **Trash browser** (`D` key) - Browse trash contents with timestamps and original paths. Restore files to their original location, permanently delete individual items, or empty all trash.
696
701
 
697
702
  ### Version 8.1 Highlights
698
703
 
data/bin/rtfm CHANGED
@@ -18,7 +18,7 @@
18
18
  # get a great understanding of the code itself by simply sending
19
19
  # or pasting this whole file into you favorite AI for coding with
20
20
  # a prompt like this: "Help me understand every part of this code".
21
- @version = '8.2.0' # Plugin system with live enable/disable and built-in plugin manager
21
+ @version = '8.2.1' # Trash browser, right pane scroll fix, plugin improvements
22
22
 
23
23
  # SAVE & STORE TERMINAL {{{1
24
24
  ORIG_STTY = `stty -g`.chomp
@@ -242,7 +242,7 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
242
242
  s = Create symlink to tagged items here
243
243
  d = Delete selected item and tagged items. Confirm with 'y'.
244
244
  Moves items to trash directory (~/.rtfm/trash/) if @trash = true
245
- D = Empty trash directory
245
+ D = Trash browser (browse, restore, delete, empty trash)
246
246
  Ctrl-d = Toggle use of trash directory
247
247
 
248
248
  UNDO OPERATIONS
@@ -4204,14 +4204,152 @@ def empty_trash # {{{3
4204
4204
  remote_download_selected
4205
4205
  return
4206
4206
  end
4207
- @pB.say(" Really empty Trash (~/.rtfm/trash)? (press 'y')")
4208
- return unless getchr == 'y'
4207
+ trash_browser
4208
+ end
4209
+
4210
+ def trash_browser # TRASH BROWSER {{{3
4211
+ clear_image
4212
+ sel = 0
4213
+ offset = 0
4214
+
4215
+ loop do
4216
+ # Scan trash directory, build item list with metadata
4217
+ raw = Dir.entries(TRASH_DIR).reject { |e| e == '.' || e == '..' }.map { |e| File.join(TRASH_DIR, e) }
4218
+ entries = raw.select { |f| File.exist?(f) || File.symlink?(f) }
4219
+ entries.sort_by! { |f| File.mtime(f) rescue Time.at(0) }
4220
+ entries.reverse!
4221
+ items = entries.filter_map do |f|
4222
+ basename = File.basename(f)
4223
+ orig_name = basename.sub(/^\d{8}_\d{6}_/, '').sub(/_\d{4}$/, '')
4224
+ orig_path = nil
4225
+ @undo_history.each do |op|
4226
+ next unless op[:type] == 'delete' && op[:trash]
4227
+ op[:paths].each { |p| orig_path = p[:path] if p[:trash_name] == basename }
4228
+ end
4229
+ begin
4230
+ size = File.directory?(f) ? (command("du -sb #{Shellwords.escape(f)} 2>/dev/null").split.first.to_i rescue 0) : File.size(f)
4231
+ mtime = File.mtime(f)
4232
+ rescue
4233
+ size = 0
4234
+ mtime = Time.at(0)
4235
+ end
4236
+ { path: f, basename: basename, orig_name: orig_name, orig_path: orig_path,
4237
+ size: size, mtime: mtime, dir: File.directory?(f) }
4238
+ end
4239
+
4240
+ sel = [sel, items.size - 1, 0].sort[1]
4241
+ page_h = @pR.h - 5
4242
+ page_h = 1 if page_h < 1
4243
+ offset = sel - page_h + 1 if sel >= offset + page_h
4244
+ offset = sel if sel < offset
4245
+ offset = 0 if offset < 0
4246
+ visible = items[offset, page_h] || []
4209
4247
 
4210
- command("rm -rf #{TRASH_DIR}/*")
4211
- @pB.say('Trash is now empty')
4248
+ lines = []
4249
+ lines << "j/k:move r:restore d:delete E:empty all q:close".fg(240)
4250
+ lines << "Trash".b.fg(254) + " (#{items.size} items)".fg(240)
4251
+ lines << ""
4252
+
4253
+ if items.empty?
4254
+ lines << "Trash is empty.".fg(240)
4255
+ else
4256
+ visible.each_with_index do |item, i|
4257
+ idx = offset + i
4258
+ size_s = human_size(item[:size])
4259
+ type_indicator = item[:dir] ? "/".fg(69) : ""
4260
+ age = time_ago(item[:mtime])
4261
+ name = item[:orig_name] + type_indicator
4262
+ origin = item[:orig_path] ? " #{File.dirname(item[:orig_path])}".fg(240) : ""
4263
+ line = "#{size_s.rjust(8).fg(249)} #{age.rjust(6).fg(240)} #{name}#{origin}"
4264
+ line = idx == sel ? line.u : line
4265
+ lines << line
4266
+ end
4267
+ lines << "(#{sel + 1}/#{items.size})".fg(240) if items.size > page_h
4268
+ end
4269
+
4270
+ @pR.update = true
4271
+ @pR.say(lines.join("\n"))
4272
+
4273
+ chr = getchr
4274
+ case chr
4275
+ when 'q', 'ESC'
4276
+ break
4277
+ when 'j', 'DOWN'
4278
+ sel = items.empty? ? 0 : (sel + 1) % items.size
4279
+ when 'k', 'UP'
4280
+ sel = items.empty? ? 0 : (sel - 1) % items.size
4281
+ when 'r'
4282
+ next if items.empty?
4283
+ item = items[sel]
4284
+ if item[:orig_path]
4285
+ dest = item[:orig_path]
4286
+ else
4287
+ dest = @pCmd.ask('Restore to: ', File.join(Dir.pwd, item[:orig_name]))
4288
+ end
4289
+ next if dest.strip.empty?
4290
+ if File.exist?(dest)
4291
+ @pB.say("Destination exists: #{dest}".fg(196))
4292
+ next
4293
+ end
4294
+ begin
4295
+ FileUtils.mv(item[:path], dest)
4296
+ # Remove from undo history
4297
+ @undo_history.each do |op|
4298
+ next unless op[:type] == 'delete' && op[:trash]
4299
+ op[:paths].reject! { |p| p[:trash_name] == item[:basename] }
4300
+ end
4301
+ @pB.say("Restored: #{File.basename(dest)}".fg(156))
4302
+ sel = [sel - 1, 0].max
4303
+ rescue => e
4304
+ @pB.say("Restore failed: #{e.message}".fg(196))
4305
+ end
4306
+ when 'd'
4307
+ next if items.empty?
4308
+ item = items[sel]
4309
+ @pB.say(" Permanently delete #{item[:orig_name]}? (y/n)".fg(196))
4310
+ if getchr == 'y'
4311
+ command("rm -rf #{Shellwords.escape(item[:path])}")
4312
+ @pB.say("Deleted: #{item[:orig_name]}".fg(204))
4313
+ sel = [sel - 1, 0].max
4314
+ end
4315
+ when 'E'
4316
+ @pB.say(" Empty ALL trash? This cannot be undone! (y/n)".fg(196))
4317
+ if getchr == 'y'
4318
+ command("rm -rf #{TRASH_DIR}/*")
4319
+ @undo_history.reject! { |op| op[:type] == 'delete' && op[:trash] }
4320
+ @pB.say("Trash emptied.".fg(204))
4321
+ sel = 0
4322
+ end
4323
+ end
4324
+ end
4325
+
4326
+ @pR.update = true
4327
+ refresh
4212
4328
  render
4213
4329
  end
4214
4330
 
4331
+ def human_size(bytes) # {{{3
4332
+ if bytes >= 1_073_741_824
4333
+ "%.1fG" % (bytes / 1_073_741_824.0)
4334
+ elsif bytes >= 1_048_576
4335
+ "%.1fM" % (bytes / 1_048_576.0)
4336
+ elsif bytes >= 1024
4337
+ "%.1fK" % (bytes / 1024.0)
4338
+ else
4339
+ "#{bytes}B"
4340
+ end
4341
+ end
4342
+
4343
+ def time_ago(time) # {{{3
4344
+ seconds = Time.now - time
4345
+ if seconds < 60 then "now"
4346
+ elsif seconds < 3600 then "#{(seconds / 60).to_i}m"
4347
+ elsif seconds < 86400 then "#{(seconds / 3600).to_i}h"
4348
+ elsif seconds < 604800 then "#{(seconds / 86400).to_i}d"
4349
+ else "#{(seconds / 604800).to_i}w"
4350
+ end
4351
+ end
4352
+
4215
4353
  def toggle_trash # {{{3
4216
4354
  @trash = !@trash
4217
4355
  @pB.say("Trash (~/.rtfm/trash) = #{@trash ? 'On' : 'Off'}")
@@ -4720,19 +4858,19 @@ def refresh_right # {{{3
4720
4858
  end
4721
4859
 
4722
4860
  def line_down_right # {{{3
4723
- @pR.linedown; @pB.update = true
4861
+ @pR.update = true; @pR.linedown; @pR.update = false; @pB.update = true
4724
4862
  end
4725
4863
 
4726
4864
  def line_up_right # {{{3
4727
- @pR.lineup; @pB.update = true
4865
+ @pR.update = true; @pR.lineup; @pR.update = false; @pB.update = true
4728
4866
  end
4729
4867
 
4730
4868
  def page_down_right # {{{3
4731
- @pR.pagedown; @pB.update = true
4869
+ @pR.update = true; @pR.pagedown; @pR.update = false; @pB.update = true
4732
4870
  end
4733
4871
 
4734
4872
  def page_up_right # {{{3
4735
- @pR.pageup; @pB.update = true
4873
+ @pR.update = true; @pR.pageup; @pR.update = false; @pB.update = true
4736
4874
  end
4737
4875
 
4738
4876
  # CLIPBOARD COPY {{{2
@@ -0,0 +1,199 @@
1
+ # @name: Disk Usage
2
+ # @description: Interactive disk usage analyzer for current directory
3
+ # @key: S (extends system info)
4
+
5
+ KEYMAP['S'] = :diskusage_menu
6
+
7
+ PLUGIN_HELP['Disk Usage'] = <<~HELP
8
+ Interactive disk usage analyzer for the current directory.
9
+
10
+ Press S to open the system/disk usage menu.
11
+
12
+ #{"Menu:".b}
13
+ s Show system info (original S behavior)
14
+ d Open disk usage analyzer
15
+
16
+ #{"Disk Usage Analyzer:".b}
17
+ j/k Navigate up/down
18
+ ENTER Drill into selected directory
19
+ LEFT/h Go up to parent directory
20
+ q/ESC Close analyzer
21
+
22
+ Scanning can be cancelled with ESC. The analyzer
23
+ shows all files and directories sorted by size
24
+ (largest first), with visual bars and human-readable
25
+ sizes. Directories are shown in blue.
26
+ HELP
27
+
28
+ def diskusage_menu
29
+ clear_image
30
+
31
+ lines = []
32
+ lines << "System / Disk Usage".b.fg(254)
33
+ lines << ""
34
+ lines << "s".b.fg(112) + " System info"
35
+ lines << "d".b.fg(112) + " Disk usage analyzer"
36
+ lines << ""
37
+ lines << "q/ESC: close".fg(240)
38
+
39
+ @pR.update = true
40
+ @pR.say(lines.join("\n"))
41
+
42
+ chr = getchr
43
+ case chr
44
+ when 's'
45
+ system_info
46
+ when 'd'
47
+ diskusage_browse(Dir.pwd)
48
+ when 'q', 'ESC'
49
+ @pR.update = true
50
+ refresh
51
+ render
52
+ end
53
+ end
54
+
55
+ def diskusage_browse(start_dir)
56
+ browse_dir = start_dir
57
+ sel = 0
58
+ offset = 0
59
+ cached = {} # dir => entries
60
+
61
+ loop do
62
+ entries = cached[browse_dir] || diskusage_scan(browse_dir)
63
+ cached[browse_dir] = entries if entries
64
+ entries ||= []
65
+ total = entries.sum { |e| e[:size] }
66
+ max_size = entries.map { |e| e[:size] }.max || 1
67
+ page_h = @pR.h - 6
68
+ page_h = 1 if page_h < 1
69
+
70
+ sel = 0 if entries.empty?
71
+ sel = entries.size - 1 if sel >= entries.size && !entries.empty?
72
+
73
+ offset = sel - page_h + 1 if sel >= offset + page_h
74
+ offset = sel if sel < offset
75
+ offset = 0 if offset < 0
76
+
77
+ visible = entries[offset, page_h] || []
78
+
79
+ lines = []
80
+ lines << "Disk Usage".b.fg(254) + " " + browse_dir.fg(240)
81
+ lines << ("Total: " + diskusage_human(total)).fg(249)
82
+ lines << ""
83
+
84
+ bar_width = @pR.w - 30
85
+ bar_width = 10 if bar_width < 10
86
+
87
+ visible.each_with_index do |entry, i|
88
+ idx = offset + i
89
+ ratio = max_size > 0 ? entry[:size].to_f / max_size : 0
90
+ filled = (ratio * bar_width).round
91
+ bar = "\u2588" * filled + "\u2591" * (bar_width - filled)
92
+
93
+ name_color = entry[:dir] ? 69 : 249
94
+ size_str = diskusage_human(entry[:size]).rjust(9)
95
+ name = entry[:name]
96
+ name += "/" if entry[:dir]
97
+
98
+ line = bar.fg(entry[:dir] ? 69 : 243) + " " +
99
+ size_str.fg(156) + " " +
100
+ name.fg(name_color)
101
+ line = idx == sel ? line.u : line
102
+ lines << line
103
+ end
104
+
105
+ if entries.size > page_h
106
+ lines << ""
107
+ lines << "(#{sel + 1}/#{entries.size})".fg(240)
108
+ end
109
+
110
+ @pR.update = true
111
+ @pR.say(lines.join("\n"))
112
+
113
+ chr = getchr
114
+ case chr
115
+ when 'j', 'DOWN'
116
+ sel = (sel + 1) % entries.size unless entries.empty?
117
+ when 'k', 'UP'
118
+ sel = (sel - 1) % entries.size unless entries.empty?
119
+ when 'ENTER'
120
+ next if entries.empty?
121
+ entry = entries[sel]
122
+ if entry[:dir]
123
+ browse_dir = File.join(browse_dir, entry[:name])
124
+ sel = 0
125
+ offset = 0
126
+ end
127
+ when 'LEFT', 'h'
128
+ parent = File.dirname(browse_dir)
129
+ if parent != browse_dir
130
+ browse_dir = parent
131
+ sel = 0
132
+ offset = 0
133
+ end
134
+ when 'q', 'ESC'
135
+ break
136
+ end
137
+ end
138
+
139
+ @pR.update = true
140
+ refresh
141
+ render
142
+ end
143
+
144
+ def diskusage_scan(dir)
145
+ return [] unless Dir.exist?(dir)
146
+
147
+ entries = []
148
+ # Scan entries one by one for progress and cancellability
149
+ children = begin
150
+ Dir.entries(dir).reject { |e| e == '.' || e == '..' }
151
+ rescue
152
+ return []
153
+ end
154
+
155
+ children.each_with_index do |name, i|
156
+ # Check for ESC to cancel
157
+ begin
158
+ $stdin.read_nonblock(1)
159
+ $stdin.read_nonblock(16) rescue nil
160
+ @pB.say("Scan cancelled.".fg(220))
161
+ return entries.sort_by { |e| -e[:size] }
162
+ rescue IO::WaitReadable, EOFError
163
+ # No keypress, continue scanning
164
+ end
165
+
166
+ @pB.update = true
167
+ @pB.say(" Scanning #{i + 1}/#{children.size}: #{name}")
168
+ @pB.refresh
169
+
170
+ path = File.join(dir, name)
171
+ is_dir = File.directory?(path) rescue false
172
+ begin
173
+ if is_dir
174
+ out = `du -sb #{Shellwords.escape(path)} 2>/dev/null`.strip
175
+ size = out.split("\t").first.to_i
176
+ else
177
+ size = File.size(path) rescue 0
178
+ end
179
+ rescue
180
+ size = 0
181
+ end
182
+ entries << { name: name, size: size, dir: is_dir }
183
+ end
184
+
185
+ @pB.say("")
186
+ entries.sort_by { |e| -e[:size] }
187
+ end
188
+
189
+ def diskusage_human(bytes)
190
+ if bytes >= 1024 * 1024 * 1024
191
+ format("%.1f GB", bytes.to_f / (1024 * 1024 * 1024))
192
+ elsif bytes >= 1024 * 1024
193
+ format("%.1f MB", bytes.to_f / (1024 * 1024))
194
+ elsif bytes >= 1024
195
+ format("%.1f KB", bytes.to_f / 1024)
196
+ else
197
+ "#{bytes} B"
198
+ end
199
+ end
data/examples/dupes.rb ADDED
@@ -0,0 +1,335 @@
1
+ # @name: Dupes
2
+ # @description: Find duplicate files by content hash in current directory
3
+ # @key: F7
4
+
5
+ require 'digest'
6
+ require 'shellwords'
7
+
8
+ KEYMAP['F7'] = :find_dupes
9
+
10
+ PLUGIN_HELP['Dupes'] = <<~HELP
11
+ Find duplicate files by content hash.
12
+
13
+ Press F7 to scan the current directory for files
14
+ that share identical content.
15
+
16
+ #{"Scanning:".b}
17
+ Files are first grouped by size, then only
18
+ same-size files are hashed (SHA256) to confirm
19
+ duplicates. Press 'r' at the start prompt to
20
+ scan recursively.
21
+
22
+ #{"Navigation:".b}
23
+ j/k Move between duplicate groups
24
+ PgDn/PgUp Jump 10 groups
25
+ LEFT/RIGHT Move between files in a group
26
+ t Tag file to KEEP
27
+ d Delete all untagged files in group
28
+ q/ESC Close
29
+
30
+ #{"Deletion:".b}
31
+ If trash mode is enabled, deleted files are
32
+ moved to the trash and can be undone. Otherwise
33
+ files are permanently removed.
34
+
35
+ #{"Display:".b}
36
+ Groups are sorted by wasted space (largest
37
+ first). Each group shows a partial hash, file
38
+ size, and the list of duplicate paths.
39
+ HELP
40
+
41
+ def find_dupes
42
+ clear_image
43
+
44
+ @pB.say("Scan duplicates: ENTER=current dir, r=recursive, q=cancel")
45
+ chr = getchr
46
+ return if chr == 'q' || chr == 'ESC'
47
+ recursive = (chr == 'r')
48
+
49
+ dupes_progress("Collecting files#{recursive ? ' recursively' : ''}... (any key to cancel)")
50
+
51
+ # Collect files with cancellation support
52
+ all_files = []
53
+ cancelled = false
54
+ if recursive
55
+ dirs = [Dir.pwd]
56
+ while dirs.any?
57
+ break if cancelled
58
+ dir = dirs.shift
59
+ begin
60
+ Dir.foreach(dir) do |entry|
61
+ next if entry == '.' || entry == '..'
62
+ path = File.join(dir, entry)
63
+ if File.directory?(path) && !File.symlink?(path)
64
+ dirs << path
65
+ elsif File.file?(path) && !File.symlink?(path)
66
+ all_files << path
67
+ end
68
+ if all_files.size % 200 == 0
69
+ dupes_progress("Collecting: #{all_files.size} files... (any key to cancel)")
70
+ if dupes_cancelled?
71
+ cancelled = true
72
+ break
73
+ end
74
+ end
75
+ end
76
+ rescue Errno::EACCES, Errno::ENOENT
77
+ next
78
+ end
79
+ end
80
+ else
81
+ Dir.foreach(Dir.pwd) do |entry|
82
+ next if entry == '.' || entry == '..'
83
+ path = File.join(Dir.pwd, entry)
84
+ all_files << path if File.file?(path) && !File.symlink?(path)
85
+ end
86
+ end
87
+
88
+ if cancelled && all_files.empty?
89
+ @pB.say("Scan cancelled.")
90
+ return
91
+ end
92
+
93
+ if all_files.empty?
94
+ @pB.say("No files found.")
95
+ return
96
+ end
97
+
98
+ # Pass 1: group by size (with cancellation)
99
+ by_size = {}
100
+ all_files.each_with_index do |f, i|
101
+ if i % 500 == 0
102
+ dupes_progress("Pass 1/2: sizing #{i}/#{all_files.size}... (any key to cancel)")
103
+ if dupes_cancelled?
104
+ cancelled = true
105
+ break
106
+ end
107
+ end
108
+ begin
109
+ sz = File.size(f)
110
+ (by_size[sz] ||= []) << f
111
+ rescue
112
+ next
113
+ end
114
+ end
115
+ by_size.delete(0)
116
+ candidates = by_size.values.select { |g| g.size > 1 }
117
+
118
+ if candidates.empty?
119
+ @pB.say(cancelled ? "Scan cancelled. No duplicates in partial results." : "No duplicate candidates (all files have unique sizes).")
120
+ return
121
+ end
122
+
123
+ # Pass 2: hash same-size files (with cancellation)
124
+ total = candidates.sum(&:size)
125
+ done = 0
126
+ by_hash = {}
127
+ candidates.each do |group|
128
+ break if cancelled
129
+ group.each do |f|
130
+ done += 1
131
+ if done % 10 == 1 || done == total
132
+ dupes_progress("Pass 2/2: hashing #{done}/#{total}... (any key to cancel)")
133
+ if IO.select([$stdin], nil, nil, 0)
134
+ $stdin.read_nonblock(16) rescue nil
135
+ cancelled = true
136
+ break
137
+ end
138
+ end
139
+ begin
140
+ digest = Digest::SHA256.new
141
+ File.open(f, 'rb') do |io|
142
+ buf = String.new(capacity: 65536)
143
+ while io.read(65536, buf)
144
+ digest.update(buf)
145
+ end
146
+ end
147
+ hex = digest.hexdigest
148
+ (by_hash[hex] ||= []) << f
149
+ rescue
150
+ next
151
+ end
152
+ end
153
+ end
154
+
155
+ if cancelled
156
+ @pB.say("Scan cancelled. Showing partial results...")
157
+ end
158
+
159
+ dupes = by_hash.values.select { |g| g.size > 1 }
160
+
161
+ if dupes.empty?
162
+ @pB.say("No duplicates found (same-size files had different content).")
163
+ return
164
+ end
165
+
166
+ # Sort by wasted space descending
167
+ dupes.sort_by! { |g| -(File.size(g[0]) * (g.size - 1)) rescue 0 }
168
+
169
+ # Precompute group metadata
170
+ group_meta = dupes.map do |group|
171
+ sz = File.size(group[0]) rescue 0
172
+ { size: sz, waste: sz * (group.size - 1) }
173
+ end
174
+ wasted_total = group_meta.sum { |m| m[:waste] }
175
+
176
+ group_idx = 0
177
+ file_idx = 0
178
+ kept = {} # group_idx => Set of file indices to keep
179
+
180
+ loop do
181
+ group = dupes[group_idx]
182
+ meta = group_meta[group_idx]
183
+ kept[group_idx] ||= []
184
+
185
+ lines = []
186
+ lines << "j/k:group LEFT/RIGHT:file t:keep d:delete untagged q:close".fg(240)
187
+ lines << "Duplicate Files".b.fg(254) + " (#{group_idx + 1}/#{dupes.size} groups, #{format_bytes(wasted_total)} wasted)".fg(240)
188
+ lines << ""
189
+
190
+ header = "#{format_bytes(meta[:size])} x#{group.size} (#{format_bytes(meta[:waste])} wasted)"
191
+ lines << header.b.fg(112)
192
+
193
+ group.each_with_index do |f, fi|
194
+ rel = f.start_with?(Dir.pwd + '/') ? f.sub(Dir.pwd + '/', '') : f
195
+ is_kept = kept[group_idx].include?(fi)
196
+ marker = is_kept ? " KEEP ".bg(22).fg(255) : " "
197
+ label = "#{marker} #{rel}"
198
+ label = fi == file_idx ? label.u.fg(254) : label.fg(250)
199
+ lines << label
200
+ end
201
+
202
+ lines << ""
203
+ # Show neighboring groups for context (compact, no file listing)
204
+ context_start = [group_idx - 2, 0].max
205
+ context_end = [group_idx + 5, dupes.size - 1].min
206
+ (context_start..context_end).each do |gi|
207
+ next if gi == group_idx
208
+ g = dupes[gi]
209
+ m = group_meta[gi]
210
+ lines << " [#{gi + 1}] #{format_bytes(m[:size])} x#{g.size} (#{format_bytes(m[:waste])} wasted)".fg(240)
211
+ end
212
+
213
+ @pR.update = true
214
+ @pR.say(lines.join("\n"))
215
+
216
+ chr = getchr
217
+ case chr
218
+ when 'q', 'ESC'
219
+ break
220
+ when 'j', 'DOWN'
221
+ group_idx = (group_idx + 1) % dupes.size
222
+ file_idx = 0
223
+ when 'k', 'UP'
224
+ group_idx = (group_idx - 1) % dupes.size
225
+ file_idx = 0
226
+ when 'PgDOWN'
227
+ group_idx = [group_idx + 10, dupes.size - 1].min
228
+ file_idx = 0
229
+ when 'PgUP'
230
+ group_idx = [group_idx - 10, 0].max
231
+ file_idx = 0
232
+ when 'RIGHT', 'l', 'ENTER'
233
+ file_idx = (file_idx + 1) % group.size
234
+ when 'LEFT', 'h'
235
+ file_idx = (file_idx - 1) % group.size
236
+ when 't'
237
+ # Toggle keep tag on current file
238
+ if kept[group_idx].include?(file_idx)
239
+ kept[group_idx].delete(file_idx)
240
+ else
241
+ kept[group_idx] << file_idx
242
+ end
243
+ file_idx = (file_idx + 1) % group.size if file_idx < group.size - 1
244
+ when 'd'
245
+ # Delete all files NOT tagged as keep
246
+ to_keep = kept[group_idx] || []
247
+ if to_keep.empty?
248
+ @pB.say("Tag at least one file to keep first (press t).")
249
+ next
250
+ end
251
+ to_delete = group.each_index.reject { |i| to_keep.include?(i) }
252
+ if to_delete.empty?
253
+ @pB.say("All files tagged as keep, nothing to delete.")
254
+ next
255
+ end
256
+ names = to_delete.map { |i| File.basename(group[i]) }
257
+ @pB.say("Delete #{to_delete.size} files? (y/n)")
258
+ if getchr == 'y'
259
+ deleted = 0
260
+ to_delete.sort.reverse.each do |fi|
261
+ path = group[fi]
262
+ begin
263
+ if @trash
264
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
265
+ trash_name = "#{timestamp}_#{File.basename(path)}_#{rand(1000..9999)}"
266
+ trash_path = File.join(TRASH_DIR, trash_name)
267
+ command("mv -f #{Shellwords.escape(path)} #{Shellwords.escape(trash_path)}")
268
+ add_undo_operation({ type: 'delete', trash: true, paths: [{ path: path, trash_name: trash_name }], timestamp: Time.now })
269
+ else
270
+ command("rm -rf #{Shellwords.escape(path)}")
271
+ end
272
+ deleted += 1
273
+ rescue
274
+ end
275
+ end
276
+ # Remove deleted files from group (reverse order to preserve indices)
277
+ to_delete.sort.reverse.each { |fi| group.delete_at(fi) }
278
+ kept.delete(group_idx)
279
+ file_idx = 0
280
+ # Update metadata
281
+ meta[:waste] = (meta[:size] * (group.size - 1)) rescue 0
282
+ group_meta[group_idx] = meta
283
+ wasted_total = group_meta.sum { |m| m[:waste] }
284
+ # Remove group if only one file remains
285
+ if group.size <= 1
286
+ dupes.delete_at(group_idx)
287
+ group_meta.delete_at(group_idx)
288
+ # Shift kept indices
289
+ new_kept = {}
290
+ kept.each { |k, v| new_kept[k > group_idx ? k - 1 : k] = v if k != group_idx }
291
+ kept = new_kept
292
+ if dupes.empty?
293
+ @pB.say("All duplicates resolved! Freed #{format_bytes(wasted_total)}.")
294
+ break
295
+ end
296
+ group_idx = [group_idx, dupes.size - 1].min
297
+ wasted_total = group_meta.sum { |m| m[:waste] }
298
+ end
299
+ @pB.say("Deleted #{deleted} files.")
300
+ end
301
+ end
302
+ end
303
+
304
+ @pR.update = true
305
+ refresh
306
+ render
307
+ end
308
+
309
+ def dupes_progress(msg)
310
+ @pB.update = true
311
+ @pB.say(msg)
312
+ @pB.refresh
313
+ end
314
+
315
+ def dupes_cancelled?
316
+ begin
317
+ $stdin.read_nonblock(1)
318
+ $stdin.read_nonblock(16) rescue nil
319
+ true
320
+ rescue IO::WaitReadable, EOFError
321
+ false
322
+ end
323
+ end
324
+
325
+ def format_bytes(bytes)
326
+ if bytes >= 1073741824
327
+ "%.1f GB" % (bytes / 1073741824.0)
328
+ elsif bytes >= 1048576
329
+ "%.1f MB" % (bytes / 1048576.0)
330
+ elsif bytes >= 1024
331
+ "%.1f KB" % (bytes / 1024.0)
332
+ else
333
+ "#{bytes} B"
334
+ end
335
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rtfm-filemanager
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.2.0
4
+ version: 8.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Geir Isene
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-15 00:00:00.000000000 Z
11
+ date: 2026-03-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rcurses
@@ -89,6 +89,8 @@ files:
89
89
  - docs/remote-browsing.md
90
90
  - docs/troubleshooting.md
91
91
  - examples/bookmarks.rb
92
+ - examples/diskusage.rb
93
+ - examples/dupes.rb
92
94
  - examples/git.rb
93
95
  - examples/notes.rb
94
96
  - examples/opener.rb