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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +7 -2
- data/bin/rtfm +222 -72
- data/examples/diskusage.rb +199 -0
- data/examples/dupes.rb +335 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 70a9b068989497362fa5d26c2a31cc1f839af5364ccf978b0f99f68707159255
|
|
4
|
+
data.tar.gz: f7e15cbab0c75bef8fc163eb179b60559ed08fc2f87244eb0e7ed703758c4581
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` |
|
|
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
|
-
- **
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
4208
|
-
|
|
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
|
-
|
|
4211
|
-
|
|
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;
|
|
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 #
|
|
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 #
|
|
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 #
|
|
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 #
|
|
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 #
|
|
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 #
|
|
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 #
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|