rtfm-filemanager 5.10.4 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +54 -9
- data/bin/rtfm +2087 -243
- data/img/rtfm-kb.png +0 -0
- metadata +4 -4
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 = '
|
21
|
+
@version = '6.0.0' # Major release: Remote SSH/SFTP browsing, enhanced help system, SSH comments, undo system
|
22
22
|
|
23
23
|
# SAVE & STORE TERMINAL {{{1
|
24
24
|
ORIG_STTY = `stty -g`.chomp
|
@@ -37,19 +37,6 @@ require 'bootsnap/setup' # Speed up subsequent requires
|
|
37
37
|
# ENCODING {{{1
|
38
38
|
# encoding: utf-8
|
39
39
|
|
40
|
-
# PROFILER {{{1
|
41
|
-
#$start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
42
|
-
#$last_checkpoint = $start_time
|
43
|
-
#$checkpoints = []
|
44
|
-
#def checkpoint(label)
|
45
|
-
# now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
46
|
-
# delta_ms = ((now - $last_checkpoint) * 1000).round(2)
|
47
|
-
# total_ms = ((now - $start_time) * 1000).round(2)
|
48
|
-
# $checkpoints << "#{label.ljust(20)}: #{delta_ms} ms"
|
49
|
-
# $checkpoints.reject! { |line| line.start_with?("Total:") }
|
50
|
-
# $checkpoints << "Total: #{total_ms} ms"
|
51
|
-
# $last_checkpoint = now
|
52
|
-
#end
|
53
40
|
|
54
41
|
def check_image_redraw # {{{2
|
55
42
|
# Only check periodically to avoid performance impact
|
@@ -97,7 +84,6 @@ autoload :Open3, 'open3'
|
|
97
84
|
autoload :PTY, 'pty'
|
98
85
|
autoload :OpenAI, 'ruby/openai'
|
99
86
|
autoload :Tempfile, 'tempfile'
|
100
|
-
#checkpoint("Libraries loaded")
|
101
87
|
|
102
88
|
# FIX TERMINAL MESSAGE BLEED-THROUGH {{{1
|
103
89
|
LOG_PATH = File.join(Dir.tmpdir, 'rtfm.log')
|
@@ -115,7 +101,6 @@ end
|
|
115
101
|
logfile = File.open(LOG_PATH, 'a+')
|
116
102
|
logfile.sync = true
|
117
103
|
$stderr.reopen(logfile)
|
118
|
-
#checkpoint("Bleed-through fix")
|
119
104
|
|
120
105
|
# RCURSES CLASS EXTENSION {{{1
|
121
106
|
module Rcurses
|
@@ -181,10 +166,10 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
|
|
181
166
|
v = Display RTFM version (and latest Gem version) in bottom window/command bar
|
182
167
|
r = Refresh RTFM (recreates all windows. Use on terminal resize or when there is garbage somewhere)
|
183
168
|
R = Reload configuration (~/.rtfm/conf)
|
184
|
-
W = Write parameters to ~/.rtfm/conf: @marks, @hash, @history, @rubyhistory, @aihistory
|
169
|
+
W = Write parameters to ~/.rtfm/conf: @marks, @hash, @history, @rubyhistory, @aihistory, @sshhistory
|
185
170
|
@lslong, @lsall, @lsorder, @lsinvert, @width, @border, @preview, @trash
|
186
171
|
C = Show the current configuration in ~/.rtfm/conf
|
187
|
-
q = Quit (save basic configuration: @marks, @hash, @history, @rubyhistory, @aihistory)
|
172
|
+
q = Quit (save basic configuration: @marks, @hash, @history, @rubyhistory, @aihistory, @sshhistory)
|
188
173
|
Q = QUIT (without writing any changes to the config file)
|
189
174
|
|
190
175
|
LAYOUT
|
@@ -216,6 +201,10 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
|
|
216
201
|
The 5 latest directories visited are stored in marks 1-5 (1 being the very latest)
|
217
202
|
~ = Jump to Home directory
|
218
203
|
> = Follow symlink to the directory where the target resides
|
204
|
+
Ctrl-r = Show recently accessed files and directories (press number to jump)
|
205
|
+
Ctrl-e = Browse remote directories via SSH/SFTP (toggle remote mode)
|
206
|
+
In remote mode: d=download, u=upload, s=shell, →=file info, ←=parent dir
|
207
|
+
SSH connections support comments: user@host:/path # Comment
|
219
208
|
|
220
209
|
DIRECTORY VIEWS
|
221
210
|
a = Show all (also hidden) items
|
@@ -243,11 +232,20 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
|
|
243
232
|
p = Put (copy) tagged items here
|
244
233
|
P = PUT (move) tagged items here
|
245
234
|
c = Change/rename selected (adds command to bottom window)
|
235
|
+
E = Bulk rename tagged files using patterns (regex, templates, case conversion)
|
236
|
+
X = Compare two tagged files (text diff or binary comparison)
|
246
237
|
s = Create symlink to tagged items here
|
247
238
|
d = Delete selected item and tagged items. Confirm with 'y'.
|
248
239
|
Moves items to trash directory (~/.rtfm/trash/) if @trash = true
|
249
240
|
D = Empty trash directory
|
250
241
|
Ctrl-d = Toggle use of trash directory
|
242
|
+
|
243
|
+
UNDO OPERATIONS
|
244
|
+
U = Undo last file operation (delete from trash, move, rename, copy, symlink, bulk rename)
|
245
|
+
Only operations that can be safely undone are tracked
|
246
|
+
Permanent deletions cannot be undone
|
247
|
+
|
248
|
+
OWNERSHIP AND PERMISSIONS
|
251
249
|
Ctrl-o = Change ownership to user:group of selected and tagged items
|
252
250
|
Ctrl-p = Change permissions of selected and tagged items
|
253
251
|
Format = rwxr-xr-x or 755 or rwx (applies the trio to user, group and others)
|
@@ -281,10 +279,10 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
|
|
281
279
|
|
282
280
|
RIGHT PANE CONTROLS
|
283
281
|
ENTER = Refresh the right pane
|
284
|
-
S-
|
285
|
-
S-
|
286
|
-
S-
|
287
|
-
S-
|
282
|
+
S-DOWN = One line down in the preview
|
283
|
+
S-UP = One line up in the preview
|
284
|
+
S-RIGHT = Next page of the preview (if doc long and ∇ in the bottom right) (TAB does the same)
|
285
|
+
S-LEFT = Previous page (if you have moved down the document first - ∆ in the top right) (or S-TAB)
|
288
286
|
|
289
287
|
CLIPBOARD COPY
|
290
288
|
y = Copy path of selected item to primary selection (for pasting with middle mouse button)
|
@@ -295,6 +293,7 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
|
|
295
293
|
SYSTEM SHORTCUTS
|
296
294
|
S = Show comprehensive System info (system, CPU, filesystem, latest dmesg messages)
|
297
295
|
= = Create a new directory (a shortcut for ":mkdir ")
|
296
|
+
e = Show comprehensive file/directory properties (size, permissions, timestamps, etc.)
|
298
297
|
Ctrl-n = Invoke navi (see https://github.com/denisidoro/navi) with any output in right window
|
299
298
|
|
300
299
|
COMMAND MODE
|
@@ -302,6 +301,7 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
|
|
302
301
|
Prefix the command with a '§' to force the program to run in interactive mode
|
303
302
|
Full screen TUI programs like htop, vim or any shell must run in interactive mode
|
304
303
|
; = Show command history in right pane
|
304
|
+
Ctrl-; = Show SSH connection history in right pane
|
305
305
|
+ = Add program(s) to the list of full-UI interactive terminal programs
|
306
306
|
|
307
307
|
RUBY DEBUG MODE
|
@@ -373,6 +373,7 @@ def setup_config # {{{2
|
|
373
373
|
@history = []
|
374
374
|
@rubyhistory = []
|
375
375
|
@aihistory = []
|
376
|
+
@sshhistory = []
|
376
377
|
CONFIG
|
377
378
|
end
|
378
379
|
|
@@ -464,7 +465,6 @@ def display_welcome_message # {{{2
|
|
464
465
|
# rubocop:enable Layout/IndentationWidth
|
465
466
|
puts @firstrun
|
466
467
|
end
|
467
|
-
#checkpoint("Preliminaries done")
|
468
468
|
|
469
469
|
# BASIC SETUP {{{1
|
470
470
|
## Check for installed basic applications {{{2
|
@@ -508,6 +508,7 @@ $stdin.set_encoding(Encoding::UTF_8)
|
|
508
508
|
@history = [] # Initialize the command line history array
|
509
509
|
@rubyhistory = [] # Initialize the command line history array for ruby commands
|
510
510
|
@aihistory = [] # Initialize the command line history array for AI chat
|
511
|
+
@sshhistory = [] # Initialize the command line history array for SSH connections
|
511
512
|
### Saved on Write Config ('W')
|
512
513
|
@lslong = '' # Set short form ls (toggled by pressing "A")
|
513
514
|
@lsall = '' # Set "ls -a" to false (toggled by pressing "a" - sets it to "-a")
|
@@ -525,7 +526,8 @@ $stdin.set_encoding(Encoding::UTF_8)
|
|
525
526
|
@searchcolor = 23 # Default color for Search pane at bottom
|
526
527
|
@cmdcolor = 18 # Default color for Command pane at bottom
|
527
528
|
@rubycolor = 52 # Default color for Ruby pane at bottom
|
528
|
-
@aicolor = 58
|
529
|
+
@aicolor = 58 # Default color for AI pane at bottom
|
530
|
+
@sshcolor = 54 # Default color for SSH pane at bottom (purple-ish)
|
529
531
|
@lsbase = '--group-directories-first' # Basic ls setup
|
530
532
|
@lsuser = '' # Set this variable in ~/.rtfm/conf to any 'ls' switch you want to customize directory listings
|
531
533
|
@batuse = true # Use batcat for syntax highlighting
|
@@ -559,6 +561,25 @@ $stdin.set_encoding(Encoding::UTF_8)
|
|
559
561
|
@tab_bar_visible = false # Whether tab bar is currently shown
|
560
562
|
@tab_bar_hide_time = 0 # Time when tab bar should be hidden
|
561
563
|
|
564
|
+
## Undo system variables
|
565
|
+
@undo_history = [] # Array to store undo operations
|
566
|
+
@max_undo_levels = 20 # Maximum number of undo levels to keep
|
567
|
+
@undo_enabled = true # Enable/disable undo system
|
568
|
+
|
569
|
+
## Recently accessed files/directories
|
570
|
+
@recent_files = [] # Last 50 accessed files
|
571
|
+
@recent_dirs = [] # Last 20 accessed directories
|
572
|
+
@max_recent_files = 50 # Maximum recent files to track
|
573
|
+
@max_recent_dirs = 20 # Maximum recent directories to track
|
574
|
+
|
575
|
+
## Remote browsing variables
|
576
|
+
@remote_connections = {} # Cache of active remote connections
|
577
|
+
@current_remote = nil # Current remote connection info
|
578
|
+
@remote_cache = {} # Cache of remote directory listings
|
579
|
+
@remote_mode = false # Whether currently browsing remotely
|
580
|
+
@remote_path = '~' # Current remote directory path
|
581
|
+
@remote_files_cache = [] # Cache of current remote directory files with full info
|
582
|
+
|
562
583
|
# TAB MANAGEMENT FUNCTIONS {{{1
|
563
584
|
def create_tab(directory = Dir.pwd, name = nil) # {{{2
|
564
585
|
@tab_counter += 1
|
@@ -766,7 +787,6 @@ end
|
|
766
787
|
load_config
|
767
788
|
@marks['0'] = Dir.pwd # Original dir
|
768
789
|
@marks["'"] = Dir.pwd # Initial mark
|
769
|
-
#checkpoint("Vars/config loaded")
|
770
790
|
|
771
791
|
# Handle start dir {{{2
|
772
792
|
Dir.chdir(ARGV.shift) if ARGV[0] && File.directory?(ARGV[0])
|
@@ -835,7 +855,6 @@ PREVIEW_HANDLERS = preview_specs.map do |exts_str, tmpl|
|
|
835
855
|
exts = exts_str.split(',').map(&:strip).map { |ext| Regexp.escape(ext) }.join('|')
|
836
856
|
[/\.#{exts}$/i, tmpl]
|
837
857
|
end
|
838
|
-
#checkpoint("Plugins loaded")
|
839
858
|
|
840
859
|
# KEY DISPATCH TABLE & HANDLERS {{{1
|
841
860
|
KEYMAP = { # {{{2
|
@@ -855,7 +874,6 @@ KEYMAP = { # {{{2
|
|
855
874
|
'-' => :toggle_preview,
|
856
875
|
'_' => :toggle_image,
|
857
876
|
'b' => :toggle_syntax,
|
858
|
-
# '{' key available for future use (dual-pane mode removed)
|
859
877
|
|
860
878
|
# MOTION {{{3
|
861
879
|
'DOWN' => :move_down,
|
@@ -875,7 +893,6 @@ KEYMAP = { # {{{2
|
|
875
893
|
'PgUP' => :page_up,
|
876
894
|
'END' => :go_last,
|
877
895
|
'HOME' => :go_first,
|
878
|
-
# '}' key available for future use (dual-pane mode removed)
|
879
896
|
|
880
897
|
# MARKS & JUMPING {{{3
|
881
898
|
'm' => :set_mark,
|
@@ -897,6 +914,15 @@ KEYMAP = { # {{{2
|
|
897
914
|
'T' => :show_tagged,
|
898
915
|
'u' => :clear_tagged,
|
899
916
|
|
917
|
+
# UNDO {{{3
|
918
|
+
'U' => :undo_last_operation,
|
919
|
+
|
920
|
+
# RECENT FILES {{{3
|
921
|
+
'C-R' => :show_recent_files,
|
922
|
+
|
923
|
+
# REMOTE BROWSING {{{3
|
924
|
+
'C-E' => :browse_remote,
|
925
|
+
|
900
926
|
# TAB MANAGEMENT {{{3
|
901
927
|
']' => :new_tab,
|
902
928
|
'[' => :close_tab,
|
@@ -918,6 +944,8 @@ KEYMAP = { # {{{2
|
|
918
944
|
'p' => :copy_items,
|
919
945
|
'P' => :move_items,
|
920
946
|
'c' => :rename_item,
|
947
|
+
'E' => :bulk_rename,
|
948
|
+
'X' => :compare_files,
|
921
949
|
's' => :link_items,
|
922
950
|
'd' => :delete_items,
|
923
951
|
'D' => :empty_trash,
|
@@ -970,7 +998,9 @@ KEYMAP = { # {{{2
|
|
970
998
|
# COMMAND MODE {{{3
|
971
999
|
':' => :command_mode,
|
972
1000
|
';' => :show_history,
|
1001
|
+
'C-;' => :show_ssh_history,
|
973
1002
|
'+' => :add_interactive,
|
1003
|
+
'e' => :show_file_properties,
|
974
1004
|
|
975
1005
|
# RUBY MODE {{{3
|
976
1006
|
'@' => :ruby_debug
|
@@ -1010,7 +1040,57 @@ end
|
|
1010
1040
|
|
1011
1041
|
# BASIC KEYS {{{2
|
1012
1042
|
def show_help # {{{3
|
1013
|
-
|
1043
|
+
help_info
|
1044
|
+
end
|
1045
|
+
|
1046
|
+
def help_info # {{{3
|
1047
|
+
help_text = "RTFM Help\n".b.fg(156)
|
1048
|
+
help_text << "=" * 50 + "\n\n"
|
1049
|
+
|
1050
|
+
# First pass: find the longest key to determine column alignment
|
1051
|
+
max_key_length = 0
|
1052
|
+
@help.lines.each do |line|
|
1053
|
+
if line =~ /^ ([\S\/\-]+)\s+=\s+/
|
1054
|
+
key_length = $1.length
|
1055
|
+
max_key_length = key_length if key_length > max_key_length
|
1056
|
+
end
|
1057
|
+
end
|
1058
|
+
|
1059
|
+
# Add some padding for visual clarity
|
1060
|
+
equal_column = max_key_length + 2
|
1061
|
+
desc_column = equal_column + 2 # Description starts 2 spaces after '='
|
1062
|
+
|
1063
|
+
# Process help text with formatting but create completely new strings
|
1064
|
+
@help.lines.each do |line|
|
1065
|
+
content = line.chomp # Remove newline and create new string
|
1066
|
+
|
1067
|
+
case content
|
1068
|
+
when /^[A-Z][A-Z \/]+$/ # Section headers like "BASIC KEYS", "GIT/HASH/OPENAI" (no leading spaces)
|
1069
|
+
help_text << content.b.fg(156) + "\n" # Removed extra newline before
|
1070
|
+
when /^ RTFM - Ruby Terminal File Manager/ # Title
|
1071
|
+
help_text << content.b.fg(154) + "\n"
|
1072
|
+
when /^COPYRIGHT:/ # Copyright line (no leading spaces)
|
1073
|
+
help_text << "\n" + content.fg(242) + "\n"
|
1074
|
+
when /^ (\S+)\s+=\s+/ # Key lines: " key = description"
|
1075
|
+
# Extract the key part and the description part
|
1076
|
+
if content =~ /^ ([\S\/\-]+)\s+=\s+(.*)$/
|
1077
|
+
key_part = $1
|
1078
|
+
desc_part = $2
|
1079
|
+
padding = " " * (equal_column - key_part.length)
|
1080
|
+
help_text << " " + key_part.fg(51) + padding + "= ".fg(252) + desc_part.fg(252) + "\n" # Changed to lighter gray (252)
|
1081
|
+
else
|
1082
|
+
help_text << content.fg(252) + "\n"
|
1083
|
+
end
|
1084
|
+
when /^ / # Continuation lines (12 spaces)
|
1085
|
+
# Align continuation lines with the description column
|
1086
|
+
indent = " " * (2 + desc_column) # 2 for initial indent + column position
|
1087
|
+
help_text << indent + content.strip.fg(252) + "\n"
|
1088
|
+
else
|
1089
|
+
help_text << content.fg(252) + "\n"
|
1090
|
+
end
|
1091
|
+
end
|
1092
|
+
|
1093
|
+
@pR.say(help_text)
|
1014
1094
|
end
|
1015
1095
|
|
1016
1096
|
def show_version # {{{3
|
@@ -1093,38 +1173,82 @@ end
|
|
1093
1173
|
def move_down # {{{3
|
1094
1174
|
@index = @index >= @max_index ? @min_index : @index + 1
|
1095
1175
|
@pL.update = true
|
1096
|
-
|
1176
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1177
|
+
if @remote_mode
|
1178
|
+
@pB.update = true
|
1179
|
+
else
|
1180
|
+
@pR.update = @pB.update = true
|
1181
|
+
end
|
1097
1182
|
end
|
1098
1183
|
|
1099
1184
|
def move_up # {{{3
|
1100
1185
|
@index = @index <= @min_index ? @max_index : @index - 1
|
1101
1186
|
@pL.update = true
|
1102
|
-
|
1187
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1188
|
+
if @remote_mode
|
1189
|
+
@pB.update = true
|
1190
|
+
else
|
1191
|
+
@pR.update = @pB.update = true
|
1192
|
+
end
|
1103
1193
|
end
|
1104
1194
|
|
1105
1195
|
def move_left # {{{3
|
1106
|
-
|
1107
|
-
|
1108
|
-
|
1109
|
-
|
1110
|
-
|
1111
|
-
|
1112
|
-
|
1113
|
-
|
1114
|
-
|
1115
|
-
|
1116
|
-
|
1117
|
-
|
1196
|
+
if @remote_mode
|
1197
|
+
# Remote mode - go to parent directory
|
1198
|
+
return if @remote_path == '/' || @remote_path == '~'
|
1199
|
+
|
1200
|
+
old_path = @remote_path
|
1201
|
+
@remote_path = File.dirname(@remote_path)
|
1202
|
+
@remote_files_cache = [] # Clear cache when changing directories
|
1203
|
+
@index = 0 # Reset selection
|
1204
|
+
@pL.update = true
|
1205
|
+
@pR.update = @pB.update = true
|
1206
|
+
else
|
1207
|
+
# Local mode
|
1208
|
+
old_dir = Dir.pwd
|
1209
|
+
parent = File.dirname(old_dir)
|
1210
|
+
child = File.basename(old_dir)
|
1211
|
+
purels = command(
|
1212
|
+
"ls #{Shellwords.escape(parent)} #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}"
|
1213
|
+
).pure.split("\n")
|
1214
|
+
child_idx = purels.index(child) || 0
|
1215
|
+
@directory[parent] = child_idx
|
1216
|
+
mark_latest
|
1217
|
+
Dir.chdir(parent)
|
1218
|
+
track_directory_access(parent)
|
1219
|
+
@pL.update = true
|
1220
|
+
@pR.update = @pB.update = true
|
1221
|
+
end
|
1118
1222
|
end
|
1119
1223
|
|
1120
1224
|
# dirlist_simple function removed - was only for dual-pane navigation
|
1121
1225
|
|
1122
1226
|
def move_right # {{{3
|
1123
|
-
|
1124
|
-
|
1125
|
-
|
1126
|
-
|
1127
|
-
|
1227
|
+
if @remote_mode
|
1228
|
+
# Remote mode - enter directory or perform action on file
|
1229
|
+
return unless @files && @files[@index] && @remote_files_cache[@index]
|
1230
|
+
|
1231
|
+
selected_file = @remote_files_cache[@index]
|
1232
|
+
|
1233
|
+
if selected_file[:type] == 'directory'
|
1234
|
+
# Enter directory
|
1235
|
+
@remote_path = File.join(@remote_path, selected_file[:name])
|
1236
|
+
@remote_files_cache = [] # Clear cache when changing directories
|
1237
|
+
@index = 0 # Reset selection
|
1238
|
+
@pL.update = true
|
1239
|
+
@pR.update = @pB.update = true
|
1240
|
+
else
|
1241
|
+
# File selected - show file info in right pane
|
1242
|
+
show_remote_file_info(selected_file)
|
1243
|
+
end
|
1244
|
+
else
|
1245
|
+
# Local mode
|
1246
|
+
@directory[Dir.pwd] = @index
|
1247
|
+
mark_latest
|
1248
|
+
open_selected
|
1249
|
+
@index = @directory[Dir.pwd] || 0
|
1250
|
+
@pB.update = true
|
1251
|
+
end
|
1128
1252
|
end
|
1129
1253
|
|
1130
1254
|
def open_force # {{{3
|
@@ -1137,23 +1261,47 @@ end
|
|
1137
1261
|
def page_down # {{{3
|
1138
1262
|
@index += @pL.h - 2
|
1139
1263
|
@index = @max_index if @index > @max_index
|
1140
|
-
@
|
1264
|
+
@pL.update = true
|
1265
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1266
|
+
if @remote_mode
|
1267
|
+
@pB.update = true
|
1268
|
+
else
|
1269
|
+
@pR.update = @pB.update = true
|
1270
|
+
end
|
1141
1271
|
end
|
1142
1272
|
|
1143
1273
|
def page_up # {{{3
|
1144
1274
|
@index -= @pL.h - 2
|
1145
1275
|
@index = @min_index if @index < @min_index
|
1146
|
-
@
|
1276
|
+
@pL.update = true
|
1277
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1278
|
+
if @remote_mode
|
1279
|
+
@pB.update = true
|
1280
|
+
else
|
1281
|
+
@pR.update = @pB.update = true
|
1282
|
+
end
|
1147
1283
|
end
|
1148
1284
|
|
1149
1285
|
def go_last # {{{3
|
1150
1286
|
@index = @max_index
|
1151
|
-
@
|
1287
|
+
@pL.update = true
|
1288
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1289
|
+
if @remote_mode
|
1290
|
+
@pB.update = true
|
1291
|
+
else
|
1292
|
+
@pR.update = @pB.update = true
|
1293
|
+
end
|
1152
1294
|
end
|
1153
1295
|
|
1154
1296
|
def go_first # {{{3
|
1155
1297
|
@index = @min_index
|
1156
|
-
@
|
1298
|
+
@pL.update = true
|
1299
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1300
|
+
if @remote_mode
|
1301
|
+
@pB.update = true
|
1302
|
+
else
|
1303
|
+
@pR.update = @pB.update = true
|
1304
|
+
end
|
1157
1305
|
end
|
1158
1306
|
|
1159
1307
|
# switch_pane function removed - using tabs for multi-directory navigation
|
@@ -1186,7 +1334,7 @@ def jump_to_mark # {{{3
|
|
1186
1334
|
if m =~ /[\w']/ && @marks[m]
|
1187
1335
|
@directory[Dir.pwd] = @index
|
1188
1336
|
dir_before = Dir.pwd
|
1189
|
-
begin; Dir.chdir(@marks[m]); rescue; @pB.say(' No such directory'); end
|
1337
|
+
begin; Dir.chdir(@marks[m]); track_directory_access(@marks[m]); rescue; @pB.say(' No such directory'); end
|
1190
1338
|
mark_latest
|
1191
1339
|
@marks["'"] = dir_before
|
1192
1340
|
end
|
@@ -1265,118 +1413,1489 @@ def follow_symlink # {{{3
|
|
1265
1413
|
@pB.update = true
|
1266
1414
|
end
|
1267
1415
|
|
1268
|
-
# DIRECTORY VIEWS {{{2
|
1269
|
-
def toggle_all # {{{3
|
1270
|
-
@lsall = @lsall.empty? ? '-a' : ''
|
1271
|
-
@pR.update = @pB.update = true
|
1416
|
+
# DIRECTORY VIEWS {{{2
|
1417
|
+
def toggle_all # {{{3
|
1418
|
+
@lsall = @lsall.empty? ? '-a' : ''
|
1419
|
+
@pR.update = @pB.update = true
|
1420
|
+
end
|
1421
|
+
|
1422
|
+
def toggle_long # {{{3
|
1423
|
+
@lslong = @lslong.empty? ? '-lh --time-style=long-iso' : ''
|
1424
|
+
@pR.update = @pB.update = true
|
1425
|
+
end
|
1426
|
+
|
1427
|
+
def toggle_order # {{{3
|
1428
|
+
case @lsorder
|
1429
|
+
when ''
|
1430
|
+
@lsorder = '-S'; @pB.say(' Sorting by size')
|
1431
|
+
when '-S'
|
1432
|
+
@lsorder = '-t'; @pB.say(' Sorting by time')
|
1433
|
+
when '-t'
|
1434
|
+
@lsorder = '-X'; @pB.say(' Sorting by extension')
|
1435
|
+
else
|
1436
|
+
@lsorder = ''; @pB.say(' Normal sorting')
|
1437
|
+
end
|
1438
|
+
@pR.update = true; @orderchange = true
|
1439
|
+
end
|
1440
|
+
|
1441
|
+
def toggle_invert # {{{3
|
1442
|
+
@lsinvert = @lsinvert.empty? ? '-r' : ''
|
1443
|
+
@pB.say(' Sorting inverted')
|
1444
|
+
@pR.update = true; @orderchange = true
|
1445
|
+
end
|
1446
|
+
|
1447
|
+
def show_ls_command # {{{3
|
1448
|
+
@pB.say(" Full 'ls' command: ls #{@lsbase} #{@lslong} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}".gsub(/ +/, ' '))
|
1449
|
+
@pB.update = false
|
1450
|
+
end
|
1451
|
+
|
1452
|
+
# TAGGING {{{2
|
1453
|
+
def tag_current # {{{3
|
1454
|
+
if @dual_pane
|
1455
|
+
# In dual-pane mode, get the correct selected item and construct full path
|
1456
|
+
current_dir = @active_pane == :left ? @pwd_left : @pwd_right
|
1457
|
+
current_index = @active_pane == :left ? @index_left : @index_right
|
1458
|
+
current_files = @active_pane == :left ? @files_left : @files_right
|
1459
|
+
|
1460
|
+
if current_files && current_index < current_files.length
|
1461
|
+
selected_item = current_files[current_index]
|
1462
|
+
item = File.join(current_dir, selected_item)
|
1463
|
+
|
1464
|
+
# Tag/untag the item
|
1465
|
+
if @tagged.include?(item)
|
1466
|
+
@tagged.delete(item); @tagsize -= File.size(item) rescue 0
|
1467
|
+
else
|
1468
|
+
@tagged.push(item); @tagsize += File.size(item) rescue 0
|
1469
|
+
end
|
1470
|
+
|
1471
|
+
# Advance to next item in the active pane
|
1472
|
+
max_index = current_files.size - 1
|
1473
|
+
if @active_pane == :left
|
1474
|
+
@index_left = [@index_left + 1, max_index].min
|
1475
|
+
@selected_left = current_files[@index_left] if current_files[@index_left]
|
1476
|
+
@pLeft.update = true
|
1477
|
+
else
|
1478
|
+
@index_right = [@index_right + 1, max_index].min
|
1479
|
+
@selected_right = current_files[@index_right] if current_files[@index_right]
|
1480
|
+
@pRight.update = true
|
1481
|
+
end
|
1482
|
+
|
1483
|
+
# Update compatibility variables
|
1484
|
+
@index = @active_pane == :left ? @index_left : @index_right
|
1485
|
+
@selected = @active_pane == :left ? @selected_left : @selected_right
|
1486
|
+
|
1487
|
+
@pPreview.update = true if @pPreview
|
1488
|
+
end
|
1489
|
+
else
|
1490
|
+
# Original single-pane logic
|
1491
|
+
item = @selected
|
1492
|
+
if @tagged.include?(item)
|
1493
|
+
@tagged.delete(item); @tagsize -= File.size(item) rescue 0
|
1494
|
+
else
|
1495
|
+
@tagged.push(item); @tagsize += File.size(item) rescue 0
|
1496
|
+
end
|
1497
|
+
@index = [@index + 1, (@files.size - 1)].min
|
1498
|
+
@pL.update = true
|
1499
|
+
end
|
1500
|
+
|
1501
|
+
@pB.say(" Tagged #{@tagged.size} files (#{(@tagsize.to_f / 1_000_000).round(2)}MB)".fg(204))
|
1502
|
+
@pB.update = false; @pR.update = true
|
1503
|
+
end
|
1504
|
+
|
1505
|
+
def tag_pattern # {{{3
|
1506
|
+
pat = @pB.ask('Tag pattern (ruby regex): ', '')
|
1507
|
+
re = Regexp.new(pat)
|
1508
|
+
matches = @files.grep(re).map { |t| File.join(Dir.pwd, t) }
|
1509
|
+
matches.each do |f|
|
1510
|
+
@tagsize += File.size(f) rescue nil
|
1511
|
+
end
|
1512
|
+
@tagged.concat(matches)
|
1513
|
+
@tagged.uniq!
|
1514
|
+
@pB.say(" Tagged #{@tagged.size} files (#{(@tagsize.to_f / 1_000_000).round(2)}MB)".fg(204))
|
1515
|
+
@pB.update = false
|
1516
|
+
@pR.update = true
|
1517
|
+
end
|
1518
|
+
|
1519
|
+
def show_tagged # {{{3
|
1520
|
+
tagged_info
|
1521
|
+
@pB.update = true
|
1522
|
+
end
|
1523
|
+
|
1524
|
+
def clear_tagged # {{{3
|
1525
|
+
if @remote_mode
|
1526
|
+
# In remote mode, 'u' key uploads a file
|
1527
|
+
remote_upload_file
|
1528
|
+
return
|
1529
|
+
end
|
1530
|
+
|
1531
|
+
@tagged = []
|
1532
|
+
tagged_info
|
1533
|
+
@pB.update = true
|
1534
|
+
end
|
1535
|
+
|
1536
|
+
# UNDO SYSTEM {{{2
|
1537
|
+
def add_undo_operation(operation) # {{{3
|
1538
|
+
return unless @undo_enabled
|
1539
|
+
|
1540
|
+
@undo_history << operation
|
1541
|
+
# Keep only the most recent operations
|
1542
|
+
@undo_history.shift if @undo_history.length > @max_undo_levels
|
1543
|
+
end
|
1544
|
+
|
1545
|
+
def undo_last_operation # {{{3
|
1546
|
+
return unless @undo_enabled
|
1547
|
+
|
1548
|
+
if @undo_history.empty?
|
1549
|
+
@pB.say("No operations to undo".fg(196))
|
1550
|
+
return
|
1551
|
+
end
|
1552
|
+
|
1553
|
+
operation = @undo_history.pop
|
1554
|
+
|
1555
|
+
begin
|
1556
|
+
case operation[:type]
|
1557
|
+
when 'delete'
|
1558
|
+
undo_delete(operation)
|
1559
|
+
when 'move'
|
1560
|
+
undo_move(operation)
|
1561
|
+
when 'rename'
|
1562
|
+
undo_rename(operation)
|
1563
|
+
when 'copy'
|
1564
|
+
undo_copy(operation)
|
1565
|
+
when 'link'
|
1566
|
+
undo_link(operation)
|
1567
|
+
when 'bulk_rename'
|
1568
|
+
undo_bulk_rename(operation)
|
1569
|
+
else
|
1570
|
+
@pB.say("Unknown operation type: #{operation[:type]}".fg(196))
|
1571
|
+
return
|
1572
|
+
end
|
1573
|
+
|
1574
|
+
dirlist
|
1575
|
+
render
|
1576
|
+
@pB.say("Undid #{operation[:type]} operation".fg(156))
|
1577
|
+
rescue StandardError => e
|
1578
|
+
@pB.say("Undo failed: #{e.message}".fg(196))
|
1579
|
+
# Don't re-add the operation to history if undo failed
|
1580
|
+
end
|
1581
|
+
end
|
1582
|
+
|
1583
|
+
def undo_delete(operation) # {{{3
|
1584
|
+
if operation[:trash]
|
1585
|
+
# Restore from trash
|
1586
|
+
operation[:paths].each do |path_info|
|
1587
|
+
source = File.join(TRASH_DIR, File.basename(path_info[:path]))
|
1588
|
+
dest = path_info[:path]
|
1589
|
+
|
1590
|
+
if File.exist?(source)
|
1591
|
+
FileUtils.mv(source, dest)
|
1592
|
+
else
|
1593
|
+
raise "Cannot restore #{path_info[:path]}: not found in trash"
|
1594
|
+
end
|
1595
|
+
end
|
1596
|
+
else
|
1597
|
+
raise "Cannot undo permanent deletion"
|
1598
|
+
end
|
1599
|
+
end
|
1600
|
+
|
1601
|
+
def undo_move(operation) # {{{3
|
1602
|
+
# Move items back to their original locations
|
1603
|
+
operation[:moves].each do |move_info|
|
1604
|
+
source = move_info[:dest_path]
|
1605
|
+
dest = move_info[:source_path]
|
1606
|
+
|
1607
|
+
if File.exist?(source)
|
1608
|
+
FileUtils.mv(source, dest)
|
1609
|
+
else
|
1610
|
+
raise "Cannot undo move: #{source} not found"
|
1611
|
+
end
|
1612
|
+
end
|
1613
|
+
end
|
1614
|
+
|
1615
|
+
def undo_rename(operation) # {{{3
|
1616
|
+
old_path = operation[:old_path]
|
1617
|
+
new_path = operation[:new_path]
|
1618
|
+
|
1619
|
+
if File.exist?(new_path)
|
1620
|
+
FileUtils.mv(new_path, old_path)
|
1621
|
+
else
|
1622
|
+
raise "Cannot undo rename: #{new_path} not found"
|
1623
|
+
end
|
1624
|
+
end
|
1625
|
+
|
1626
|
+
def undo_copy(operation) # {{{3
|
1627
|
+
# Remove copied files
|
1628
|
+
operation[:copies].each do |copy_info|
|
1629
|
+
dest = copy_info[:dest_path]
|
1630
|
+
|
1631
|
+
if File.exist?(dest)
|
1632
|
+
if File.directory?(dest)
|
1633
|
+
FileUtils.rm_rf(dest)
|
1634
|
+
else
|
1635
|
+
FileUtils.rm(dest)
|
1636
|
+
end
|
1637
|
+
end
|
1638
|
+
end
|
1639
|
+
end
|
1640
|
+
|
1641
|
+
def undo_link(operation) # {{{3
|
1642
|
+
# Remove symlinks
|
1643
|
+
operation[:links].each do |link_info|
|
1644
|
+
dest = link_info[:dest_path]
|
1645
|
+
|
1646
|
+
if File.symlink?(dest)
|
1647
|
+
FileUtils.rm(dest)
|
1648
|
+
elsif File.exist?(dest)
|
1649
|
+
raise "Cannot undo link: #{dest} is not a symlink"
|
1650
|
+
end
|
1651
|
+
end
|
1652
|
+
end
|
1653
|
+
|
1654
|
+
def undo_bulk_rename(operation) # {{{3
|
1655
|
+
# Undo bulk rename operations by reversing each rename
|
1656
|
+
operation[:renames].reverse.each do |rename_info|
|
1657
|
+
old_path = rename_info[:old_path]
|
1658
|
+
new_path = rename_info[:new_path]
|
1659
|
+
|
1660
|
+
if File.exist?(new_path)
|
1661
|
+
FileUtils.mv(new_path, old_path)
|
1662
|
+
else
|
1663
|
+
raise "Cannot undo bulk rename: #{new_path} not found"
|
1664
|
+
end
|
1665
|
+
end
|
1666
|
+
end
|
1667
|
+
|
1668
|
+
# RECENTLY ACCESSED FILES {{{2
|
1669
|
+
def track_file_access(file_path) # {{{3
|
1670
|
+
return unless File.exist?(file_path)
|
1671
|
+
|
1672
|
+
abs_path = File.expand_path(file_path)
|
1673
|
+
@recent_files.delete(abs_path) # Remove if already exists
|
1674
|
+
@recent_files.unshift(abs_path) # Add to front
|
1675
|
+
@recent_files = @recent_files.first(@max_recent_files) # Limit size
|
1676
|
+
end
|
1677
|
+
|
1678
|
+
def track_directory_access(dir_path) # {{{3
|
1679
|
+
return unless File.directory?(dir_path)
|
1680
|
+
|
1681
|
+
abs_path = File.expand_path(dir_path)
|
1682
|
+
@recent_dirs.delete(abs_path) # Remove if already exists
|
1683
|
+
@recent_dirs.unshift(abs_path) # Add to front
|
1684
|
+
@recent_dirs = @recent_dirs.first(@max_recent_dirs) # Limit size
|
1685
|
+
end
|
1686
|
+
|
1687
|
+
def show_recent_files # {{{3
|
1688
|
+
text = "Recently Accessed Files and Directories\n".b.fg(156)
|
1689
|
+
text << "=" * 40 + "\n\n"
|
1690
|
+
|
1691
|
+
unless @recent_files.empty?
|
1692
|
+
text << "Files:\n".fg(226)
|
1693
|
+
@recent_files.first(15).each_with_index do |file, i|
|
1694
|
+
basename = File.basename(file)
|
1695
|
+
dirname = File.dirname(file)
|
1696
|
+
mtime = File.exist?(file) ? File.mtime(file).strftime("%Y-%m-%d %H:%M") : "MISSING"
|
1697
|
+
text << sprintf("%2d. %-30s %s\n", i + 1, basename.fg(156), "#{dirname} (#{mtime})".fg(240))
|
1698
|
+
end
|
1699
|
+
text << "\n"
|
1700
|
+
end
|
1701
|
+
|
1702
|
+
unless @recent_dirs.empty?
|
1703
|
+
text << "Directories:\n".fg(226)
|
1704
|
+
@recent_dirs.first(10).each_with_index do |dir, i|
|
1705
|
+
basename = File.basename(dir)
|
1706
|
+
parent = File.dirname(dir)
|
1707
|
+
exists = File.exist?(dir) ? "✓".fg(156) : "✗".fg(196)
|
1708
|
+
text << sprintf("%2d. %s %-25s %s\n", i + 1, exists, basename.fg(156), parent.fg(240))
|
1709
|
+
end
|
1710
|
+
end
|
1711
|
+
|
1712
|
+
if @recent_files.empty? && @recent_dirs.empty?
|
1713
|
+
text << "No recently accessed files or directories.\n".fg(240)
|
1714
|
+
text << "Files and directories will appear here as you use RTFM.\n".fg(240)
|
1715
|
+
else
|
1716
|
+
text << "\nPress number to jump to item, or any other key to close.".fg(240)
|
1717
|
+
end
|
1718
|
+
|
1719
|
+
@pR.say(text)
|
1720
|
+
@pR.update = false
|
1721
|
+
|
1722
|
+
# Handle selection
|
1723
|
+
chr = getchr
|
1724
|
+
if chr =~ /\d/
|
1725
|
+
num = chr.to_i
|
1726
|
+
if num > 0 && num <= @recent_files.length
|
1727
|
+
# Jump to recent file
|
1728
|
+
target = @recent_files[num - 1]
|
1729
|
+
if File.exist?(target)
|
1730
|
+
Dir.chdir(File.dirname(target))
|
1731
|
+
dirlist
|
1732
|
+
# Select the file in the list
|
1733
|
+
basename = File.basename(target)
|
1734
|
+
@index = @files.index(basename) || 0
|
1735
|
+
@selected = target
|
1736
|
+
render
|
1737
|
+
else
|
1738
|
+
@pB.say("File no longer exists: #{target}".fg(196))
|
1739
|
+
end
|
1740
|
+
elsif num > 0 && num <= @recent_dirs.length + @recent_files.length
|
1741
|
+
# Jump to recent directory
|
1742
|
+
dir_index = num - @recent_files.length - 1
|
1743
|
+
if dir_index >= 0 && dir_index < @recent_dirs.length
|
1744
|
+
target = @recent_dirs[dir_index]
|
1745
|
+
if File.directory?(target)
|
1746
|
+
Dir.chdir(target)
|
1747
|
+
dirlist
|
1748
|
+
render
|
1749
|
+
else
|
1750
|
+
@pB.say("Directory no longer exists: #{target}".fg(196))
|
1751
|
+
end
|
1752
|
+
end
|
1753
|
+
end
|
1754
|
+
end
|
1755
|
+
|
1756
|
+
@pR.update = true
|
1757
|
+
end
|
1758
|
+
|
1759
|
+
# FILE PROPERTIES {{{2
|
1760
|
+
def show_file_properties # {{{3
|
1761
|
+
return unless @selected && File.exist?(@selected)
|
1762
|
+
|
1763
|
+
begin
|
1764
|
+
stat = File.stat(@selected)
|
1765
|
+
text = "File Properties: #{File.basename(@selected)}\n".b.fg(156)
|
1766
|
+
text << "=" * 50 + "\n\n"
|
1767
|
+
|
1768
|
+
# Basic information
|
1769
|
+
text << "Basic Information:\n".fg(226)
|
1770
|
+
text << sprintf(" %-20s %s\n", "Full Path:", @selected.fg(156))
|
1771
|
+
text << sprintf(" %-20s %s\n", "Directory:", File.dirname(@selected).fg(240))
|
1772
|
+
text << sprintf(" %-20s %s\n", "Size:", format_file_size(stat.size))
|
1773
|
+
text << sprintf(" %-20s %s\n", "Type:", File.ftype(@selected).capitalize.fg(156))
|
1774
|
+
|
1775
|
+
# MIME type
|
1776
|
+
begin
|
1777
|
+
mime_output = `file --mime-type #{Shellwords.escape(@selected)} 2>/dev/null`.strip
|
1778
|
+
mime_type = mime_output.split(':')[1]&.strip || "Unknown"
|
1779
|
+
text << sprintf(" %-20s %s\n", "MIME Type:", mime_type.fg(156))
|
1780
|
+
rescue
|
1781
|
+
text << sprintf(" %-20s %s\n", "MIME Type:", "Unknown".fg(240))
|
1782
|
+
end
|
1783
|
+
|
1784
|
+
text << "\n"
|
1785
|
+
|
1786
|
+
# Permissions and ownership
|
1787
|
+
text << "Permissions & Ownership:\n".fg(226)
|
1788
|
+
mode_oct = sprintf("%04o", stat.mode & 0777)
|
1789
|
+
mode_str = File.world_readable?(@selected) ?
|
1790
|
+
sprintf("%s (readable)", mode_oct) :
|
1791
|
+
sprintf("%s (protected)", mode_oct)
|
1792
|
+
text << sprintf(" %-20s %s\n", "Permissions:", mode_str.fg(156))
|
1793
|
+
|
1794
|
+
begin
|
1795
|
+
require 'etc'
|
1796
|
+
owner = Etc.getpwuid(stat.uid).name rescue stat.uid.to_s
|
1797
|
+
group = Etc.getgrgid(stat.gid).name rescue stat.gid.to_s
|
1798
|
+
text << sprintf(" %-20s %s\n", "Owner:Group:", "#{owner}:#{group}".fg(156))
|
1799
|
+
rescue
|
1800
|
+
text << sprintf(" %-20s %s\n", "Owner:Group:", "#{stat.uid}:#{stat.gid}".fg(156))
|
1801
|
+
end
|
1802
|
+
|
1803
|
+
text << "\n"
|
1804
|
+
|
1805
|
+
# Timestamps
|
1806
|
+
text << "Timestamps:\n".fg(226)
|
1807
|
+
text << sprintf(" %-20s %s\n", "Created:", stat.ctime.strftime("%Y-%m-%d %H:%M:%S").fg(156))
|
1808
|
+
text << sprintf(" %-20s %s\n", "Modified:", stat.mtime.strftime("%Y-%m-%d %H:%M:%S").fg(156))
|
1809
|
+
text << sprintf(" %-20s %s\n", "Accessed:", stat.atime.strftime("%Y-%m-%d %H:%M:%S").fg(156))
|
1810
|
+
|
1811
|
+
# Symlink information
|
1812
|
+
if File.symlink?(@selected)
|
1813
|
+
text << "\n"
|
1814
|
+
text << "Symlink Information:\n".fg(226)
|
1815
|
+
begin
|
1816
|
+
target = File.readlink(@selected)
|
1817
|
+
text << sprintf(" %-20s %s\n", "Points to:", target.fg(156))
|
1818
|
+
text << sprintf(" %-20s %s\n", "Target exists:", File.exist?(target) ? "Yes".fg(156) : "No".fg(196))
|
1819
|
+
rescue
|
1820
|
+
text << sprintf(" %-20s %s\n", "Target:", "Cannot read link".fg(196))
|
1821
|
+
end
|
1822
|
+
end
|
1823
|
+
|
1824
|
+
# Directory-specific information
|
1825
|
+
if File.directory?(@selected)
|
1826
|
+
text << "\n"
|
1827
|
+
text << "Directory Information:\n".fg(226)
|
1828
|
+
begin
|
1829
|
+
entries = Dir.entries(@selected).reject { |e| e == '.' || e == '..' }
|
1830
|
+
text << sprintf(" %-20s %d\n", "Total entries:", entries.length)
|
1831
|
+
dirs = entries.select { |e| File.directory?(File.join(@selected, e)) }
|
1832
|
+
files = entries.select { |e| File.file?(File.join(@selected, e)) }
|
1833
|
+
text << sprintf(" %-20s %s\n", "Breakdown:", "#{dirs.length} directories, #{files.length} files")
|
1834
|
+
rescue
|
1835
|
+
text << sprintf(" %-20s %s\n", "Contents:", "Cannot read directory".fg(196))
|
1836
|
+
end
|
1837
|
+
end
|
1838
|
+
|
1839
|
+
# File-specific information
|
1840
|
+
if File.file?(@selected)
|
1841
|
+
text << "\n"
|
1842
|
+
text << "File Information:\n".fg(226)
|
1843
|
+
|
1844
|
+
# Try to get checksum for regular files
|
1845
|
+
if stat.size < 100 * 1024 * 1024 # Only for files under 100MB
|
1846
|
+
begin
|
1847
|
+
require 'digest'
|
1848
|
+
checksum = Digest::SHA256.file(@selected).hexdigest[0, 16]
|
1849
|
+
text << sprintf(" %-20s %s...\n", "SHA256 (partial):", checksum.fg(156))
|
1850
|
+
rescue
|
1851
|
+
text << sprintf(" %-20s %s\n", "Checksum:", "Cannot calculate".fg(240))
|
1852
|
+
end
|
1853
|
+
else
|
1854
|
+
text << sprintf(" %-20s %s\n", "Checksum:", "File too large".fg(240))
|
1855
|
+
end
|
1856
|
+
|
1857
|
+
# Check if binary or text
|
1858
|
+
begin
|
1859
|
+
File.open(@selected, 'rb') do |f|
|
1860
|
+
chunk = f.read(1024)
|
1861
|
+
is_binary = chunk && chunk.encoding == Encoding::ASCII_8BIT &&
|
1862
|
+
(chunk.bytes.any? { |b| b < 32 && ![9, 10, 13].include?(b) })
|
1863
|
+
text << sprintf(" %-20s %s\n", "Content type:", is_binary ? "Binary".fg(196) : "Text".fg(156))
|
1864
|
+
end
|
1865
|
+
rescue
|
1866
|
+
text << sprintf(" %-20s %s\n", "Content type:", "Unknown".fg(240))
|
1867
|
+
end
|
1868
|
+
end
|
1869
|
+
|
1870
|
+
text << "\n"
|
1871
|
+
text << "Press any key to close...".fg(240)
|
1872
|
+
|
1873
|
+
@pR.say(text)
|
1874
|
+
@pR.update = false
|
1875
|
+
getchr
|
1876
|
+
@pR.update = true
|
1877
|
+
|
1878
|
+
rescue StandardError => e
|
1879
|
+
@pB.say("Error getting file properties: #{e.message}".fg(196))
|
1880
|
+
end
|
1881
|
+
end
|
1882
|
+
|
1883
|
+
def format_file_size(bytes) # {{{3
|
1884
|
+
units = ['B', 'KB', 'MB', 'GB', 'TB']
|
1885
|
+
size = bytes.to_f
|
1886
|
+
unit_index = 0
|
1887
|
+
|
1888
|
+
while size >= 1024 && unit_index < units.length - 1
|
1889
|
+
size /= 1024.0
|
1890
|
+
unit_index += 1
|
1891
|
+
end
|
1892
|
+
|
1893
|
+
if unit_index == 0
|
1894
|
+
"#{size.to_i} #{units[unit_index]}".fg(156)
|
1895
|
+
else
|
1896
|
+
"#{sprintf('%.1f', size)} #{units[unit_index]}".fg(156)
|
1897
|
+
end
|
1898
|
+
end
|
1899
|
+
|
1900
|
+
# BULK RENAME {{{2
|
1901
|
+
def bulk_rename # {{{3
|
1902
|
+
if @tagged.empty?
|
1903
|
+
@pB.say("No files tagged for bulk rename. Tag files first with 't'".fg(196))
|
1904
|
+
return
|
1905
|
+
end
|
1906
|
+
|
1907
|
+
@pB.say("Bulk rename pattern: ".fg(156))
|
1908
|
+
@pR.say(build_pattern_help)
|
1909
|
+
@pR.update = false
|
1910
|
+
|
1911
|
+
pattern = @pCmd.ask('Pattern: ', '')
|
1912
|
+
return if pattern.nil? || pattern.strip.empty?
|
1913
|
+
|
1914
|
+
# Parse and preview renames
|
1915
|
+
rename_operations = []
|
1916
|
+
errors = []
|
1917
|
+
|
1918
|
+
@tagged.each do |file|
|
1919
|
+
next unless File.exist?(file)
|
1920
|
+
|
1921
|
+
old_name = File.basename(file)
|
1922
|
+
new_name = apply_rename_pattern(old_name, pattern)
|
1923
|
+
|
1924
|
+
if new_name && new_name != old_name
|
1925
|
+
old_path = file
|
1926
|
+
new_path = File.join(File.dirname(file), new_name)
|
1927
|
+
|
1928
|
+
if File.exist?(new_path) && new_path != old_path
|
1929
|
+
errors << "#{old_name} -> #{new_name} (target exists)"
|
1930
|
+
else
|
1931
|
+
rename_operations << {
|
1932
|
+
old_path: old_path,
|
1933
|
+
new_path: new_path,
|
1934
|
+
old_name: old_name,
|
1935
|
+
new_name: new_name
|
1936
|
+
}
|
1937
|
+
end
|
1938
|
+
end
|
1939
|
+
end
|
1940
|
+
|
1941
|
+
# Show preview
|
1942
|
+
show_rename_preview(rename_operations, errors, pattern)
|
1943
|
+
|
1944
|
+
if rename_operations.empty?
|
1945
|
+
@pB.say("No valid renames to perform".fg(196))
|
1946
|
+
return
|
1947
|
+
end
|
1948
|
+
|
1949
|
+
# Confirm and execute
|
1950
|
+
@pB.say("Apply #{rename_operations.length} renames? (y/N): ".fg(226))
|
1951
|
+
|
1952
|
+
if getchr.downcase == 'y'
|
1953
|
+
successful_renames = []
|
1954
|
+
failed_renames = []
|
1955
|
+
|
1956
|
+
rename_operations.each do |op|
|
1957
|
+
begin
|
1958
|
+
FileUtils.mv(op[:old_path], op[:new_path])
|
1959
|
+
successful_renames << op
|
1960
|
+
rescue StandardError => e
|
1961
|
+
failed_renames << { operation: op, error: e.message }
|
1962
|
+
end
|
1963
|
+
end
|
1964
|
+
|
1965
|
+
# Record undo information for successful renames
|
1966
|
+
unless successful_renames.empty?
|
1967
|
+
add_undo_operation({
|
1968
|
+
type: 'bulk_rename',
|
1969
|
+
renames: successful_renames,
|
1970
|
+
timestamp: Time.now
|
1971
|
+
})
|
1972
|
+
end
|
1973
|
+
|
1974
|
+
# Show results
|
1975
|
+
result_msg = "Renamed #{successful_renames.length} files"
|
1976
|
+
result_msg += ", #{failed_renames.length} failed" unless failed_renames.empty?
|
1977
|
+
@pB.say(result_msg.fg(successful_renames.empty? ? 196 : 156))
|
1978
|
+
|
1979
|
+
# Update file listing and clear tags
|
1980
|
+
@tagged.clear
|
1981
|
+
dirlist
|
1982
|
+
render
|
1983
|
+
else
|
1984
|
+
@pB.say("Bulk rename cancelled".fg(240))
|
1985
|
+
end
|
1986
|
+
|
1987
|
+
@pR.update = true
|
1988
|
+
end
|
1989
|
+
|
1990
|
+
def apply_rename_pattern(old_name, pattern) # {{{3
|
1991
|
+
case pattern
|
1992
|
+
when /^s\/(.+?)\/(.+?)\/([gimx]*)$/
|
1993
|
+
# Regex substitution: s/old/new/flags
|
1994
|
+
regex_pattern, replacement, flags = $1, $2, $3
|
1995
|
+
options = 0
|
1996
|
+
options |= Regexp::IGNORECASE if flags.include?('i')
|
1997
|
+
options |= Regexp::MULTILINE if flags.include?('m')
|
1998
|
+
options |= Regexp::EXTENDED if flags.include?('x')
|
1999
|
+
|
2000
|
+
begin
|
2001
|
+
regex = Regexp.new(regex_pattern, options)
|
2002
|
+
if flags.include?('g')
|
2003
|
+
old_name.gsub(regex, replacement)
|
2004
|
+
else
|
2005
|
+
old_name.sub(regex, replacement)
|
2006
|
+
end
|
2007
|
+
rescue StandardError
|
2008
|
+
nil # Invalid regex
|
2009
|
+
end
|
2010
|
+
|
2011
|
+
when /^\*(.+)$/
|
2012
|
+
# Append suffix: *_backup
|
2013
|
+
suffix = $1
|
2014
|
+
base = File.basename(old_name, '.*')
|
2015
|
+
ext = File.extname(old_name)
|
2016
|
+
"#{base}#{suffix}#{ext}"
|
2017
|
+
|
2018
|
+
when /^(.+)\*$/
|
2019
|
+
# Prepend prefix: backup_*
|
2020
|
+
prefix = $1
|
2021
|
+
"#{prefix}#{old_name}"
|
2022
|
+
|
2023
|
+
when /^(.+)\*(.+)$/
|
2024
|
+
# Prefix and suffix: backup_*_old
|
2025
|
+
prefix, suffix = $1, $2
|
2026
|
+
base = File.basename(old_name, '.*')
|
2027
|
+
ext = File.extname(old_name)
|
2028
|
+
"#{prefix}#{base}#{suffix}#{ext}"
|
2029
|
+
|
2030
|
+
when /^upper$/i
|
2031
|
+
# Convert to uppercase
|
2032
|
+
old_name.upcase
|
2033
|
+
|
2034
|
+
when /^lower$/i
|
2035
|
+
# Convert to lowercase
|
2036
|
+
old_name.downcase
|
2037
|
+
|
2038
|
+
when /^title$/i
|
2039
|
+
# Convert to title case
|
2040
|
+
old_name.split(/[-_\s]/).map(&:capitalize).join('_')
|
2041
|
+
|
2042
|
+
when /^\*\.(.+)$/
|
2043
|
+
# Change extension: *.txt
|
2044
|
+
new_ext = $1
|
2045
|
+
base = File.basename(old_name, '.*')
|
2046
|
+
"#{base}.#{new_ext}"
|
2047
|
+
|
2048
|
+
when /^(\d+)-(\d+)$/
|
2049
|
+
# Sequential numbering: 1-100 (start-end)
|
2050
|
+
# This will be handled by the caller with an index
|
2051
|
+
nil
|
2052
|
+
|
2053
|
+
else
|
2054
|
+
# Direct replacement
|
2055
|
+
pattern
|
2056
|
+
end
|
2057
|
+
end
|
2058
|
+
|
2059
|
+
def build_pattern_help # {{{3
|
2060
|
+
<<~HELP
|
2061
|
+
Bulk Rename Patterns:
|
2062
|
+
|
2063
|
+
Regex substitution:
|
2064
|
+
s/old/new/ - Replace first occurrence
|
2065
|
+
s/old/new/g - Replace all occurrences
|
2066
|
+
s/old/new/i - Case insensitive
|
2067
|
+
|
2068
|
+
Template patterns:
|
2069
|
+
prefix_* - Add prefix: "backup_filename.txt"
|
2070
|
+
*_suffix - Add suffix: "filename_backup.txt"
|
2071
|
+
prefix_*_suffix - Both: "backup_filename_old.txt"
|
2072
|
+
|
2073
|
+
Extension change:
|
2074
|
+
*.txt - Change all extensions to .txt
|
2075
|
+
|
2076
|
+
Case conversion:
|
2077
|
+
upper - Convert to UPPERCASE
|
2078
|
+
lower - convert to lowercase
|
2079
|
+
title - Convert To Title_Case
|
2080
|
+
|
2081
|
+
Examples:
|
2082
|
+
s/IMG/Photo/g - Replace "IMG" with "Photo"
|
2083
|
+
backup_* - Add "backup_" prefix
|
2084
|
+
*_old - Add "_old" suffix
|
2085
|
+
*.backup - Change extension to .backup
|
2086
|
+
lower - Convert to lowercase
|
2087
|
+
HELP
|
2088
|
+
end
|
2089
|
+
|
2090
|
+
def show_rename_preview(operations, errors, pattern) # {{{3
|
2091
|
+
text = "Bulk Rename Preview: #{pattern}\n".b.fg(156)
|
2092
|
+
text << "=" * 50 + "\n\n"
|
2093
|
+
|
2094
|
+
unless operations.empty?
|
2095
|
+
text << "Successful renames (#{operations.length}):\n".fg(156)
|
2096
|
+
operations.first(10).each do |op|
|
2097
|
+
text << " #{op[:old_name].fg(240)} -> #{op[:new_name].fg(156)}\n"
|
2098
|
+
end
|
2099
|
+
|
2100
|
+
if operations.length > 10
|
2101
|
+
text << " ... and #{operations.length - 10} more\n".fg(240)
|
2102
|
+
end
|
2103
|
+
text << "\n"
|
2104
|
+
end
|
2105
|
+
|
2106
|
+
unless errors.empty?
|
2107
|
+
text << "Errors (#{errors.length}):\n".fg(196)
|
2108
|
+
errors.first(5).each do |error|
|
2109
|
+
text << " #{error}\n".fg(196)
|
2110
|
+
end
|
2111
|
+
|
2112
|
+
if errors.length > 5
|
2113
|
+
text << " ... and #{errors.length - 5} more errors\n".fg(240)
|
2114
|
+
end
|
2115
|
+
text << "\n"
|
2116
|
+
end
|
2117
|
+
|
2118
|
+
if operations.empty? && errors.empty?
|
2119
|
+
text << "No changes would be made with this pattern.\n".fg(240)
|
2120
|
+
end
|
2121
|
+
|
2122
|
+
@pR.say(text)
|
2123
|
+
end
|
2124
|
+
|
2125
|
+
# FILE COMPARISON {{{2
|
2126
|
+
def compare_files # {{{3
|
2127
|
+
if @tagged.length == 0
|
2128
|
+
@pB.say("No files tagged for comparison. Tag 2 files with 't'".fg(196))
|
2129
|
+
return
|
2130
|
+
elsif @tagged.length == 1
|
2131
|
+
@pB.say("Only 1 file tagged. Tag a second file to compare with #{File.basename(@tagged[0])}".fg(196))
|
2132
|
+
return
|
2133
|
+
elsif @tagged.length > 2
|
2134
|
+
@pB.say("Too many files tagged (#{@tagged.length}). Tag exactly 2 files for comparison.".fg(196))
|
2135
|
+
return
|
2136
|
+
end
|
2137
|
+
|
2138
|
+
file1, file2 = @tagged
|
2139
|
+
|
2140
|
+
# Validate files exist
|
2141
|
+
unless File.exist?(file1) && File.exist?(file2)
|
2142
|
+
@pB.say("One or both tagged files no longer exist".fg(196))
|
2143
|
+
return
|
2144
|
+
end
|
2145
|
+
|
2146
|
+
# Show comparison
|
2147
|
+
show_file_comparison(file1, file2)
|
2148
|
+
end
|
2149
|
+
|
2150
|
+
def show_file_comparison(file1, file2) # {{{3
|
2151
|
+
basename1 = File.basename(file1)
|
2152
|
+
basename2 = File.basename(file2)
|
2153
|
+
|
2154
|
+
text = "File Comparison\n".b.fg(156)
|
2155
|
+
text << "=" * 50 + "\n\n"
|
2156
|
+
text << sprintf("%-25s vs %s\n", basename1.fg(156), basename2.fg(156))
|
2157
|
+
text << "=" * 50 + "\n\n"
|
2158
|
+
|
2159
|
+
# Basic file info comparison
|
2160
|
+
stat1 = File.stat(file1)
|
2161
|
+
stat2 = File.stat(file2)
|
2162
|
+
|
2163
|
+
text << "File Information:\n".fg(226)
|
2164
|
+
text << sprintf(" %-20s %-25s %s\n", "Size:", format_size_simple(stat1.size), format_size_simple(stat2.size))
|
2165
|
+
text << sprintf(" %-20s %-25s %s\n", "Modified:", stat1.mtime.strftime("%Y-%m-%d %H:%M"), stat2.mtime.strftime("%Y-%m-%d %H:%M"))
|
2166
|
+
text << sprintf(" %-20s %-25s %s\n", "Type:", File.ftype(file1), File.ftype(file2))
|
2167
|
+
|
2168
|
+
# Check if files are identical
|
2169
|
+
if stat1.size == stat2.size && files_identical?(file1, file2)
|
2170
|
+
text << "\n"
|
2171
|
+
text << "Files are identical! ✓".fg(156).b
|
2172
|
+
@pR.say(text)
|
2173
|
+
@pR.update = false
|
2174
|
+
getchr
|
2175
|
+
@pR.update = true
|
2176
|
+
return
|
2177
|
+
end
|
2178
|
+
|
2179
|
+
text << "\n"
|
2180
|
+
|
2181
|
+
# Determine comparison type
|
2182
|
+
if binary_file?(file1) || binary_file?(file2)
|
2183
|
+
text << show_binary_comparison(file1, file2, stat1, stat2)
|
2184
|
+
else
|
2185
|
+
text << show_text_comparison(file1, file2)
|
2186
|
+
end
|
2187
|
+
|
2188
|
+
text << "\n"
|
2189
|
+
text << "Press any key to close...".fg(240)
|
2190
|
+
|
2191
|
+
@pR.say(text)
|
2192
|
+
@pR.update = false
|
2193
|
+
getchr
|
2194
|
+
@pR.update = true
|
2195
|
+
end
|
2196
|
+
|
2197
|
+
def show_binary_comparison(file1, file2, stat1, stat2) # {{{3
|
2198
|
+
text = "Binary File Comparison:\n".fg(226)
|
2199
|
+
|
2200
|
+
# Size comparison
|
2201
|
+
size_diff = stat2.size - stat1.size
|
2202
|
+
if size_diff == 0
|
2203
|
+
text << " Same size: #{format_size_simple(stat1.size)}\n".fg(156)
|
2204
|
+
else
|
2205
|
+
sign = size_diff > 0 ? "+" : ""
|
2206
|
+
text << " Size difference: #{sign}#{format_size_simple(size_diff.abs)}\n".fg(size_diff > 0 ? 196 : 156)
|
2207
|
+
end
|
2208
|
+
|
2209
|
+
# Checksum comparison
|
2210
|
+
text << "\n Computing checksums...\n".fg(240)
|
2211
|
+
|
2212
|
+
begin
|
2213
|
+
require 'digest'
|
2214
|
+
hash1 = Digest::SHA256.file(file1).hexdigest
|
2215
|
+
hash2 = Digest::SHA256.file(file2).hexdigest
|
2216
|
+
|
2217
|
+
if hash1 == hash2
|
2218
|
+
text << " SHA256: Identical ✓\n".fg(156)
|
2219
|
+
else
|
2220
|
+
text << " SHA256: Different ✗\n".fg(196)
|
2221
|
+
text << " File 1: #{hash1[0, 16]}...\n".fg(240)
|
2222
|
+
text << " File 2: #{hash2[0, 16]}...\n".fg(240)
|
2223
|
+
end
|
2224
|
+
rescue StandardError => e
|
2225
|
+
text << " Checksum: Error - #{e.message}\n".fg(196)
|
2226
|
+
end
|
2227
|
+
|
2228
|
+
# File type analysis
|
2229
|
+
begin
|
2230
|
+
type1 = `file #{Shellwords.escape(file1)} 2>/dev/null`.strip
|
2231
|
+
type2 = `file #{Shellwords.escape(file2)} 2>/dev/null`.strip
|
2232
|
+
|
2233
|
+
text << "\n File types:\n".fg(226)
|
2234
|
+
text << " #{File.basename(file1)}: #{type1.split(':')[1]&.strip || 'Unknown'}\n".fg(240)
|
2235
|
+
text << " #{File.basename(file2)}: #{type2.split(':')[1]&.strip || 'Unknown'}\n".fg(240)
|
2236
|
+
rescue
|
2237
|
+
# Ignore file type detection errors
|
2238
|
+
end
|
2239
|
+
|
2240
|
+
text
|
2241
|
+
end
|
2242
|
+
|
2243
|
+
def show_text_comparison(file1, file2) # {{{3
|
2244
|
+
text = "Text File Comparison:\n".fg(226)
|
2245
|
+
|
2246
|
+
begin
|
2247
|
+
lines1 = File.readlines(file1, chomp: true)
|
2248
|
+
lines2 = File.readlines(file2, chomp: true)
|
2249
|
+
rescue StandardError => e
|
2250
|
+
return "Error reading files: #{e.message}\n".fg(196)
|
2251
|
+
end
|
2252
|
+
|
2253
|
+
# Line count comparison
|
2254
|
+
if lines1.length == lines2.length
|
2255
|
+
text << " Same line count: #{lines1.length}\n".fg(156)
|
2256
|
+
else
|
2257
|
+
diff = lines2.length - lines1.length
|
2258
|
+
sign = diff > 0 ? "+" : ""
|
2259
|
+
text << " Line count: #{lines1.length} vs #{lines2.length} (#{sign}#{diff})\n".fg(diff > 0 ? 196 : 156)
|
2260
|
+
end
|
2261
|
+
|
2262
|
+
# Generate and show diff
|
2263
|
+
diff_lines = generate_unified_diff(lines1, lines2, File.basename(file1), File.basename(file2))
|
2264
|
+
|
2265
|
+
if diff_lines.empty?
|
2266
|
+
text << " Content: Identical ✓\n".fg(156)
|
2267
|
+
else
|
2268
|
+
text << "\n Differences (unified diff):\n".fg(226)
|
2269
|
+
|
2270
|
+
# Show first 15 lines of diff
|
2271
|
+
diff_lines.first(15).each do |line|
|
2272
|
+
color = case line[0]
|
2273
|
+
when '+' then 156 # Green for additions
|
2274
|
+
when '-' then 196 # Red for deletions
|
2275
|
+
when '@' then 226 # Yellow for headers
|
2276
|
+
else 240 # Gray for context
|
2277
|
+
end
|
2278
|
+
text << " #{line}\n".fg(color)
|
2279
|
+
end
|
2280
|
+
|
2281
|
+
if diff_lines.length > 15
|
2282
|
+
text << " ... and #{diff_lines.length - 15} more lines\n".fg(240)
|
2283
|
+
end
|
2284
|
+
end
|
2285
|
+
|
2286
|
+
text
|
2287
|
+
end
|
2288
|
+
|
2289
|
+
def generate_unified_diff(lines1, lines2, name1, name2) # {{{3
|
2290
|
+
# Simple unified diff implementation
|
2291
|
+
diff_lines = []
|
2292
|
+
|
2293
|
+
# Find differences using basic LCS-like approach
|
2294
|
+
i1 = i2 = 0
|
2295
|
+
context_size = 3
|
2296
|
+
|
2297
|
+
while i1 < lines1.length || i2 < lines2.length
|
2298
|
+
if i1 < lines1.length && i2 < lines2.length && lines1[i1] == lines2[i2]
|
2299
|
+
# Lines match, move forward
|
2300
|
+
i1 += 1
|
2301
|
+
i2 += 1
|
2302
|
+
else
|
2303
|
+
# Found a difference, create a hunk
|
2304
|
+
hunk_start1, hunk_start2 = i1, i2
|
2305
|
+
|
2306
|
+
# Find the end of differences
|
2307
|
+
temp_i1, temp_i2 = i1, i2
|
2308
|
+
while temp_i1 < lines1.length || temp_i2 < lines2.length
|
2309
|
+
if temp_i1 < lines1.length && temp_i2 < lines2.length && lines1[temp_i1] == lines2[temp_i2]
|
2310
|
+
break
|
2311
|
+
end
|
2312
|
+
temp_i1 += 1 if temp_i1 < lines1.length
|
2313
|
+
temp_i2 += 1 if temp_i2 < lines2.length
|
2314
|
+
end
|
2315
|
+
|
2316
|
+
# Add hunk header
|
2317
|
+
diff_lines << "@@ -#{hunk_start1 + 1},#{temp_i1 - hunk_start1} +#{hunk_start2 + 1},#{temp_i2 - hunk_start2} @@"
|
2318
|
+
|
2319
|
+
# Add removed lines
|
2320
|
+
(hunk_start1...temp_i1).each do |idx|
|
2321
|
+
diff_lines << "-#{lines1[idx]}" if idx < lines1.length
|
2322
|
+
end
|
2323
|
+
|
2324
|
+
# Add added lines
|
2325
|
+
(hunk_start2...temp_i2).each do |idx|
|
2326
|
+
diff_lines << "+#{lines2[idx]}" if idx < lines2.length
|
2327
|
+
end
|
2328
|
+
|
2329
|
+
i1, i2 = temp_i1, temp_i2
|
2330
|
+
|
2331
|
+
break if diff_lines.length > 50 # Prevent huge diffs
|
2332
|
+
end
|
2333
|
+
end
|
2334
|
+
|
2335
|
+
diff_lines
|
2336
|
+
end
|
2337
|
+
|
2338
|
+
def files_identical?(file1, file2) # {{{3
|
2339
|
+
# Quick check for identical files
|
2340
|
+
return false unless File.size(file1) == File.size(file2)
|
2341
|
+
|
2342
|
+
File.open(file1, 'rb') do |f1|
|
2343
|
+
File.open(file2, 'rb') do |f2|
|
2344
|
+
while (chunk1 = f1.read(8192))
|
2345
|
+
chunk2 = f2.read(8192)
|
2346
|
+
return false if chunk1 != chunk2
|
2347
|
+
end
|
2348
|
+
end
|
2349
|
+
end
|
2350
|
+
|
2351
|
+
true
|
2352
|
+
rescue
|
2353
|
+
false
|
2354
|
+
end
|
2355
|
+
|
2356
|
+
def binary_file?(file) # {{{3
|
2357
|
+
# Check if file appears to be binary
|
2358
|
+
File.open(file, 'rb') do |f|
|
2359
|
+
chunk = f.read(1024)
|
2360
|
+
return false if chunk.nil? || chunk.empty?
|
2361
|
+
|
2362
|
+
# Consider file binary if it contains null bytes or too many non-printable chars
|
2363
|
+
null_count = chunk.count("\x00")
|
2364
|
+
non_printable = chunk.bytes.count { |b| b < 32 && ![9, 10, 13].include?(b) }
|
2365
|
+
|
2366
|
+
null_count > 0 || (non_printable.to_f / chunk.length) > 0.3
|
2367
|
+
end
|
2368
|
+
rescue
|
2369
|
+
true # Assume binary if we can't read it
|
2370
|
+
end
|
2371
|
+
|
2372
|
+
def format_size_simple(bytes) # {{{3
|
2373
|
+
units = ['B', 'KB', 'MB', 'GB']
|
2374
|
+
size = bytes.to_f
|
2375
|
+
unit_index = 0
|
2376
|
+
|
2377
|
+
while size >= 1024 && unit_index < units.length - 1
|
2378
|
+
size /= 1024.0
|
2379
|
+
unit_index += 1
|
2380
|
+
end
|
2381
|
+
|
2382
|
+
if unit_index == 0
|
2383
|
+
"#{size.to_i} #{units[unit_index]}"
|
2384
|
+
else
|
2385
|
+
"#{sprintf('%.1f', size)} #{units[unit_index]}"
|
2386
|
+
end
|
2387
|
+
end
|
2388
|
+
|
2389
|
+
# REMOTE BROWSING {{{2
|
2390
|
+
def browse_remote # {{{3
|
2391
|
+
if @remote_mode
|
2392
|
+
# Exit remote mode
|
2393
|
+
exit_remote_mode
|
2394
|
+
else
|
2395
|
+
# Enter remote mode
|
2396
|
+
@pB.say("Remote connection: ".fg(156))
|
2397
|
+
@pR.say(build_remote_help)
|
2398
|
+
@pR.update = false
|
2399
|
+
|
2400
|
+
connection_string = @pSsh.ask('SSH connect to: ', '')
|
2401
|
+
|
2402
|
+
# Check if user cancelled (ESC or empty input)
|
2403
|
+
if connection_string.nil? || connection_string.strip.empty?
|
2404
|
+
@pB.clear; @pB.update = true
|
2405
|
+
@pR.update = true
|
2406
|
+
return
|
2407
|
+
end
|
2408
|
+
|
2409
|
+
# Parse connection string
|
2410
|
+
remote_info = parse_remote_connection(connection_string)
|
2411
|
+
unless remote_info
|
2412
|
+
@pB.clear; @pB.update = true
|
2413
|
+
@pR.update = true
|
2414
|
+
return
|
2415
|
+
end
|
2416
|
+
|
2417
|
+
# Test connection and enter remote mode
|
2418
|
+
begin
|
2419
|
+
connect_remote(remote_info)
|
2420
|
+
enter_remote_mode(remote_info[:path])
|
2421
|
+
rescue StandardError => e
|
2422
|
+
@pB.say("Connection failed: #{e.message}".fg(196))
|
2423
|
+
@pR.update = true
|
2424
|
+
end
|
2425
|
+
end
|
2426
|
+
end
|
2427
|
+
|
2428
|
+
def enter_remote_mode(path = '~') # {{{3
|
2429
|
+
@remote_mode = true
|
2430
|
+
@remote_path = path
|
2431
|
+
@index = 0 # Reset selection
|
2432
|
+
@remote_files_cache = [] # Clear cache when entering remote mode
|
2433
|
+
@pB.say("Remote mode: #{@current_remote[:user]}@#{@current_remote[:host]}:#{@remote_path} (Ctrl+E to exit)".fg(156))
|
2434
|
+
@pR.update = true
|
2435
|
+
dirlist
|
2436
|
+
render
|
2437
|
+
end
|
2438
|
+
|
2439
|
+
def exit_remote_mode # {{{3
|
2440
|
+
@remote_mode = false
|
2441
|
+
@current_remote = nil
|
2442
|
+
@remote_path = '~'
|
2443
|
+
@remote_files_cache = [] # Clear cache when exiting remote mode
|
2444
|
+
@index = 0 # Reset selection
|
2445
|
+
@pB.say("Returned to local browsing".fg(156))
|
2446
|
+
dirlist
|
2447
|
+
render
|
2448
|
+
end
|
2449
|
+
|
2450
|
+
def show_remote_file_info(file) # {{{3
|
2451
|
+
info_text = ""
|
2452
|
+
info_text << "Remote File Information\n".b.fg(156)
|
2453
|
+
info_text << "=" * 40 + "\n\n"
|
2454
|
+
|
2455
|
+
info_text << "Name: #{file[:name]}\n".fg(255)
|
2456
|
+
info_text << "Type: #{file[:type].capitalize}\n".fg(255)
|
2457
|
+
info_text << "Size: #{format_size_simple(file[:size])}\n".fg(255)
|
2458
|
+
info_text << "Permissions: #{file[:permissions]}\n".fg(255)
|
2459
|
+
info_text << "Owner: #{file[:owner]}\n".fg(255)
|
2460
|
+
info_text << "Group: #{file[:group]}\n".fg(255)
|
2461
|
+
info_text << "Modified: #{file[:modified]}\n".fg(255)
|
2462
|
+
info_text << "Remote Path: #{@remote_path}/#{file[:name]}\n".fg(240)
|
2463
|
+
|
2464
|
+
info_text << "\n"
|
2465
|
+
info_text << "Actions:\n".fg(226)
|
2466
|
+
if file[:type] == 'directory'
|
2467
|
+
info_text << " Enter = Navigate into directory\n".fg(240)
|
2468
|
+
info_text << " ← = Go to parent directory\n".fg(240)
|
2469
|
+
else
|
2470
|
+
info_text << " d = Download file\n".fg(240)
|
2471
|
+
info_text << " Enter = Show this information\n".fg(240)
|
2472
|
+
end
|
2473
|
+
info_text << " s = Open SSH shell in current directory\n".fg(240)
|
2474
|
+
info_text << " u = Upload file to current directory\n".fg(240)
|
2475
|
+
info_text << " Ctrl+E = Exit remote mode\n".fg(240)
|
2476
|
+
|
2477
|
+
@pR.say(info_text)
|
2478
|
+
@pR.update = true
|
2479
|
+
end
|
2480
|
+
|
2481
|
+
def remote_download_selected # {{{3
|
2482
|
+
return unless @remote_mode && @files && @files[@index] && @remote_files_cache[@index]
|
2483
|
+
|
2484
|
+
selected_file = @remote_files_cache[@index]
|
2485
|
+
|
2486
|
+
if selected_file[:type] == 'directory'
|
2487
|
+
@pB.say("Cannot download directories directly".fg(196))
|
2488
|
+
return
|
2489
|
+
end
|
2490
|
+
|
2491
|
+
# Show download prompt in right pane to avoid interfering with top pane
|
2492
|
+
default_dest = File.join(Dir.pwd, selected_file[:name])
|
2493
|
+
prompt_text = "Download: #{selected_file[:name]}\n\n"
|
2494
|
+
prompt_text << "Default destination:\n#{default_dest}\n\n"
|
2495
|
+
prompt_text << "Press Enter to use default,\ntype new path, or clear field to cancel:"
|
2496
|
+
|
2497
|
+
@pR.say(prompt_text.fg(156))
|
2498
|
+
@pR.update = true
|
2499
|
+
|
2500
|
+
# Ask for local destination with clearer instructions
|
2501
|
+
destination = @pSsh.ask('Download to (Enter=default, clear field to cancel): ', default_dest)
|
2502
|
+
|
2503
|
+
# Check if user cancelled by clearing the field
|
2504
|
+
if destination.nil? || destination.strip.empty?
|
2505
|
+
@pB.say("Download cancelled".fg(240))
|
2506
|
+
# Restore file info display
|
2507
|
+
show_remote_file_info(selected_file)
|
2508
|
+
return
|
2509
|
+
end
|
2510
|
+
|
2511
|
+
# Download the file
|
2512
|
+
remote_file = File.join(@remote_path, selected_file[:name])
|
2513
|
+
scp_cmd = build_scp_command(@current_remote, destination, remote_file, :download)
|
2514
|
+
|
2515
|
+
@pB.say("Downloading #{selected_file[:name]}...".fg(156))
|
2516
|
+
|
2517
|
+
begin
|
2518
|
+
result = system(scp_cmd)
|
2519
|
+
if result
|
2520
|
+
@pB.say("Downloaded: #{selected_file[:name]} -> #{destination}".fg(156))
|
2521
|
+
# Refresh local directory if we downloaded to current directory
|
2522
|
+
if File.dirname(destination) == Dir.pwd
|
2523
|
+
dirlist(left: false) # Refresh right pane if needed
|
2524
|
+
end
|
2525
|
+
# Show success and restore file info
|
2526
|
+
show_remote_file_info(selected_file)
|
2527
|
+
else
|
2528
|
+
@pB.say("Download failed".fg(196))
|
2529
|
+
show_remote_file_info(selected_file)
|
2530
|
+
end
|
2531
|
+
rescue StandardError => e
|
2532
|
+
@pB.say("Download error: #{e.message}".fg(196))
|
2533
|
+
show_remote_file_info(selected_file)
|
2534
|
+
end
|
2535
|
+
end
|
2536
|
+
|
2537
|
+
def open_remote_shell # {{{3
|
2538
|
+
return unless @remote_mode && @current_remote
|
2539
|
+
|
2540
|
+
@pB.say("Launching SSH shell...".fg(156))
|
2541
|
+
|
2542
|
+
begin
|
2543
|
+
# Build SSH command with proper directory navigation
|
2544
|
+
ssh_opts = "-t"
|
2545
|
+
ssh_opts += " -i #{Shellwords.escape(@current_remote[:ssh_key])}" if @current_remote[:ssh_key]
|
2546
|
+
|
2547
|
+
ssh_target = "#{@current_remote[:user]}@#{@current_remote[:host]}"
|
2548
|
+
|
2549
|
+
# Create SSH command that changes to the remote directory
|
2550
|
+
ssh_cmd = "ssh #{ssh_opts} #{ssh_target} -t 'cd #{Shellwords.escape(@remote_path)} 2>/dev/null || cd ~; exec bash -l'"
|
2551
|
+
|
2552
|
+
# Use RTFM's interactive program pattern
|
2553
|
+
system("stty #{ORIG_STTY} < /dev/tty")
|
2554
|
+
system('clear < /dev/tty > /dev/tty')
|
2555
|
+
Cursor.show
|
2556
|
+
|
2557
|
+
# Show connection info
|
2558
|
+
puts "Connecting to #{ssh_target}..."
|
2559
|
+
puts "Starting in directory: #{@remote_path}"
|
2560
|
+
puts "Type 'exit' to return to RTFM"
|
2561
|
+
puts "=" * 50
|
2562
|
+
puts
|
2563
|
+
|
2564
|
+
# Launch SSH on real TTY using Process.spawn like RTFM does
|
2565
|
+
pid = Process.spawn(ssh_cmd,
|
2566
|
+
in: '/dev/tty',
|
2567
|
+
out: '/dev/tty',
|
2568
|
+
err: '/dev/tty')
|
2569
|
+
begin
|
2570
|
+
Process.wait(pid)
|
2571
|
+
rescue Interrupt
|
2572
|
+
Process.kill('TERM', pid) rescue nil
|
2573
|
+
retry
|
2574
|
+
end
|
2575
|
+
|
2576
|
+
# Restore RTFM's terminal state
|
2577
|
+
system('stty raw -echo isig < /dev/tty')
|
2578
|
+
$stdin.raw!
|
2579
|
+
$stdin.echo = false
|
2580
|
+
Rcurses.init! # Reinitialize rcurses to fix input handling
|
2581
|
+
Cursor.hide
|
2582
|
+
Rcurses.clear_screen
|
2583
|
+
|
2584
|
+
# Refresh RTFM interface
|
2585
|
+
@pL.update = true
|
2586
|
+
@pR.update = true
|
2587
|
+
@pB.say("Returned from SSH shell session".fg(118))
|
2588
|
+
dirlist
|
2589
|
+
refresh
|
2590
|
+
render
|
2591
|
+
|
2592
|
+
rescue StandardError => e
|
2593
|
+
# Error handling with proper terminal restoration
|
2594
|
+
system('stty raw -echo isig < /dev/tty') rescue nil
|
2595
|
+
$stdin.raw! rescue nil
|
2596
|
+
$stdin.echo = false rescue nil
|
2597
|
+
Cursor.hide rescue nil
|
2598
|
+
Rcurses.clear_screen rescue nil
|
2599
|
+
|
2600
|
+
@pL.update = true
|
2601
|
+
@pR.update = true
|
2602
|
+
@pB.say("SSH shell failed: #{e.message}".fg(196))
|
2603
|
+
dirlist
|
2604
|
+
refresh
|
2605
|
+
render
|
2606
|
+
end
|
2607
|
+
end
|
2608
|
+
|
2609
|
+
def remote_upload_file # {{{3
|
2610
|
+
return unless @remote_mode
|
2611
|
+
|
2612
|
+
# Show upload prompt in right pane
|
2613
|
+
upload_text = "Upload File to Remote Directory\n\n".fg(156)
|
2614
|
+
upload_text << "Current remote path: #{@remote_path}\n\n".fg(240)
|
2615
|
+
upload_text << "Enter local file path to upload\n(or ESC to cancel):"
|
2616
|
+
|
2617
|
+
@pR.say(upload_text)
|
2618
|
+
@pR.update = true
|
2619
|
+
|
2620
|
+
# Ask for local file to upload
|
2621
|
+
local_file = @pSsh.ask('Local file (clear to cancel): ', '')
|
2622
|
+
|
2623
|
+
# Check if user cancelled
|
2624
|
+
if local_file.nil? || local_file.strip.empty?
|
2625
|
+
@pB.say("Upload cancelled".fg(240))
|
2626
|
+
@pR.update = true # Restore normal right pane
|
2627
|
+
return
|
2628
|
+
end
|
2629
|
+
|
2630
|
+
unless File.exist?(local_file)
|
2631
|
+
@pB.say("File not found: #{local_file}".fg(196))
|
2632
|
+
@pR.update = true
|
2633
|
+
return
|
2634
|
+
end
|
2635
|
+
|
2636
|
+
if File.directory?(local_file)
|
2637
|
+
@pB.say("Cannot upload directories directly".fg(196))
|
2638
|
+
@pR.update = true
|
2639
|
+
return
|
2640
|
+
end
|
2641
|
+
|
2642
|
+
# Ask for remote destination name (default to same name)
|
2643
|
+
default_name = File.basename(local_file)
|
2644
|
+
remote_name = @pSsh.ask('Remote filename (Enter=default, clear to cancel): ', default_name)
|
2645
|
+
|
2646
|
+
# Check if user cancelled
|
2647
|
+
if remote_name.nil? || remote_name.strip.empty?
|
2648
|
+
@pB.say("Upload cancelled".fg(240))
|
2649
|
+
@pR.update = true
|
2650
|
+
return
|
2651
|
+
end
|
2652
|
+
|
2653
|
+
# Upload the file
|
2654
|
+
remote_destination = File.join(@remote_path, remote_name)
|
2655
|
+
scp_cmd = build_scp_command(@current_remote, local_file, remote_destination, :upload)
|
2656
|
+
|
2657
|
+
@pB.say("Uploading #{File.basename(local_file)}...".fg(156))
|
2658
|
+
|
2659
|
+
begin
|
2660
|
+
result = system(scp_cmd)
|
2661
|
+
if result
|
2662
|
+
@pB.say("Uploaded: #{local_file} -> #{remote_name}".fg(156))
|
2663
|
+
# Clear cache and refresh remote directory
|
2664
|
+
connection_id = "#{@current_remote[:user]}@#{@current_remote[:host]}"
|
2665
|
+
cache_key = "#{connection_id}:#{@remote_path}"
|
2666
|
+
@remote_cache.delete(cache_key)
|
2667
|
+
@remote_files_cache = [] # Clear file cache too
|
2668
|
+
@pL.update = true # Force refresh of left pane
|
2669
|
+
else
|
2670
|
+
@pB.say("Upload failed".fg(196))
|
2671
|
+
end
|
2672
|
+
rescue StandardError => e
|
2673
|
+
@pB.say("Upload error: #{e.message}".fg(196))
|
2674
|
+
end
|
2675
|
+
|
2676
|
+
@pR.update = true # Restore normal right pane
|
2677
|
+
end
|
2678
|
+
|
2679
|
+
def parse_remote_connection(connection_string) # {{{3
|
2680
|
+
# Support various formats:
|
2681
|
+
# ssh://user@host/path
|
2682
|
+
# user@host:/path
|
2683
|
+
# host:/path
|
2684
|
+
# user@host (defaults to home directory)
|
2685
|
+
# -i ~/.ssh/keyfile user@host:/path
|
2686
|
+
# user@host:/path -i ~/.ssh/keyfile
|
2687
|
+
# Comments are supported: user@host:/path # My server
|
2688
|
+
|
2689
|
+
# Strip comments (everything after #)
|
2690
|
+
connection_string = connection_string.split('#').first.strip
|
2691
|
+
|
2692
|
+
parts = connection_string.split(/\s+/)
|
2693
|
+
ssh_key = nil
|
2694
|
+
main_connection = nil
|
2695
|
+
|
2696
|
+
# Look for -i flag and extract key file
|
2697
|
+
if parts.include?('-i')
|
2698
|
+
key_index = parts.index('-i')
|
2699
|
+
if key_index && key_index + 1 < parts.length
|
2700
|
+
ssh_key = parts[key_index + 1]
|
2701
|
+
# Remove -i and keyfile from parts
|
2702
|
+
parts.delete_at(key_index + 1) # Remove keyfile
|
2703
|
+
parts.delete_at(key_index) # Remove -i flag
|
2704
|
+
end
|
2705
|
+
end
|
2706
|
+
|
2707
|
+
# Join remaining parts back (in case there were spaces in paths)
|
2708
|
+
main_connection = parts.join(' ')
|
2709
|
+
|
2710
|
+
# Parse the main connection string
|
2711
|
+
result = case main_connection
|
2712
|
+
when %r{^ssh://([^@]+@)?([^/]+)(/.*)?$}
|
2713
|
+
user_part, host, path = $1, $2, $3
|
2714
|
+
user = user_part ? user_part.chomp('@') : ENV['USER']
|
2715
|
+
path ||= '~'
|
2716
|
+
{ protocol: 'ssh', user: user, host: host, path: path }
|
2717
|
+
|
2718
|
+
when %r{^([^@]+@)?([^:]+):(.*)$}
|
2719
|
+
user_part, host, path = $1, $2, $3
|
2720
|
+
user = user_part ? user_part.chomp('@') : ENV['USER']
|
2721
|
+
{ protocol: 'ssh', user: user, host: host, path: path }
|
2722
|
+
|
2723
|
+
when %r{^([^@]+@)?([^:]+)$}
|
2724
|
+
user_part, host = $1, $2
|
2725
|
+
user = user_part ? user_part.chomp('@') : ENV['USER']
|
2726
|
+
{ protocol: 'ssh', user: user, host: host, path: '~' }
|
2727
|
+
|
2728
|
+
else
|
2729
|
+
@pB.say("Invalid connection format. Use: user@host:/path or -i ~/.ssh/key user@host:/path".fg(196))
|
2730
|
+
nil
|
2731
|
+
end
|
2732
|
+
|
2733
|
+
# Add SSH key to result if specified
|
2734
|
+
result[:ssh_key] = ssh_key if result && ssh_key
|
2735
|
+
result
|
2736
|
+
end
|
2737
|
+
|
2738
|
+
def build_ssh_command(remote_info, command = nil) # {{{3
|
2739
|
+
# Build SSH command with optional key file
|
2740
|
+
ssh_opts = "-o ConnectTimeout=10 -o BatchMode=yes"
|
2741
|
+
ssh_opts += " -i #{Shellwords.escape(remote_info[:ssh_key])}" if remote_info[:ssh_key]
|
2742
|
+
|
2743
|
+
ssh_cmd = "ssh #{ssh_opts} #{remote_info[:user]}@#{remote_info[:host]}"
|
2744
|
+
ssh_cmd += " '#{command}'" if command
|
2745
|
+
ssh_cmd
|
1272
2746
|
end
|
1273
2747
|
|
1274
|
-
def
|
1275
|
-
|
1276
|
-
|
2748
|
+
def build_scp_command(remote_info, local_path, remote_path, direction = :download) # {{{3
|
2749
|
+
# Build SCP command with optional key file
|
2750
|
+
scp_opts = ""
|
2751
|
+
scp_opts += " -i #{Shellwords.escape(remote_info[:ssh_key])}" if remote_info[:ssh_key]
|
2752
|
+
|
2753
|
+
ssh_target = "#{remote_info[:user]}@#{remote_info[:host]}"
|
2754
|
+
|
2755
|
+
# Handle tilde expansion for SCP
|
2756
|
+
scp_remote_path = if remote_path.start_with?('~')
|
2757
|
+
"#{ssh_target}:#{remote_path}"
|
2758
|
+
else
|
2759
|
+
"#{ssh_target}:#{Shellwords.escape(remote_path)}"
|
2760
|
+
end
|
2761
|
+
|
2762
|
+
if direction == :download
|
2763
|
+
"scp#{scp_opts} #{scp_remote_path} #{Shellwords.escape(local_path)}"
|
2764
|
+
else # upload
|
2765
|
+
"scp#{scp_opts} #{Shellwords.escape(local_path)} #{scp_remote_path}"
|
2766
|
+
end
|
1277
2767
|
end
|
1278
2768
|
|
1279
|
-
def
|
1280
|
-
|
1281
|
-
|
1282
|
-
|
1283
|
-
|
1284
|
-
|
1285
|
-
|
1286
|
-
|
1287
|
-
|
1288
|
-
|
2769
|
+
def connect_remote(remote_info) # {{{3
|
2770
|
+
@pB.say("Connecting to #{remote_info[:user]}@#{remote_info[:host]}...".fg(156))
|
2771
|
+
|
2772
|
+
# Test SSH connection
|
2773
|
+
ssh_cmd = build_ssh_command(remote_info, 'echo connected')
|
2774
|
+
result = `#{ssh_cmd} 2>&1`
|
2775
|
+
|
2776
|
+
unless $?.success?
|
2777
|
+
key_hint = remote_info[:ssh_key] ? " with key #{remote_info[:ssh_key]}" : ""
|
2778
|
+
raise "SSH connection failed#{key_hint}. Check your SSH keys or try: ssh #{remote_info[:user]}@#{remote_info[:host]}"
|
1289
2779
|
end
|
1290
|
-
|
2780
|
+
|
2781
|
+
# Store connection info
|
2782
|
+
@current_remote = remote_info
|
2783
|
+
connection_id = "#{remote_info[:user]}@#{remote_info[:host]}"
|
2784
|
+
@remote_connections[connection_id] = remote_info
|
2785
|
+
|
2786
|
+
@pB.say("Connected successfully!".fg(156))
|
1291
2787
|
end
|
1292
2788
|
|
1293
|
-
def toggle_invert # {{{3
|
1294
|
-
@lsinvert = @lsinvert.empty? ? '-r' : ''
|
1295
|
-
@pB.say(' Sorting inverted')
|
1296
|
-
@pR.update = true; @orderchange = true
|
1297
|
-
end
|
1298
2789
|
|
1299
|
-
def
|
1300
|
-
|
1301
|
-
|
2790
|
+
def list_remote_directory(remote_path) # {{{3
|
2791
|
+
# Handle tilde expansion - don't escape ~ as it needs shell expansion
|
2792
|
+
escaped_path = if remote_path.start_with?('~')
|
2793
|
+
remote_path # Don't escape paths starting with ~
|
2794
|
+
else
|
2795
|
+
Shellwords.escape(remote_path)
|
2796
|
+
end
|
2797
|
+
|
2798
|
+
# Use ls -la to get detailed file listing
|
2799
|
+
ls_cmd = build_ssh_command(@current_remote, "cd #{escaped_path} && ls -la 2>/dev/null")
|
2800
|
+
output = `#{ls_cmd} 2>&1`
|
2801
|
+
|
2802
|
+
unless $?.success?
|
2803
|
+
raise "Failed to list remote directory: #{remote_path}"
|
2804
|
+
end
|
2805
|
+
|
2806
|
+
# Parse ls output
|
2807
|
+
files = []
|
2808
|
+
output.lines.each do |line|
|
2809
|
+
next if line.match?(/^total \d+/) # Skip total line
|
2810
|
+
|
2811
|
+
parts = line.strip.split(/\s+/, 9)
|
2812
|
+
next if parts.length < 9
|
2813
|
+
|
2814
|
+
permissions, links, owner, group, size, month, day, time_or_year, name = parts
|
2815
|
+
next if name == '.' || name == '..'
|
2816
|
+
|
2817
|
+
# Determine file type
|
2818
|
+
type = case permissions[0]
|
2819
|
+
when 'd' then 'directory'
|
2820
|
+
when 'l' then 'symlink'
|
2821
|
+
when '-' then 'file'
|
2822
|
+
else 'other'
|
2823
|
+
end
|
2824
|
+
|
2825
|
+
files << {
|
2826
|
+
name: name,
|
2827
|
+
type: type,
|
2828
|
+
size: size.to_i,
|
2829
|
+
permissions: permissions,
|
2830
|
+
owner: owner,
|
2831
|
+
group: group,
|
2832
|
+
modified: "#{month} #{day} #{time_or_year}"
|
2833
|
+
}
|
2834
|
+
end
|
2835
|
+
|
2836
|
+
# Sort: directories first, then by name
|
2837
|
+
files.sort_by { |f| [f[:type] == 'directory' ? 0 : 1, f[:name].downcase] }
|
1302
2838
|
end
|
1303
2839
|
|
1304
|
-
|
1305
|
-
def
|
1306
|
-
|
1307
|
-
|
1308
|
-
|
1309
|
-
|
1310
|
-
|
1311
|
-
|
1312
|
-
|
1313
|
-
|
1314
|
-
|
1315
|
-
|
1316
|
-
# Tag/untag the item
|
1317
|
-
if @tagged.include?(item)
|
1318
|
-
@tagged.delete(item); @tagsize -= File.size(item) rescue 0
|
1319
|
-
else
|
1320
|
-
@tagged.push(item); @tagsize += File.size(item) rescue 0
|
1321
|
-
end
|
1322
|
-
|
1323
|
-
# Advance to next item in the active pane
|
1324
|
-
max_index = current_files.size - 1
|
1325
|
-
if @active_pane == :left
|
1326
|
-
@index_left = [@index_left + 1, max_index].min
|
1327
|
-
@selected_left = current_files[@index_left] if current_files[@index_left]
|
1328
|
-
@pLeft.update = true
|
2840
|
+
|
2841
|
+
def build_remote_help # {{{3
|
2842
|
+
help_text = "Remote Connection Setup\n".b.fg(156)
|
2843
|
+
help_text << "=" * 50 + "\n\n"
|
2844
|
+
|
2845
|
+
# Show recent SSH connections if any exist
|
2846
|
+
unless @pSsh.history.empty?
|
2847
|
+
help_text << "Recent Connections:\n".fg(226)
|
2848
|
+
@pSsh.history.reverse.first(5).each_with_index do |connection, i|
|
2849
|
+
if connection =~ /([^@]+@[^:\s]+)/
|
2850
|
+
host_part = $1
|
2851
|
+
help_text << sprintf(" %d. %s\n", i + 1, host_part.fg(156))
|
1329
2852
|
else
|
1330
|
-
|
1331
|
-
@selected_right = current_files[@index_right] if current_files[@index_right]
|
1332
|
-
@pRight.update = true
|
2853
|
+
help_text << sprintf(" %d. %s\n", i + 1, connection.fg(156))
|
1333
2854
|
end
|
1334
|
-
|
1335
|
-
# Update compatibility variables
|
1336
|
-
@index = @active_pane == :left ? @index_left : @index_right
|
1337
|
-
@selected = @active_pane == :left ? @selected_left : @selected_right
|
1338
|
-
|
1339
|
-
@pPreview.update = true if @pPreview
|
1340
|
-
end
|
1341
|
-
else
|
1342
|
-
# Original single-pane logic
|
1343
|
-
item = @selected
|
1344
|
-
if @tagged.include?(item)
|
1345
|
-
@tagged.delete(item); @tagsize -= File.size(item) rescue 0
|
1346
|
-
else
|
1347
|
-
@tagged.push(item); @tagsize += File.size(item) rescue 0
|
1348
2855
|
end
|
1349
|
-
|
1350
|
-
@pL.update = true
|
2856
|
+
help_text << "\nUse " + "↑/↓".fg(156) + " in prompt to recall connections\n\n".fg(249)
|
1351
2857
|
end
|
1352
2858
|
|
1353
|
-
|
1354
|
-
|
1355
|
-
|
1356
|
-
|
1357
|
-
|
1358
|
-
|
1359
|
-
|
1360
|
-
|
1361
|
-
|
1362
|
-
@
|
1363
|
-
|
1364
|
-
|
1365
|
-
|
1366
|
-
|
1367
|
-
@
|
1368
|
-
@
|
1369
|
-
|
1370
|
-
|
1371
|
-
|
1372
|
-
|
1373
|
-
@
|
1374
|
-
|
1375
|
-
|
1376
|
-
|
1377
|
-
|
1378
|
-
|
1379
|
-
|
2859
|
+
help_text << "Connection Examples:\n".fg(226)
|
2860
|
+
help_text << " Basic SSH/SFTP connections:\n".fg(249)
|
2861
|
+
help_text << " user@hostname:/path - Connect to specific path\n"
|
2862
|
+
help_text << " user@hostname - Connect to home directory\n"
|
2863
|
+
help_text << " ssh://user@host/path - Full SSH URI format\n"
|
2864
|
+
help_text << " myserver.com:/var/www - Connect to web directory\n\n"
|
2865
|
+
|
2866
|
+
help_text << " With SSH keys:\n".fg(249)
|
2867
|
+
help_text << " -i ~/.ssh/keyfile user@host:/path - Use specific SSH key\n"
|
2868
|
+
help_text << " user@host:/path -i ~/.ssh/keyfile - SSH key at end\n"
|
2869
|
+
help_text << " -i ~/.ssh/pf-do pfadmin@server.com - Custom key example\n\n"
|
2870
|
+
|
2871
|
+
help_text << " Example connections:\n".fg(249)
|
2872
|
+
help_text << " john@server.com:/home/john/documents\n".fg(240)
|
2873
|
+
help_text << " admin@192.168.1.100:/var/log\n".fg(240)
|
2874
|
+
help_text << " ssh://deploy@prod.server.com/app\n".fg(240)
|
2875
|
+
help_text << " -i ~/.ssh/aws-key ec2-user@1.2.3.4\n".fg(240)
|
2876
|
+
help_text << "\n"
|
2877
|
+
|
2878
|
+
help_text << " Comments for organization:\n".fg(249)
|
2879
|
+
help_text << " user@server.com:/path # Production server\n".fg(240)
|
2880
|
+
help_text << " admin@192.168.1.10 # Local development\n".fg(240)
|
2881
|
+
help_text << " root@backup.server.com # Backup storage\n".fg(240)
|
2882
|
+
help_text << " (Comments after # are ignored)\n".fg(240)
|
2883
|
+
help_text << "\n"
|
2884
|
+
|
2885
|
+
help_text << "Requirements:\n".fg(226)
|
2886
|
+
help_text << " • SSH access to remote host\n"
|
2887
|
+
help_text << " • SSH key authentication (recommended)\n"
|
2888
|
+
help_text << " • Or password authentication enabled\n\n"
|
2889
|
+
|
2890
|
+
help_text << "Remote Navigation:\n".fg(226)
|
2891
|
+
help_text << " • Use arrow keys to navigate directories\n"
|
2892
|
+
help_text << " • Press " + "d".fg(156) + " to download files\n"
|
2893
|
+
help_text << " • Press " + "u".fg(156) + " to upload files\n"
|
2894
|
+
help_text << " • Press " + "s".fg(156) + " to open SSH shell\n"
|
2895
|
+
help_text << " • Press " + "→".fg(156) + " to view file info\n"
|
2896
|
+
help_text << " • Press " + "Ctrl+E".fg(156) + " to return to local browsing\n"
|
2897
|
+
|
2898
|
+
help_text
|
1380
2899
|
end
|
1381
2900
|
|
1382
2901
|
# MANIPULATE ITEMS {{{2
|
@@ -1400,44 +2919,108 @@ def rename_item # {{{3
|
|
1400
2919
|
cmd = @pCmd.ask(': ', tpl).pure
|
1401
2920
|
match = cmd.match(/mv\s+"[^"]+"\s+"([^"]+)"/)
|
1402
2921
|
new_basename = match ? match[1] : basename
|
1403
|
-
|
1404
|
-
|
1405
|
-
|
1406
|
-
|
1407
|
-
|
1408
|
-
|
1409
|
-
|
1410
|
-
|
1411
|
-
|
1412
|
-
|
2922
|
+
|
2923
|
+
# Only proceed if name actually changed
|
2924
|
+
if new_basename != basename
|
2925
|
+
old_path = @selected
|
2926
|
+
old_esc = Shellwords.escape(old_path)
|
2927
|
+
new_path = File.join(dir, new_basename)
|
2928
|
+
new_esc = Shellwords.escape(new_path)
|
2929
|
+
|
2930
|
+
# Record undo information before rename
|
2931
|
+
undo_info = {
|
2932
|
+
type: 'rename',
|
2933
|
+
old_path: old_path,
|
2934
|
+
new_path: new_path,
|
2935
|
+
timestamp: Time.now
|
2936
|
+
}
|
2937
|
+
|
2938
|
+
shellexec("mv #{old_esc} #{new_esc}")
|
2939
|
+
|
2940
|
+
# Only add to undo history if rename was successful
|
2941
|
+
if File.exist?(new_path)
|
2942
|
+
add_undo_operation(undo_info)
|
2943
|
+
end
|
2944
|
+
|
2945
|
+
dirlist
|
2946
|
+
# point @selected and @index at the new name
|
2947
|
+
@selected = new_path
|
2948
|
+
new_idx = @files.index(new_basename)
|
2949
|
+
@index = new_idx if new_idx
|
2950
|
+
render
|
2951
|
+
end
|
1413
2952
|
end
|
1414
2953
|
|
1415
2954
|
def link_items # {{{3
|
2955
|
+
if @remote_mode
|
2956
|
+
# In remote mode, 's' key opens SSH shell
|
2957
|
+
open_remote_shell
|
2958
|
+
return
|
2959
|
+
end
|
2960
|
+
|
1416
2961
|
copy_move_link('link')
|
1417
2962
|
# Dual-pane refresh is handled in copy_move_link function
|
1418
2963
|
@pR.update = true
|
1419
2964
|
end
|
1420
2965
|
|
1421
2966
|
def delete_items # {{{3
|
2967
|
+
if @remote_mode
|
2968
|
+
# In remote mode, 'd' key downloads the selected file
|
2969
|
+
remote_download_selected
|
2970
|
+
return
|
2971
|
+
end
|
2972
|
+
|
1422
2973
|
tagged_info
|
1423
|
-
|
2974
|
+
|
2975
|
+
# Add deletion warning to the right pane
|
2976
|
+
warning_text = "\n" + "=" * 50 + "\n"
|
2977
|
+
action = @trash ? 'Move to Trash' : 'PERMANENT DELETE'
|
2978
|
+
action_color = @trash ? 220 : 196
|
2979
|
+
warning_text << action.fg(action_color).b + "\n\n"
|
2980
|
+
|
2981
|
+
if @trash
|
2982
|
+
warning_text << "Items will be moved to:\n".fg(249)
|
2983
|
+
warning_text << " ~/.rtfm/trash/\n".fg(240)
|
2984
|
+
warning_text << "\nYou can restore them with " + "C-z".fg(156) + "\n".fg(249)
|
2985
|
+
else
|
2986
|
+
warning_text << "⚠️ WARNING: PERMANENT DELETION!\n".fg(196).b
|
2987
|
+
warning_text << "Files will be permanently removed\n".fg(196)
|
2988
|
+
warning_text << "This action CANNOT be undone!\n".fg(196).b
|
2989
|
+
end
|
2990
|
+
|
2991
|
+
warning_text << "\n" + "Press " + "y".fg(156).b + " to confirm, any other key to cancel".fg(249)
|
2992
|
+
|
2993
|
+
@pR.text << warning_text
|
1424
2994
|
@pR.refresh
|
1425
|
-
|
1426
|
-
|
1427
|
-
|
1428
|
-
@pB.say(" #{
|
2995
|
+
|
2996
|
+
# Bottom pane prompt
|
2997
|
+
prompt_text = @trash ? "Move to trash? (y/n)" : "⚠️ PERMANENTLY DELETE? (y/n)"
|
2998
|
+
@pB.say(" #{prompt_text}".fg(action_color))
|
1429
2999
|
if getchr == 'y'
|
1430
3000
|
# collect & escape every path with existence verification
|
1431
3001
|
paths = (@tagged + [@selected]).uniq.select { |p| File.exist?(p) }
|
1432
3002
|
if paths.empty?
|
1433
3003
|
@pB.say("No valid items to #{action.downcase}".fg(196))
|
1434
3004
|
else
|
3005
|
+
# Record undo information before deletion
|
3006
|
+
if @trash
|
3007
|
+
undo_info = {
|
3008
|
+
type: 'delete',
|
3009
|
+
trash: true,
|
3010
|
+
paths: paths.map { |p| { path: p } },
|
3011
|
+
timestamp: Time.now
|
3012
|
+
}
|
3013
|
+
end
|
3014
|
+
|
1435
3015
|
esc = paths.map { |p| Shellwords.escape(p) }.join(' ')
|
1436
3016
|
if @trash
|
1437
3017
|
esc_trash = Shellwords.escape(TRASH_DIR)
|
1438
3018
|
command("mv -f #{esc} #{esc_trash}")
|
3019
|
+
# Only add to undo history if operation succeeded and we can undo it
|
3020
|
+
add_undo_operation(undo_info)
|
1439
3021
|
else
|
1440
3022
|
command("rm -rf #{esc}")
|
3023
|
+
# Cannot undo permanent deletion, so don't add to undo history
|
1441
3024
|
end
|
1442
3025
|
@tagged.clear
|
1443
3026
|
refresh_right
|
@@ -1846,70 +3429,141 @@ end
|
|
1846
3429
|
|
1847
3430
|
# SYSTEM SHORTCUTS {{{2
|
1848
3431
|
def system_info # {{{3
|
1849
|
-
text =
|
3432
|
+
text = "System Information\n".b.fg(156)
|
3433
|
+
text << "=" * 50 + "\n\n"
|
3434
|
+
|
1850
3435
|
begin
|
1851
|
-
|
1852
|
-
|
1853
|
-
|
1854
|
-
|
1855
|
-
|
1856
|
-
text
|
1857
|
-
|
1858
|
-
|
1859
|
-
|
1860
|
-
|
1861
|
-
|
1862
|
-
|
1863
|
-
|
1864
|
-
|
1865
|
-
|
1866
|
-
|
3436
|
+
# Operating System Information
|
3437
|
+
text << "Operating System:\n".fg(226)
|
3438
|
+
os_name = `awk -F '"' '/PRETTY/ {print $2}' /etc/os-release 2>/dev/null`.chomp
|
3439
|
+
kernel_version = `uname -r 2>/dev/null`.chomp
|
3440
|
+
architecture = `uname -m 2>/dev/null`.chomp
|
3441
|
+
text << sprintf(" %-15s %s\n", "Distribution:", os_name.fg(156))
|
3442
|
+
text << sprintf(" %-15s %s\n", "Kernel:", kernel_version.fg(156))
|
3443
|
+
text << sprintf(" %-15s %s\n", "Architecture:", architecture.fg(156))
|
3444
|
+
text << "\n"
|
3445
|
+
rescue # rubocop:disable Lint/SuppressedException
|
3446
|
+
end
|
3447
|
+
|
3448
|
+
begin
|
3449
|
+
# Hardware Information
|
3450
|
+
text << "Hardware:\n".fg(226)
|
3451
|
+
cpu_count = `nproc 2>/dev/null`.chomp
|
3452
|
+
cpuinfo = `lscpu 2>/dev/null`
|
3453
|
+
cpu_model = cpuinfo[/^.*Model name:\s*(.*)/, 1] || "Unknown"
|
3454
|
+
cpu_max = cpuinfo[/^.*CPU max MHz:\s*(.*)/, 1]&.to_i || 0
|
3455
|
+
cpu_min = cpuinfo[/^.*CPU min MHz:\s*(.*)/, 1]&.to_i || 0
|
3456
|
+
|
3457
|
+
text << sprintf(" %-15s %s cores\n", "CPU Count:", cpu_count.fg(156))
|
3458
|
+
text << sprintf(" %-15s %s\n", "CPU Model:", cpu_model.fg(156))
|
3459
|
+
if cpu_max > 0 && cpu_min > 0
|
3460
|
+
text << sprintf(" %-15s %d-%d MHz\n", "CPU Speed:", cpu_min, cpu_max)
|
3461
|
+
end
|
3462
|
+
text << "\n"
|
1867
3463
|
rescue # rubocop:disable Lint/SuppressedException
|
1868
3464
|
end
|
3465
|
+
|
1869
3466
|
begin
|
1870
|
-
|
1871
|
-
|
1872
|
-
|
1873
|
-
|
1874
|
-
|
1875
|
-
|
1876
|
-
|
1877
|
-
|
1878
|
-
|
1879
|
-
|
1880
|
-
|
1881
|
-
|
1882
|
-
|
1883
|
-
|
1884
|
-
sed 's/-/ /g'`.sub(/.*:/, '') + "\n"
|
1885
|
-
text += system.fg(251)
|
3467
|
+
# Memory Information
|
3468
|
+
text << "Memory Usage:\n".fg(226)
|
3469
|
+
mem_output = `free -h 2>/dev/null`
|
3470
|
+
if mem_output && !mem_output.empty?
|
3471
|
+
mem_lines = mem_output.lines
|
3472
|
+
mem_lines.each_with_index do |line, i|
|
3473
|
+
if i == 0 # Header
|
3474
|
+
text << " " + line.strip.fg(240) + "\n"
|
3475
|
+
else
|
3476
|
+
text << " " + line.strip.fg(156) + "\n"
|
3477
|
+
end
|
3478
|
+
end
|
3479
|
+
end
|
3480
|
+
text << "\n"
|
1886
3481
|
rescue # rubocop:disable Lint/SuppressedException
|
1887
3482
|
end
|
3483
|
+
|
1888
3484
|
begin
|
1889
|
-
|
1890
|
-
text
|
3485
|
+
# Environment Information
|
3486
|
+
text << "Environment:\n".fg(226)
|
3487
|
+
shell = `echo $SHELL 2>/dev/null`.sub(%r{.*/}, '').chomp
|
3488
|
+
terminal = `echo $TERM 2>/dev/null`.chomp
|
3489
|
+
packages = `dpkg-query -l 2>/dev/null | grep -c '^.i'`.chomp
|
3490
|
+
packages = `pacman -Q 2>/dev/null | wc -l`.chomp if packages == '0'
|
3491
|
+
packages = "Unknown" if packages == '0'
|
3492
|
+
|
3493
|
+
text << sprintf(" %-15s %s\n", "Shell:", shell.fg(156))
|
3494
|
+
text << sprintf(" %-15s %s\n", "Terminal:", terminal.fg(156))
|
3495
|
+
text << sprintf(" %-15s %s\n", "Packages:", packages.fg(156))
|
3496
|
+
text << "\n"
|
1891
3497
|
rescue # rubocop:disable Lint/SuppressedException
|
1892
3498
|
end
|
3499
|
+
|
1893
3500
|
begin
|
1894
|
-
|
1895
|
-
text
|
3501
|
+
# Disk Usage
|
3502
|
+
text << "Disk Usage:\n".fg(226)
|
3503
|
+
disk_output = `df -h 2>/dev/null | head -8`
|
3504
|
+
if disk_output && !disk_output.empty?
|
3505
|
+
disk_lines = disk_output.lines
|
3506
|
+
disk_lines.each_with_index do |line, i|
|
3507
|
+
if i == 0 # Header
|
3508
|
+
text << " " + line.strip.fg(240) + "\n"
|
3509
|
+
else
|
3510
|
+
# Highlight usage percentage
|
3511
|
+
colored_line = line.gsub(/(\d+)%/) do |match|
|
3512
|
+
percent = $1.to_i
|
3513
|
+
color = percent > 90 ? 196 : percent > 80 ? 220 : 156
|
3514
|
+
match.fg(color)
|
3515
|
+
end
|
3516
|
+
text << " " + colored_line.strip.fg(249) + "\n"
|
3517
|
+
end
|
3518
|
+
end
|
3519
|
+
end
|
3520
|
+
text << "\n"
|
1896
3521
|
rescue # rubocop:disable Lint/SuppressedException
|
1897
3522
|
end
|
3523
|
+
|
1898
3524
|
begin
|
1899
|
-
|
1900
|
-
text
|
3525
|
+
# Top Processes
|
3526
|
+
text << "Top Processes (CPU & Memory):\n".fg(226)
|
3527
|
+
ps_output = `ps -eo comm,pid,user,pcpu,pmem,stat --sort -pcpu,-pmem 2>/dev/null | head -8`
|
3528
|
+
if ps_output && !ps_output.empty?
|
3529
|
+
ps_lines = ps_output.lines
|
3530
|
+
ps_lines.each_with_index do |line, i|
|
3531
|
+
if i == 0 # Header
|
3532
|
+
text << " " + line.strip.fg(240) + "\n"
|
3533
|
+
else
|
3534
|
+
text << " " + line.strip.fg(249) + "\n"
|
3535
|
+
end
|
3536
|
+
end
|
3537
|
+
end
|
3538
|
+
text << "\n"
|
1901
3539
|
rescue # rubocop:disable Lint/SuppressedException
|
1902
3540
|
end
|
3541
|
+
|
1903
3542
|
begin
|
1904
|
-
|
1905
|
-
|
1906
|
-
|
1907
|
-
|
3543
|
+
# System Messages
|
3544
|
+
text << "Recent System Messages:\n".fg(226)
|
3545
|
+
dmesg_output = `dmesg 2>/dev/null | tail -5`
|
3546
|
+
if dmesg_output && !dmesg_output.empty?
|
3547
|
+
dmesg_output.lines.reverse.each do |line|
|
3548
|
+
# Color code different message types
|
3549
|
+
colored_line = case line
|
3550
|
+
when /error|fail|critical/i then line.fg(196)
|
3551
|
+
when /warn/i then line.fg(220)
|
3552
|
+
when /info/i then line.fg(156)
|
3553
|
+
else line.fg(249)
|
3554
|
+
end
|
3555
|
+
text << " " + colored_line.strip + "\n"
|
3556
|
+
end
|
3557
|
+
else
|
3558
|
+
text << " " + "dmesg requires root access".fg(240) + "\n"
|
3559
|
+
text << " " + "Run: sudo sysctl kernel.dmesg_restrict=0".fg(240) + "\n"
|
3560
|
+
end
|
1908
3561
|
rescue # rubocop:disable Lint/SuppressedException
|
1909
3562
|
end
|
3563
|
+
|
1910
3564
|
@pR.say(text)
|
1911
3565
|
rescue
|
1912
|
-
@pR.say('Unable to show system info')
|
3566
|
+
@pR.say('Unable to show system info'.fg(196))
|
1913
3567
|
end
|
1914
3568
|
|
1915
3569
|
def make_directory # {{{3
|
@@ -1999,6 +3653,34 @@ def show_history # {{{3
|
|
1999
3653
|
@pB.update = true
|
2000
3654
|
end
|
2001
3655
|
|
3656
|
+
def show_ssh_history # {{{3
|
3657
|
+
history_text = "SSH Connection History\n".b.fg(156)
|
3658
|
+
history_text << "=" * 50 + "\n\n"
|
3659
|
+
|
3660
|
+
if @pSsh.history.empty?
|
3661
|
+
history_text << "No SSH connections in history\n".fg(240)
|
3662
|
+
history_text << "\nPress " + "Ctrl+E".fg(156) + " to start browsing remote directories\n".fg(249)
|
3663
|
+
else
|
3664
|
+
history_text << "Recent connections:\n".fg(226)
|
3665
|
+
@pSsh.history.reverse.each_with_index do |connection, i|
|
3666
|
+
# Parse and format connection for display
|
3667
|
+
if connection =~ /([^@]+@[^:\s]+)/
|
3668
|
+
host_part = $1
|
3669
|
+
history_text << sprintf(" %2d. %s\n", i + 1, host_part.fg(156))
|
3670
|
+
if connection.length > host_part.length + 10
|
3671
|
+
history_text << sprintf(" %s\n", connection.fg(240))
|
3672
|
+
end
|
3673
|
+
else
|
3674
|
+
history_text << sprintf(" %2d. %s\n", i + 1, connection.fg(156))
|
3675
|
+
end
|
3676
|
+
end
|
3677
|
+
history_text << "\n" + "Use " + "↑/↓".fg(156) + " in SSH prompt to recall connections".fg(249)
|
3678
|
+
end
|
3679
|
+
|
3680
|
+
@pR.say(history_text)
|
3681
|
+
@pB.update = true
|
3682
|
+
end
|
3683
|
+
|
2002
3684
|
def add_interactive # {{{
|
2003
3685
|
@interactive = @pB.ask('Add program to @interactive: '.fg(213), @interactive.fg(213))
|
2004
3686
|
@pB.clear; @pB.update = true
|
@@ -2008,6 +3690,13 @@ end
|
|
2008
3690
|
def ruby_debug # {{{3
|
2009
3691
|
require 'stringio'
|
2010
3692
|
cmd = @pRuby.ask('Ruby command: ', '')
|
3693
|
+
|
3694
|
+
# If user cancelled (ESC) or entered empty command, don't execute or output anything
|
3695
|
+
if cmd.nil? || cmd.strip.empty?
|
3696
|
+
@pB.clear; @pB.update = true
|
3697
|
+
return
|
3698
|
+
end
|
3699
|
+
|
2011
3700
|
@pR.text = "Command: #{cmd}\n\n".fg(205)
|
2012
3701
|
original_stdout = $stdout
|
2013
3702
|
original_stderr = $stderr
|
@@ -2031,24 +3720,9 @@ end
|
|
2031
3720
|
|
2032
3721
|
# GENERIC FUNCTIONS {{{1
|
2033
3722
|
def get_cached_dirlist(dir, ls_options) # {{{2
|
2034
|
-
#
|
3723
|
+
# Directory caching disabled for stability
|
2035
3724
|
return nil
|
2036
3725
|
|
2037
|
-
# Create cache key from directory and options
|
2038
|
-
# cache_key = "#{dir}:#{ls_options}"
|
2039
|
-
|
2040
|
-
# begin
|
2041
|
-
# dir_stat = File.stat(dir)
|
2042
|
-
# cache_with_time = "#{cache_key}:#{dir_stat.mtime.to_i}"
|
2043
|
-
# rescue
|
2044
|
-
# # Directory might not exist or be accessible
|
2045
|
-
# return nil
|
2046
|
-
# end
|
2047
|
-
|
2048
|
-
# Check if we have cached results for this directory
|
2049
|
-
# cached_result = @dir_cache[cache_with_time]
|
2050
|
-
# return cached_result if cached_result
|
2051
|
-
|
2052
3726
|
# Clean old cache entries if cache is getting too large
|
2053
3727
|
if @dir_cache_size >= @max_cache_entries
|
2054
3728
|
@dir_cache.clear
|
@@ -2118,10 +3792,93 @@ def get_cached_file_metadata(file_path) # {{{2
|
|
2118
3792
|
end
|
2119
3793
|
end
|
2120
3794
|
|
3795
|
+
def dirlist_remote # {{{2
|
3796
|
+
return '' unless @current_remote && @remote_mode
|
3797
|
+
|
3798
|
+
current_index = @index || 0
|
3799
|
+
current_index = current_index.to_i
|
3800
|
+
width = @pL.w
|
3801
|
+
|
3802
|
+
# Check if we need to refresh the directory listing
|
3803
|
+
connection_id = "#{@current_remote[:user]}@#{@current_remote[:host]}"
|
3804
|
+
cache_key = "#{connection_id}:#{@remote_path}"
|
3805
|
+
|
3806
|
+
# Use cache if available and recent (60 seconds for navigation)
|
3807
|
+
if @remote_cache[cache_key] && (Time.now - @remote_cache[cache_key][:timestamp]) < 60
|
3808
|
+
files = @remote_cache[cache_key][:files]
|
3809
|
+
else
|
3810
|
+
# Only fetch remote listing when cache is stale or missing
|
3811
|
+
begin
|
3812
|
+
files = list_remote_directory(@remote_path)
|
3813
|
+
# Cache the result
|
3814
|
+
@remote_cache[cache_key] = {
|
3815
|
+
files: files,
|
3816
|
+
timestamp: Time.now
|
3817
|
+
}
|
3818
|
+
rescue StandardError => e
|
3819
|
+
@pB.say("Remote listing failed: #{e.message}".fg(196))
|
3820
|
+
return ''
|
3821
|
+
end
|
3822
|
+
end
|
3823
|
+
|
3824
|
+
# Update global @files for navigation (simple names array)
|
3825
|
+
@files = files.map { |f| f[:name] }
|
3826
|
+
|
3827
|
+
# Store the full file info for later use (avoid repeated SSH calls)
|
3828
|
+
@remote_files_cache = files
|
3829
|
+
|
3830
|
+
# Update @selected for current selection
|
3831
|
+
if @files[@index] && files[@index]
|
3832
|
+
@selected = "#{@remote_path}/#{@files[@index]}"
|
3833
|
+
selected_file = files[@index]
|
3834
|
+
@fileattr = "#{selected_file[:owner]}:#{selected_file[:group]} #{selected_file[:permissions]} #{format_size_simple(selected_file[:size])} #{selected_file[:modified]}"
|
3835
|
+
end
|
3836
|
+
|
3837
|
+
# Format the listing with remote-specific styling
|
3838
|
+
search_regex = @searched.empty? ? nil : /#{@searched}/
|
3839
|
+
|
3840
|
+
result = files.map.with_index do |file, i|
|
3841
|
+
name = file[:name]
|
3842
|
+
|
3843
|
+
# Color coding for different file types
|
3844
|
+
color = case file[:type]
|
3845
|
+
when 'directory' then 156 # Blue-ish
|
3846
|
+
when 'symlink' then 226 # Yellow
|
3847
|
+
when 'file' then 255 # White
|
3848
|
+
else 240 # Gray
|
3849
|
+
end
|
3850
|
+
|
3851
|
+
# Apply color and decorations
|
3852
|
+
n = name.fg(color)
|
3853
|
+
n = n.inject('@', -1) if file[:type] == 'symlink'
|
3854
|
+
n = n.inject('/', -1) if file[:type] == 'directory'
|
3855
|
+
n = n.bg(238) if search_regex && name.match(search_regex)
|
3856
|
+
|
3857
|
+
# Truncate if too long
|
3858
|
+
n = n.shorten(width - 5).inject('…', -1) if name.length > width - 6
|
3859
|
+
|
3860
|
+
# Add selection indicator and remote mode indicator (red background)
|
3861
|
+
if i == current_index
|
3862
|
+
n = '→ ' + n.u.bg(52) # Red background for remote mode selection
|
3863
|
+
else
|
3864
|
+
n = ' ' + n.bg(52) # Red background for remote mode
|
3865
|
+
end
|
3866
|
+
|
3867
|
+
n
|
3868
|
+
end
|
3869
|
+
|
3870
|
+
result.join("\n")
|
3871
|
+
end
|
3872
|
+
|
2121
3873
|
def dirlist(left: true, directory: nil) # LIST DIRECTORIES {{{2
|
2122
3874
|
current_index = @index || 0
|
2123
3875
|
current_index = current_index.to_i
|
2124
3876
|
|
3877
|
+
# Handle remote mode for left pane
|
3878
|
+
if left && @remote_mode
|
3879
|
+
return dirlist_remote
|
3880
|
+
end
|
3881
|
+
|
2125
3882
|
if left
|
2126
3883
|
dir = directory || Dir.pwd
|
2127
3884
|
width = @pL.w
|
@@ -2279,9 +4036,6 @@ end
|
|
2279
4036
|
def render # RENDER ALL PANES {{{2
|
2280
4037
|
return unless needs_render?
|
2281
4038
|
|
2282
|
-
# TEMPORARILY DISABLED: Use batch updates for rcurses 4.9.0+ performance improvement
|
2283
|
-
# Rcurses.batch_refresh do
|
2284
|
-
|
2285
4039
|
# LEFT pane {{{3
|
2286
4040
|
if @pL.update
|
2287
4041
|
lefttext = @pL.text
|
@@ -2422,8 +4176,6 @@ def render # RENDER ALL PANES {{{2
|
|
2422
4176
|
@pB.text = info
|
2423
4177
|
@pB.refresh unless @pB.text == bottomtext
|
2424
4178
|
end
|
2425
|
-
|
2426
|
-
# end # Rcurses.batch_refresh - TEMPORARILY DISABLED
|
2427
4179
|
end
|
2428
4180
|
|
2429
4181
|
def refresh # {{{2
|
@@ -2471,6 +4223,8 @@ def refresh # {{{2
|
|
2471
4223
|
@pCmd.w = @w
|
2472
4224
|
@pRuby.y = @h
|
2473
4225
|
@pRuby.w = @w
|
4226
|
+
@pSsh.y = @h
|
4227
|
+
@pSsh.w = @w
|
2474
4228
|
end
|
2475
4229
|
|
2476
4230
|
def setborder # {{{2
|
@@ -2607,6 +4361,9 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
|
|
2607
4361
|
Dir.pwd
|
2608
4362
|
end
|
2609
4363
|
|
4364
|
+
# Track operations for undo
|
4365
|
+
operations = []
|
4366
|
+
|
2610
4367
|
@tagged.each do |item|
|
2611
4368
|
dest = File.join(dest_dir, File.basename(item))
|
2612
4369
|
dest += '1' if File.exist?(dest)
|
@@ -2618,18 +4375,46 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
|
|
2618
4375
|
case type
|
2619
4376
|
when 'copy'
|
2620
4377
|
FileUtils.cp_r(item, dest)
|
4378
|
+
operations << { source_path: item, dest_path: dest }
|
2621
4379
|
@pB.say(' Item(s) copied here.')
|
2622
4380
|
when 'move'
|
2623
4381
|
FileUtils.mv(item, dest)
|
4382
|
+
operations << { source_path: item, dest_path: dest }
|
2624
4383
|
@pB.say(' Item(s) moved here.')
|
2625
4384
|
when 'link'
|
2626
4385
|
FileUtils.ln_s(item, dest)
|
4386
|
+
operations << { source_path: item, dest_path: dest }
|
2627
4387
|
@pB.say(' Item(s) symlinked here.')
|
2628
4388
|
end
|
2629
4389
|
rescue => e
|
2630
4390
|
@pB.say(e.to_s)
|
2631
4391
|
end
|
2632
4392
|
end
|
4393
|
+
|
4394
|
+
# Record undo information if operations were successful
|
4395
|
+
unless operations.empty?
|
4396
|
+
case type
|
4397
|
+
when 'copy'
|
4398
|
+
add_undo_operation({
|
4399
|
+
type: 'copy',
|
4400
|
+
copies: operations,
|
4401
|
+
timestamp: Time.now
|
4402
|
+
})
|
4403
|
+
when 'move'
|
4404
|
+
add_undo_operation({
|
4405
|
+
type: 'move',
|
4406
|
+
moves: operations,
|
4407
|
+
timestamp: Time.now
|
4408
|
+
})
|
4409
|
+
when 'link'
|
4410
|
+
add_undo_operation({
|
4411
|
+
type: 'link',
|
4412
|
+
links: operations,
|
4413
|
+
timestamp: Time.now
|
4414
|
+
})
|
4415
|
+
end
|
4416
|
+
end
|
4417
|
+
|
2633
4418
|
@tagged = []
|
2634
4419
|
|
2635
4420
|
# Set update flags for proper refresh - let render system handle the actual refresh
|
@@ -2709,9 +4494,13 @@ def open_selected(html = nil) # OPEN SELECTED FILE {{{2
|
|
2709
4494
|
if File.directory?(@selected) # Dir? just cd into it
|
2710
4495
|
mark_latest
|
2711
4496
|
Dir.chdir(@selected) rescue nil
|
4497
|
+
track_directory_access(@selected)
|
2712
4498
|
return
|
2713
4499
|
end
|
2714
4500
|
|
4501
|
+
# Track file access when opening files
|
4502
|
+
track_file_access(@selected)
|
4503
|
+
|
2715
4504
|
# Check if this file should use an interactive program (same as § prefix logic)
|
2716
4505
|
if !html && (prog = get_interactive_program(@selected))
|
2717
4506
|
cmd = "#{prog} #{Shellwords.escape(@selected)}"
|
@@ -2799,7 +4588,8 @@ def conf_write(all: false) # WRITE TO ~/.rtfm/conf {{{2
|
|
2799
4588
|
'hash' => "@hash = #{@hash}",
|
2800
4589
|
'history' => "@history = #{@pCmd.history.reverse.uniq.reverse.last(40)}",
|
2801
4590
|
'rubyhistory' => "@rubyhistory = #{@pRuby.history.reverse.uniq.reverse.last(40)}",
|
2802
|
-
'aihistory' => "@aihistory = #{@pAI.history.reverse.uniq.reverse.last(40)}"
|
4591
|
+
'aihistory' => "@aihistory = #{@pAI.history.reverse.uniq.reverse.last(40)}",
|
4592
|
+
'sshhistory' => "@sshhistory = #{@pSsh.history.reverse.uniq.reverse.last(40)}"
|
2803
4593
|
}
|
2804
4594
|
if all
|
2805
4595
|
assignments.merge!(
|
@@ -3034,24 +4824,77 @@ end
|
|
3034
4824
|
|
3035
4825
|
def marks_info # SHOW MARKS IN RIGHT WINDOW {{{2
|
3036
4826
|
@marks = @marks.sort.to_h
|
3037
|
-
info =
|
4827
|
+
info = "Directory Marks".b.fg(156) + "\n"
|
4828
|
+
info << "=" * 50 + "\n\n"
|
4829
|
+
|
3038
4830
|
if @marks.empty?
|
3039
|
-
info
|
4831
|
+
info << "No marks set".fg(240) + "\n\n"
|
4832
|
+
info << "Use 'm' followed by a letter to set a mark\n".fg(249)
|
4833
|
+
info << "Use " + "'" + " followed by a letter to jump to mark".fg(249)
|
3040
4834
|
else
|
4835
|
+
info << "Current marks:".fg(226) + "\n\n"
|
3041
4836
|
@marks.each do |mark, dir|
|
3042
|
-
|
3043
|
-
|
3044
|
-
|
4837
|
+
# Color special marks differently
|
4838
|
+
mark_color = case mark
|
4839
|
+
when "'" then 196 # Red for 'latest' mark
|
4840
|
+
when /[0-9]/ then 220 # Yellow for number marks
|
4841
|
+
else 156 # Green for letter marks
|
4842
|
+
end
|
4843
|
+
|
4844
|
+
# Truncate long paths for better display
|
4845
|
+
display_dir = dir.length > 45 ? "..." + dir[-42..-1] : dir
|
4846
|
+
info << sprintf(" %s → %s\n", mark.fg(mark_color).b, display_dir.fg(249))
|
4847
|
+
|
4848
|
+
# Add spacing after special marks
|
4849
|
+
info << "\n" if mark == "'"
|
4850
|
+
info << "\n" if mark == '5'
|
3045
4851
|
end
|
4852
|
+
info << "\n" + "Press " + "'" + " + letter to jump".fg(240)
|
3046
4853
|
end
|
3047
|
-
@pR.say(info
|
4854
|
+
@pR.say(info)
|
3048
4855
|
end
|
3049
4856
|
|
3050
4857
|
def tagged_info # SHOW THE LIST OF TAGGED ITEMS IN @pR {{{2
|
3051
|
-
info =
|
3052
|
-
info
|
3053
|
-
|
3054
|
-
|
4858
|
+
info = "Tagged Items".b.fg(204) + "\n"
|
4859
|
+
info << "=" * 50 + "\n\n"
|
4860
|
+
|
4861
|
+
# Summary information
|
4862
|
+
size_mb = (@tagsize.to_f / 1_000_000).round(2)
|
4863
|
+
info << "Summary:\n".fg(226)
|
4864
|
+
info << sprintf(" %-12s %d\n", "Items:", @tagged.size)
|
4865
|
+
info << sprintf(" %-12s %.2f MB\n", "Total size:", size_mb)
|
4866
|
+
info << "\n"
|
4867
|
+
|
4868
|
+
if @tagged.empty?
|
4869
|
+
info << "No items tagged\n".fg(240)
|
4870
|
+
info << "\nUse " + "SPACE".fg(156) + " to tag/untag items\n".fg(249)
|
4871
|
+
else
|
4872
|
+
info << "Tagged files:\n".fg(226)
|
4873
|
+
@tagged.each_with_index do |item, i|
|
4874
|
+
# Show just filename for long paths
|
4875
|
+
display_name = File.basename(item)
|
4876
|
+
full_path = item.length > 50 ? "..." + item[-47..-1] : item
|
4877
|
+
|
4878
|
+
# Color based on file type
|
4879
|
+
color = File.directory?(item) ? 156 : 249
|
4880
|
+
info << sprintf(" %2d. %s\n", i + 1, display_name.fg(color))
|
4881
|
+
|
4882
|
+
# Show full path on separate line if truncated
|
4883
|
+
if item.length > 50
|
4884
|
+
info << sprintf(" %s\n", full_path.fg(240))
|
4885
|
+
end
|
4886
|
+
end
|
4887
|
+
end
|
4888
|
+
|
4889
|
+
info << "\n" + "Currently selected:\n".fg(226)
|
4890
|
+
selected_name = File.basename(@selected)
|
4891
|
+
selected_color = File.directory?(@selected) ? 156 : 249
|
4892
|
+
info << " \u2192 " + selected_name.fg(selected_color).b + "\n"
|
4893
|
+
if @selected.length > 50
|
4894
|
+
info << " " + @selected.fg(240) + "\n"
|
4895
|
+
end
|
4896
|
+
|
4897
|
+
@pR.say(info)
|
3055
4898
|
end
|
3056
4899
|
|
3057
4900
|
# MAIN PROGRAM {{{1
|
@@ -3087,8 +4930,8 @@ end
|
|
3087
4930
|
@pSearch = Pane.new( 1, @h, @w, 1, 255, @searchcolor)
|
3088
4931
|
@pRuby = Pane.new( 1, @h, @w, 1, 255, @rubycolor)
|
3089
4932
|
@pAI = Pane.new( 1, @h, @w, 1, 255, @aicolor)
|
4933
|
+
@pSsh = Pane.new( 1, @h, @w, 1, 255, @sshcolor)
|
3090
4934
|
# rubocop:enable Naming/VariableName
|
3091
|
-
#checkpoint("Panes created")
|
3092
4935
|
|
3093
4936
|
## Set pane properties {{{2
|
3094
4937
|
@pTab.update = true
|
@@ -3103,6 +4946,8 @@ end
|
|
3103
4946
|
@pRuby.history = @rubyhistory
|
3104
4947
|
@pAI.record = true
|
3105
4948
|
@pAI.history = @aihistory
|
4949
|
+
@pSsh.record = true
|
4950
|
+
@pSsh.history = @sshhistory
|
3106
4951
|
|
3107
4952
|
# Report plugin errors {{{2
|
3108
4953
|
@pR.say("Plugin load errors:\n" + @plugin_errors.join("\n").fg(196)) if @plugin_errors.any?
|
@@ -3123,7 +4968,6 @@ end
|
|
3123
4968
|
$stdin.getc while $stdin.wait_readable(0)
|
3124
4969
|
|
3125
4970
|
## THE LOOP {{{2
|
3126
|
-
#checkpoint("Program started")
|
3127
4971
|
loop do
|
3128
4972
|
@dir_old = Dir.pwd
|
3129
4973
|
|