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