rtfm-filemanager 8.2.0 → 8.2.2

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: 70a9b068989497362fa5d26c2a31cc1f839af5364ccf978b0f99f68707159255
4
+ data.tar.gz: f7e15cbab0c75bef8fc163eb179b60559ed08fc2f87244eb0e7ed703758c4581
5
5
  SHA512:
6
- metadata.gz: 9207a4adaeed8a14f3a7a78a70fafdcba2318635605406168679aefe6f2e859cedc7c4053ebcf0d08f610415874df41f061fedad1fd41955cbcc7ae1eb2206a9
7
- data.tar.gz: 1c8b70f11a2436b5beedebf1b0a510395e62e93a99ed1050192f381097693816731864f46c86a22eeebb8139ae9bb09367625c01ad9856142cffb2b271fdb53d
6
+ metadata.gz: 664d2c0a068716ff76cd10bd525dbc6383b5e8073d696db8d268a2927eaf6050affdcd0c359fbb0be8544612c3591804975a9847594af09392f12de389c76b5b
7
+ data.tar.gz: c02dce8c6556406b65b0ff87b9062f4f45256d4feccc0fbcf1c8fc2404c84708acdaae8f87b2ddd929377494691accd61d519ca8b964481ed7d686d08ee7d6e1
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.2' # Replace bare rescues with typed exceptions
22
22
 
23
23
  # SAVE & STORE TERMINAL {{{1
24
24
  ORIG_STTY = `stty -g`.chomp
@@ -61,7 +61,7 @@ def check_image_redraw # {{{2
61
61
  end
62
62
  end
63
63
  # Kitty and Sixel protocols don't need redraw checks - images persist
64
- rescue
64
+ rescue StandardError # non-fatal image redraw check
65
65
  # Silently fail - we don't want focus checking to break anything
66
66
  end
67
67
  end
@@ -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
@@ -682,7 +682,7 @@ def restore_tab_state # {{{2
682
682
 
683
683
  begin
684
684
  Dir.chdir(tab[:directory]) if Dir.exist?(tab[:directory])
685
- rescue
685
+ rescue Errno::ENOENT, Errno::EACCES # permission denied or missing dir
686
686
  # If directory doesn't exist, go to home
687
687
  Dir.chdir
688
688
  tab[:directory] = Dir.pwd
@@ -910,7 +910,7 @@ if File.exist?(PREVIEW_FILE)
910
910
  @plugin_errors << "Invalid preview.rb line #{idx + 1}: #{line}"
911
911
  end
912
912
  end
913
- rescue => e
913
+ rescue StandardError => e # malformed preview config
914
914
  @plugin_errors << "Error loading preview.rb: #{e.class}: #{e.message}"
915
915
  end
916
916
  end
@@ -1075,7 +1075,7 @@ KEYMAP = { # {{{2
1075
1075
  if File.exist?(KEYS_FILE)
1076
1076
  begin
1077
1077
  load KEYS_FILE
1078
- rescue => e
1078
+ rescue StandardError => e # broken key binding config
1079
1079
  @plugin_errors << "Error loading keys.rb: #{e.class}: #{e.message}"
1080
1080
  end
1081
1081
  end
@@ -1114,7 +1114,7 @@ def load_plugin(name)
1114
1114
  snapshot.each { |k, v| changed[k] = v unless KEYMAP.key?(k) }
1115
1115
  p[:saved_keys] = changed
1116
1116
  p[:enabled] = true
1117
- rescue => e
1117
+ rescue StandardError => e # plugin load failure
1118
1118
  @plugin_errors << "Error loading plugin #{name}: #{e.class}: #{e.message}"
1119
1119
  end
1120
1120
  end
@@ -1248,7 +1248,7 @@ def refresh_all # {{{3
1248
1248
  if new_h && new_w && new_h >= 10 && new_w >= 20
1249
1249
  @h, @w = new_h, new_w
1250
1250
  end
1251
- rescue
1251
+ rescue StandardError # terminal size query failed
1252
1252
  # Keep current size if we can't read new size
1253
1253
  end
1254
1254
 
@@ -1346,7 +1346,7 @@ def change_width # {{{3
1346
1346
  @width = 2 if @width == 8
1347
1347
 
1348
1348
  # Persist width setting
1349
- File.write(File.join(RTFM_HOME, 'width'), @width.to_s) rescue nil
1349
+ File.write(File.join(RTFM_HOME, 'width'), @width.to_s) rescue nil # ignore write error
1350
1350
 
1351
1351
  if @dual_pane
1352
1352
  # Show width setting info for dual-pane mode using the new ratio calculation
@@ -1589,7 +1589,7 @@ def jump_to_mark # {{{3
1589
1589
  if m =~ /[\w']/ && @marks[m]
1590
1590
  @directory[Dir.pwd] = @index
1591
1591
  dir_before = Dir.pwd
1592
- begin; Dir.chdir(@marks[m]); track_directory_access(@marks[m]); rescue; @pB.say(' No such directory'); end
1592
+ begin; Dir.chdir(@marks[m]); track_directory_access(@marks[m]); rescue Errno::ENOENT, Errno::EACCES; @pB.say(' No such directory'); end
1593
1593
  mark_latest
1594
1594
  @marks["'"] = dir_before
1595
1595
  end
@@ -1692,7 +1692,7 @@ def follow_symlink # {{{3
1692
1692
 
1693
1693
  # Clear history after successful navigation
1694
1694
  @symlink_history.clear
1695
- rescue => e
1695
+ rescue StandardError => e # symlink resolution failure
1696
1696
  @pB.say("Error following symlink: #{e}")
1697
1697
  @symlink_history&.clear
1698
1698
  end
@@ -1755,7 +1755,7 @@ def tag_current # {{{3
1755
1755
  else
1756
1756
  @tagged.push(item); @tagsize += File.size(item) rescue 0
1757
1757
  end
1758
-
1758
+
1759
1759
  # Advance to next item in the active pane
1760
1760
  max_index = current_files.size - 1
1761
1761
  if @active_pane == :left
@@ -2124,7 +2124,7 @@ def show_file_properties # {{{3
2124
2124
  mime_output = `file --mime-type #{Shellwords.escape(@selected)} 2>/dev/null`.strip
2125
2125
  mime_type = mime_output.split(':')[1]&.strip || "Unknown"
2126
2126
  text << sprintf(" %-20s %s\n", "MIME Type:", mime_type.fg(156))
2127
- rescue
2127
+ rescue StandardError # file command failed
2128
2128
  text << sprintf(" %-20s %s\n", "MIME Type:", "Unknown".fg(240))
2129
2129
  end
2130
2130
 
@@ -2143,7 +2143,7 @@ def show_file_properties # {{{3
2143
2143
  owner = Etc.getpwuid(stat.uid).name rescue stat.uid.to_s
2144
2144
  group = Etc.getgrgid(stat.gid).name rescue stat.gid.to_s
2145
2145
  text << sprintf(" %-20s %s\n", "Owner:Group:", "#{owner}:#{group}".fg(156))
2146
- rescue
2146
+ rescue StandardError # uid/gid lookup failed
2147
2147
  text << sprintf(" %-20s %s\n", "Owner:Group:", "#{stat.uid}:#{stat.gid}".fg(156))
2148
2148
  end
2149
2149
 
@@ -2163,7 +2163,7 @@ def show_file_properties # {{{3
2163
2163
  target = File.readlink(@selected)
2164
2164
  text << sprintf(" %-20s %s\n", "Points to:", target.fg(156))
2165
2165
  text << sprintf(" %-20s %s\n", "Target exists:", File.exist?(target) ? "Yes".fg(156) : "No".fg(196))
2166
- rescue
2166
+ rescue Errno::ENOENT, Errno::EACCES # permission denied or missing symlink target
2167
2167
  text << sprintf(" %-20s %s\n", "Target:", "Cannot read link".fg(196))
2168
2168
  end
2169
2169
  end
@@ -2178,7 +2178,7 @@ def show_file_properties # {{{3
2178
2178
  dirs = entries.select { |e| File.directory?(File.join(@selected, e)) }
2179
2179
  files = entries.select { |e| File.file?(File.join(@selected, e)) }
2180
2180
  text << sprintf(" %-20s %s\n", "Breakdown:", "#{dirs.length} directories, #{files.length} files")
2181
- rescue
2181
+ rescue Errno::EACCES, Errno::ENOENT # permission denied or missing dir
2182
2182
  text << sprintf(" %-20s %s\n", "Contents:", "Cannot read directory".fg(196))
2183
2183
  end
2184
2184
  end
@@ -2194,7 +2194,7 @@ def show_file_properties # {{{3
2194
2194
  require 'digest'
2195
2195
  checksum = Digest::SHA256.file(@selected).hexdigest[0, 16]
2196
2196
  text << sprintf(" %-20s %s...\n", "SHA256 (partial):", checksum.fg(156))
2197
- rescue
2197
+ rescue Errno::EACCES, Errno::ENOENT # permission denied or missing file
2198
2198
  text << sprintf(" %-20s %s\n", "Checksum:", "Cannot calculate".fg(240))
2199
2199
  end
2200
2200
  else
@@ -2209,7 +2209,7 @@ def show_file_properties # {{{3
2209
2209
  (chunk.bytes.any? { |b| b < 32 && ![9, 10, 13].include?(b) })
2210
2210
  text << sprintf(" %-20s %s\n", "Content type:", is_binary ? "Binary".fg(196) : "Text".fg(156))
2211
2211
  end
2212
- rescue
2212
+ rescue Errno::EACCES, Errno::ENOENT # permission denied or missing file
2213
2213
  text << sprintf(" %-20s %s\n", "Content type:", "Unknown".fg(240))
2214
2214
  end
2215
2215
  end
@@ -2578,7 +2578,7 @@ def show_binary_comparison(file1, file2, stat1, stat2) # {{{3
2578
2578
  text << "\n File types:\n".fg(226)
2579
2579
  text << " #{File.basename(file1)}: #{type1.split(':')[1]&.strip || 'Unknown'}\n".fg(240)
2580
2580
  text << " #{File.basename(file2)}: #{type2.split(':')[1]&.strip || 'Unknown'}\n".fg(240)
2581
- rescue
2581
+ rescue StandardError # file type detection failed
2582
2582
  # Ignore file type detection errors
2583
2583
  end
2584
2584
 
@@ -2792,7 +2792,7 @@ def files_identical?(file1, file2) # {{{3
2792
2792
  end
2793
2793
 
2794
2794
  true
2795
- rescue
2795
+ rescue Errno::EACCES, Errno::ENOENT, Errno::EISDIR # permission denied or missing file
2796
2796
  false
2797
2797
  end
2798
2798
 
@@ -2808,7 +2808,7 @@ def binary_file?(file) # {{{3
2808
2808
 
2809
2809
  null_count > 0 || (non_printable.to_f / chunk.length) > 0.3
2810
2810
  end
2811
- rescue
2811
+ rescue Errno::EACCES, Errno::ENOENT, Errno::EISDIR # permission denied or missing file
2812
2812
  true # Assume binary if we can't read it
2813
2813
  end
2814
2814
 
@@ -2924,7 +2924,7 @@ def parse_archive_listing(archive_path) # {{{3
2924
2924
  else
2925
2925
  parse_tar_listing(raw, entries)
2926
2926
  end
2927
- rescue => e
2927
+ rescue StandardError => e # archive format or read error
2928
2928
  @pB.say("Error reading archive: #{e.message}".fg(196))
2929
2929
  end
2930
2930
  entries
@@ -3064,6 +3064,7 @@ end
3064
3064
  def enter_archive_mode(archive_path) # {{{3
3065
3065
  @archive_origin_dir = Dir.pwd
3066
3066
  @archive_origin_tagged = @tagged.dup
3067
+ @archive_origin_index = @index
3067
3068
  @archive_mode = true
3068
3069
  @archive_path = archive_path
3069
3070
  @archive_current_dir = ''
@@ -3093,7 +3094,8 @@ def exit_archive_mode # {{{3
3093
3094
  @tagged = @archive_origin_tagged || []
3094
3095
  @archive_origin_tagged = []
3095
3096
  @archive_origin_dir = nil
3096
- @index = 0
3097
+ @index = @archive_origin_index || 0
3098
+ @archive_origin_index = nil
3097
3099
  @pB.say("Returned to local browsing".fg(156))
3098
3100
  @pL.update = @pR.update = @pT.update = @pB.update = true
3099
3101
  dirlist
@@ -3645,7 +3647,7 @@ def upload_tagged_files # {{{3
3645
3647
  failed_count += 1
3646
3648
  @pB.say("✗ Failed to upload #{File.basename(local_file)}".fg(196))
3647
3649
  end
3648
- rescue => e
3650
+ rescue StandardError => e # upload failure
3649
3651
  failed_count += 1
3650
3652
  @pB.say("✗ Error uploading #{File.basename(local_file)}: #{e.message}".fg(196))
3651
3653
  end
@@ -4204,14 +4206,152 @@ def empty_trash # {{{3
4204
4206
  remote_download_selected
4205
4207
  return
4206
4208
  end
4207
- @pB.say(" Really empty Trash (~/.rtfm/trash)? (press 'y')")
4208
- return unless getchr == 'y'
4209
+ trash_browser
4210
+ end
4211
+
4212
+ def trash_browser # TRASH BROWSER {{{3
4213
+ clear_image
4214
+ sel = 0
4215
+ offset = 0
4216
+
4217
+ loop do
4218
+ # Scan trash directory, build item list with metadata
4219
+ raw = Dir.entries(TRASH_DIR).reject { |e| e == '.' || e == '..' }.map { |e| File.join(TRASH_DIR, e) }
4220
+ entries = raw.select { |f| File.exist?(f) || File.symlink?(f) }
4221
+ entries.sort_by! { |f| File.mtime(f) rescue Time.at(0) }
4222
+ entries.reverse!
4223
+ items = entries.filter_map do |f|
4224
+ basename = File.basename(f)
4225
+ orig_name = basename.sub(/^\d{8}_\d{6}_/, '').sub(/_\d{4}$/, '')
4226
+ orig_path = nil
4227
+ @undo_history.each do |op|
4228
+ next unless op[:type] == 'delete' && op[:trash]
4229
+ op[:paths].each { |p| orig_path = p[:path] if p[:trash_name] == basename }
4230
+ end
4231
+ begin
4232
+ size = File.directory?(f) ? (command("du -sb #{Shellwords.escape(f)} 2>/dev/null").split.first.to_i rescue 0) : File.size(f)
4233
+ mtime = File.mtime(f)
4234
+ rescue Errno::EACCES, Errno::ENOENT # permission denied or missing file
4235
+ size = 0
4236
+ mtime = Time.at(0)
4237
+ end
4238
+ { path: f, basename: basename, orig_name: orig_name, orig_path: orig_path,
4239
+ size: size, mtime: mtime, dir: File.directory?(f) }
4240
+ end
4241
+
4242
+ sel = [sel, items.size - 1, 0].sort[1]
4243
+ page_h = @pR.h - 5
4244
+ page_h = 1 if page_h < 1
4245
+ offset = sel - page_h + 1 if sel >= offset + page_h
4246
+ offset = sel if sel < offset
4247
+ offset = 0 if offset < 0
4248
+ visible = items[offset, page_h] || []
4249
+
4250
+ lines = []
4251
+ lines << "j/k:move r:restore d:delete E:empty all q:close".fg(240)
4252
+ lines << "Trash".b.fg(254) + " (#{items.size} items)".fg(240)
4253
+ lines << ""
4254
+
4255
+ if items.empty?
4256
+ lines << "Trash is empty.".fg(240)
4257
+ else
4258
+ visible.each_with_index do |item, i|
4259
+ idx = offset + i
4260
+ size_s = human_size(item[:size])
4261
+ type_indicator = item[:dir] ? "/".fg(69) : ""
4262
+ age = time_ago(item[:mtime])
4263
+ name = item[:orig_name] + type_indicator
4264
+ origin = item[:orig_path] ? " #{File.dirname(item[:orig_path])}".fg(240) : ""
4265
+ line = "#{size_s.rjust(8).fg(249)} #{age.rjust(6).fg(240)} #{name}#{origin}"
4266
+ line = idx == sel ? line.u : line
4267
+ lines << line
4268
+ end
4269
+ lines << "(#{sel + 1}/#{items.size})".fg(240) if items.size > page_h
4270
+ end
4271
+
4272
+ @pR.update = true
4273
+ @pR.say(lines.join("\n"))
4274
+
4275
+ chr = getchr
4276
+ case chr
4277
+ when 'q', 'ESC'
4278
+ break
4279
+ when 'j', 'DOWN'
4280
+ sel = items.empty? ? 0 : (sel + 1) % items.size
4281
+ when 'k', 'UP'
4282
+ sel = items.empty? ? 0 : (sel - 1) % items.size
4283
+ when 'r'
4284
+ next if items.empty?
4285
+ item = items[sel]
4286
+ if item[:orig_path]
4287
+ dest = item[:orig_path]
4288
+ else
4289
+ dest = @pCmd.ask('Restore to: ', File.join(Dir.pwd, item[:orig_name]))
4290
+ end
4291
+ next if dest.strip.empty?
4292
+ if File.exist?(dest)
4293
+ @pB.say("Destination exists: #{dest}".fg(196))
4294
+ next
4295
+ end
4296
+ begin
4297
+ FileUtils.mv(item[:path], dest)
4298
+ # Remove from undo history
4299
+ @undo_history.each do |op|
4300
+ next unless op[:type] == 'delete' && op[:trash]
4301
+ op[:paths].reject! { |p| p[:trash_name] == item[:basename] }
4302
+ end
4303
+ @pB.say("Restored: #{File.basename(dest)}".fg(156))
4304
+ sel = [sel - 1, 0].max
4305
+ rescue StandardError => e # restore from trash failed
4306
+ @pB.say("Restore failed: #{e.message}".fg(196))
4307
+ end
4308
+ when 'd'
4309
+ next if items.empty?
4310
+ item = items[sel]
4311
+ @pB.say(" Permanently delete #{item[:orig_name]}? (y/n)".fg(196))
4312
+ if getchr == 'y'
4313
+ command("rm -rf #{Shellwords.escape(item[:path])}")
4314
+ @pB.say("Deleted: #{item[:orig_name]}".fg(204))
4315
+ sel = [sel - 1, 0].max
4316
+ end
4317
+ when 'E'
4318
+ @pB.say(" Empty ALL trash? This cannot be undone! (y/n)".fg(196))
4319
+ if getchr == 'y'
4320
+ command("rm -rf #{TRASH_DIR}/*")
4321
+ @undo_history.reject! { |op| op[:type] == 'delete' && op[:trash] }
4322
+ @pB.say("Trash emptied.".fg(204))
4323
+ sel = 0
4324
+ end
4325
+ end
4326
+ end
4209
4327
 
4210
- command("rm -rf #{TRASH_DIR}/*")
4211
- @pB.say('Trash is now empty')
4328
+ @pR.update = true
4329
+ refresh
4212
4330
  render
4213
4331
  end
4214
4332
 
4333
+ def human_size(bytes) # {{{3
4334
+ if bytes >= 1_073_741_824
4335
+ "%.1fG" % (bytes / 1_073_741_824.0)
4336
+ elsif bytes >= 1_048_576
4337
+ "%.1fM" % (bytes / 1_048_576.0)
4338
+ elsif bytes >= 1024
4339
+ "%.1fK" % (bytes / 1024.0)
4340
+ else
4341
+ "#{bytes}B"
4342
+ end
4343
+ end
4344
+
4345
+ def time_ago(time) # {{{3
4346
+ seconds = Time.now - time
4347
+ if seconds < 60 then "now"
4348
+ elsif seconds < 3600 then "#{(seconds / 60).to_i}m"
4349
+ elsif seconds < 86400 then "#{(seconds / 3600).to_i}h"
4350
+ elsif seconds < 604800 then "#{(seconds / 86400).to_i}d"
4351
+ else "#{(seconds / 604800).to_i}w"
4352
+ end
4353
+ end
4354
+
4215
4355
  def toggle_trash # {{{3
4216
4356
  @trash = !@trash
4217
4357
  @pB.say("Trash (~/.rtfm/trash) = #{@trash ? 'On' : 'Off'}")
@@ -4720,19 +4860,19 @@ def refresh_right # {{{3
4720
4860
  end
4721
4861
 
4722
4862
  def line_down_right # {{{3
4723
- @pR.linedown; @pB.update = true
4863
+ @pR.update = true; @pR.linedown; @pR.update = false; @pB.update = true
4724
4864
  end
4725
4865
 
4726
4866
  def line_up_right # {{{3
4727
- @pR.lineup; @pB.update = true
4867
+ @pR.update = true; @pR.lineup; @pR.update = false; @pB.update = true
4728
4868
  end
4729
4869
 
4730
4870
  def page_down_right # {{{3
4731
- @pR.pagedown; @pB.update = true
4871
+ @pR.update = true; @pR.pagedown; @pR.update = false; @pB.update = true
4732
4872
  end
4733
4873
 
4734
4874
  def page_up_right # {{{3
4735
- @pR.pageup; @pB.update = true
4875
+ @pR.update = true; @pR.pageup; @pR.update = false; @pB.update = true
4736
4876
  end
4737
4877
 
4738
4878
  # CLIPBOARD COPY {{{2
@@ -4864,9 +5004,9 @@ def system_info # {{{3
4864
5004
  text << sprintf(" %-15s %s\n", "Kernel:".fg(249), kernel_version.fg(156))
4865
5005
  text << sprintf(" %-15s %s\n", "Architecture:".fg(249), architecture.fg(156))
4866
5006
  text << "\n"
4867
- rescue # rubocop:disable Lint/SuppressedException
5007
+ rescue StandardError # OS info unavailable
4868
5008
  end
4869
-
5009
+
4870
5010
  begin
4871
5011
  # Hardware Information
4872
5012
  text << "Hardware".fg(226).b + "\n"
@@ -4895,11 +5035,11 @@ def system_info # {{{3
4895
5035
  temp_color = temp > 80 ? 196 : temp > 60 ? 220 : 156
4896
5036
  text << sprintf(" %-15s %s\n", "CPU Temp:".fg(249), "#{temp}°C".fg(temp_color))
4897
5037
  end
4898
-
5038
+
4899
5039
  text << "\n"
4900
- rescue # rubocop:disable Lint/SuppressedException
5040
+ rescue StandardError # hardware info unavailable
4901
5041
  end
4902
-
5042
+
4903
5043
  begin
4904
5044
  # Memory Information with visual bar
4905
5045
  text << "Memory".fg(226).b + "\n"
@@ -4941,11 +5081,11 @@ def system_info # {{{3
4941
5081
  swap_used = (parts[2].to_i / 1024.0 / 1024.0 / 1024.0).round(1)
4942
5082
  text << sprintf(" %-15s %s\n", "Swap:".fg(249), "#{swap_used}/#{swap_total} GB".fg(156))
4943
5083
  end
4944
-
5084
+
4945
5085
  text << "\n"
4946
- rescue # rubocop:disable Lint/SuppressedException
5086
+ rescue StandardError # memory info unavailable
4947
5087
  end
4948
-
5088
+
4949
5089
  begin
4950
5090
  # Storage Information with visual bars
4951
5091
  text << "Storage".fg(226).b + "\n"
@@ -4985,9 +5125,9 @@ def system_info # {{{3
4985
5125
  end
4986
5126
  end
4987
5127
  text << "\n"
4988
- rescue # rubocop:disable Lint/SuppressedException
5128
+ rescue StandardError # storage info unavailable
4989
5129
  end
4990
-
5130
+
4991
5131
  begin
4992
5132
  # Network Information
4993
5133
  text << "Network".fg(226).b + "\n"
@@ -5021,14 +5161,14 @@ def system_info # {{{3
5021
5161
  `curl -s ifconfig.me 2>/dev/null`.chomp
5022
5162
  end
5023
5163
  text << sprintf(" %-15s %s\n", "Public IP:".fg(249), public_ip.fg(156)) unless public_ip.empty?
5024
- rescue
5164
+ rescue StandardError # timeout or network error
5025
5165
  # Skip public IP if timeout or error
5026
5166
  end
5027
5167
 
5028
5168
  text << "\n"
5029
- rescue # rubocop:disable Lint/SuppressedException
5169
+ rescue StandardError # network info unavailable
5030
5170
  end
5031
-
5171
+
5032
5172
  begin
5033
5173
  # Environment Information
5034
5174
  text << "Environment".fg(226).b + "\n"
@@ -5063,9 +5203,9 @@ def system_info # {{{3
5063
5203
  end
5064
5204
 
5065
5205
  text << "\n"
5066
- rescue # rubocop:disable Lint/SuppressedException
5206
+ rescue StandardError # environment info unavailable
5067
5207
  end
5068
-
5208
+
5069
5209
  begin
5070
5210
  # Services & Processes
5071
5211
  text << "Services & Processes".fg(226).b + "\n"
@@ -5106,11 +5246,11 @@ def system_info # {{{3
5106
5246
  text << sprintf(" %-15s %s\n", "Users:".fg(249), "#{users} logged in".fg(156))
5107
5247
 
5108
5248
  text << "\n"
5109
- rescue # rubocop:disable Lint/SuppressedException
5249
+ rescue StandardError # services info unavailable
5110
5250
  end
5111
-
5251
+
5112
5252
  @pR.say(text)
5113
- rescue => e
5253
+ rescue StandardError => e # system info display failed
5114
5254
  @pR.say("Unable to show system info\n#{e.message}".fg(196))
5115
5255
  end
5116
5256
 
@@ -5123,7 +5263,7 @@ end
5123
5263
 
5124
5264
  def navi_invoke # {{{3
5125
5265
  @navi = `navi`
5126
- rescue
5266
+ rescue StandardError # navi command not found
5127
5267
  @pB.say(' navi not installed - see https://github.com/junegunn/fzf')
5128
5268
  end
5129
5269
 
@@ -5322,7 +5462,7 @@ def get_cached_dirlist(dir, ls_options, ls_options_with_long = nil) # {{{2
5322
5462
  dir_mtime = File.mtime(dir).to_i
5323
5463
  file_count = Dir.entries(dir).size
5324
5464
  cache_key = "#{dir}:#{ls_options_with_long}:#{dir_mtime}:#{file_count}"
5325
- rescue
5465
+ rescue Errno::EACCES, Errno::ENOENT # permission denied or missing dir
5326
5466
  return nil # Can't cache if we can't get mtime or file count
5327
5467
  end
5328
5468
 
@@ -5350,7 +5490,7 @@ def get_cached_dirlist(dir, ls_options, ls_options_with_long = nil) # {{{2
5350
5490
  @dir_cache.delete_if { |key, _| key.start_with?("#{dir}:") && key != cache_key }
5351
5491
 
5352
5492
  result
5353
- rescue => e
5493
+ rescue StandardError => e # ls command failed
5354
5494
  # Return empty result on error
5355
5495
  { purels: [], colorls: [] }
5356
5496
  end
@@ -5362,7 +5502,7 @@ def get_cached_file_metadata(file_path) # {{{2
5362
5502
  begin
5363
5503
  file_stat = File.stat(file_path)
5364
5504
  cache_key = "#{file_path}:#{file_stat.mtime.to_i}:#{file_stat.size}"
5365
- rescue
5505
+ rescue Errno::EACCES, Errno::ENOENT # permission denied or missing file
5366
5506
  return nil
5367
5507
  end
5368
5508
 
@@ -5398,7 +5538,7 @@ def get_cached_file_metadata(file_path) # {{{2
5398
5538
  @metadata_cache.delete_if { |key, _| key.start_with?("#{file_path}:") && key != cache_key }
5399
5539
 
5400
5540
  metadata
5401
- rescue
5541
+ rescue StandardError # metadata extraction failed
5402
5542
  nil
5403
5543
  end
5404
5544
  end
@@ -5563,7 +5703,7 @@ def dirlist(left: true, directory: nil) # LIST DIRECTORIES {{{2
5563
5703
  pure_output = command("ls #{Shellwords.escape(dir)} #{ls_options}")
5564
5704
  purels = pure_output.pure.split("\n")
5565
5705
  colorls = color_output.split("\n")
5566
- rescue => e
5706
+ rescue StandardError => e # ls command failed
5567
5707
  purels = []
5568
5708
  colorls = []
5569
5709
  end
@@ -5594,7 +5734,7 @@ def dirlist(left: true, directory: nil) # LIST DIRECTORIES {{{2
5594
5734
  @files = purels
5595
5735
  # Update @selected & @fileattr for left pane
5596
5736
  if purels[@index]
5597
- @selected = Dir.pwd + '/' + purels[@index]
5737
+ @selected = File.join(Dir.pwd, purels[@index])
5598
5738
  sfile = @selected.dup
5599
5739
  sfile += '/' if File.directory?(@selected)
5600
5740
  time_opt = @is_macos_bsd ? '-T' : '--time-style=long-iso'
@@ -6027,7 +6167,7 @@ def command(cmd, timeout: 5, return_both: false) # {{{2
6027
6167
  @pR.say(msg.fg(196))
6028
6168
  ''
6029
6169
  end
6030
- rescue => e
6170
+ rescue StandardError => e # command execution failed
6031
6171
  msg = "Error: #{e.message}\n#{e.backtrace.join("\n")}\n"
6032
6172
  if return_both
6033
6173
  ['', msg]
@@ -6103,7 +6243,7 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
6103
6243
  end
6104
6244
 
6105
6245
  @file_op_result = " #{type.capitalize} complete: #{operations.size} item(s)".fg(156)
6106
- rescue => e
6246
+ rescue StandardError => e # file operation failed
6107
6247
  @file_op_result = " #{type.capitalize} error: #{e.message}".fg(196)
6108
6248
  ensure
6109
6249
  @file_op_progress = nil
@@ -6136,7 +6276,7 @@ def copy_move_link_sync(type, items, dest_dir) # {{{3
6136
6276
  operations << { source_path: item, dest_path: dest }
6137
6277
  @pB.say(' Item(s) symlinked here.')
6138
6278
  end
6139
- rescue => e
6279
+ rescue StandardError => e # file operation failed
6140
6280
  @pB.say(e.to_s)
6141
6281
  end
6142
6282
  end
@@ -6265,7 +6405,7 @@ def get_interactive_program(file_path) # HELPER FOR OPEN_SELECTED TO USE @intera
6265
6405
  end
6266
6406
  end
6267
6407
  end
6268
- rescue
6408
+ rescue StandardError # desktop file detection failed
6269
6409
  # If detection fails, fall back to normal behavior
6270
6410
  end
6271
6411
  nil
@@ -6585,7 +6725,7 @@ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
6585
6725
  end
6586
6726
  end
6587
6727
  end
6588
- rescue => e
6728
+ rescue StandardError => e # SVG render failed
6589
6729
  @pR.say("Error processing SVG: #{e}")
6590
6730
  end
6591
6731
  when /\.(?:png|jpe?g|bmp|gif|webp|tiff?)$/i
@@ -6598,7 +6738,7 @@ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
6598
6738
  showimage(@selected)
6599
6739
  @image = true
6600
6740
  end
6601
- rescue => e
6741
+ rescue Errno::EACCES, Errno::ENOENT => e # permission denied or missing image
6602
6742
  @pR.say("Error checking image size: #{e}")
6603
6743
  end
6604
6744
  when /\.(?:mpg|mpeg|avi|mov|mkv|mp4|webm|flv|wmv|m4v)$/i
@@ -6627,6 +6767,7 @@ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
6627
6767
  else
6628
6768
  # Enhanced text file preview with partial loading for large files
6629
6769
  begin
6770
+ Timeout.timeout(5) do # Prevent preview from hanging
6630
6771
  file_size = File.size(@selected)
6631
6772
 
6632
6773
  # For text files, we can preview them partially even if large
@@ -6641,6 +6782,7 @@ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
6641
6782
  line_count = 0
6642
6783
 
6643
6784
  file.each_line do |line|
6785
+ line = line[0, 10_000] + "...\n" if line.length > 10_000
6644
6786
  lines << line
6645
6787
  line_count += 1
6646
6788
  break if line_count >= preview_lines
@@ -6681,25 +6823,33 @@ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
6681
6823
  else
6682
6824
  # Small files - read entirely as before
6683
6825
  text = File.read(@selected).force_encoding('UTF-8') rescue ''
6826
+ # Truncate extremely long lines to prevent UI hang
6827
+ has_long_lines = text.lines.any? { |l| l.length > 2_000 }
6828
+ if has_long_lines
6829
+ text = text.lines.map { |l| l.length > 2_000 ? l[0, 10_000] + "...\n" : l }.join
6830
+ end
6684
6831
  if text.valid_encoding?
6685
- if @batuse
6686
- # Try bat first, with robust fallback
6832
+ if @batuse && !has_long_lines
6833
+ # Try bat on original file (safe, no long lines)
6687
6834
  bat_cmd = "#{@bat} -n --color=always #{Shellwords.escape(@selected)}"
6688
- bat_output = command(bat_cmd, timeout: 10)
6835
+ bat_output = command(bat_cmd, timeout: 5)
6689
6836
  if bat_output.empty?
6690
- # Bat failed or returned empty - try plain cat
6691
6837
  showcommand("cat #{Shellwords.escape(@selected)}")
6692
6838
  else
6693
6839
  @pR.say(bat_output)
6694
6840
  end
6695
6841
  else
6696
- showcommand("cat #{Shellwords.escape(@selected)}")
6842
+ # Use truncated text directly (long lines or no bat)
6843
+ @pR.say(text)
6697
6844
  end
6698
6845
  else
6699
6846
  @pR.say("No preview available for #{@selected}")
6700
6847
  end
6701
6848
  end
6702
- rescue => e
6849
+ end # Timeout.timeout
6850
+ rescue Timeout::Error
6851
+ @pR.say("Preview timed out (file too large or complex)")
6852
+ rescue StandardError => e # file preview failed
6703
6853
  @pR.say("Error previewing file: #{e}")
6704
6854
  end
6705
6855
  end
@@ -6756,7 +6906,7 @@ def showimage(image) # SHOW THE SELECTED IMAGE IN THE RIGHT WINDOW {{{2
6756
6906
  max_height: @pR.h - 1)
6757
6907
  @current_image_path = img_path
6758
6908
  end
6759
- rescue => e
6909
+ rescue StandardError => e # image display failed
6760
6910
  @pR.text = "Error showing image: #{e.message}"
6761
6911
  end
6762
6912
  end
@@ -6937,7 +7087,7 @@ loop do
6937
7087
  @pT.update = @pL.update = @pR.update = @pB.update = true
6938
7088
  refresh
6939
7089
  end
6940
- rescue; end
7090
+ rescue StandardError; end # terminal size query failed
6941
7091
  end
6942
7092
 
6943
7093
  # redraw, but ignore TTY‐focus errors
@@ -6955,7 +7105,7 @@ loop do
6955
7105
  # If cwd was deleted externally, jump home
6956
7106
  begin
6957
7107
  Dir.pwd
6958
- rescue
7108
+ rescue Errno::ENOENT, Errno::EACCES # cwd was deleted or became inaccessible
6959
7109
  Dir.chdir
6960
7110
  end
6961
7111
  # If selected file was removed externally, force pane refresh (skip in archive/remote mode)
@@ -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.2
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-21 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