rtfm-filemanager 5.10.4 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +54 -9
- data/bin/rtfm +2245 -223
- 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.1' # Remote mode fixes: FrozenError fix, enhanced upload workflow, refresh functionality
|
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
|
@@ -1018,6 +1098,11 @@ def show_version # {{{3
|
|
1018
1098
|
end
|
1019
1099
|
|
1020
1100
|
def refresh_all # {{{3
|
1101
|
+
# Clear remote directory cache if in remote mode
|
1102
|
+
if @remote_mode
|
1103
|
+
@remote_files_cache = []
|
1104
|
+
@pL.update = true
|
1105
|
+
end
|
1021
1106
|
refresh
|
1022
1107
|
end
|
1023
1108
|
|
@@ -1093,38 +1178,82 @@ end
|
|
1093
1178
|
def move_down # {{{3
|
1094
1179
|
@index = @index >= @max_index ? @min_index : @index + 1
|
1095
1180
|
@pL.update = true
|
1096
|
-
|
1181
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1182
|
+
if @remote_mode
|
1183
|
+
@pB.update = true
|
1184
|
+
else
|
1185
|
+
@pR.update = @pB.update = true
|
1186
|
+
end
|
1097
1187
|
end
|
1098
1188
|
|
1099
1189
|
def move_up # {{{3
|
1100
1190
|
@index = @index <= @min_index ? @max_index : @index - 1
|
1101
1191
|
@pL.update = true
|
1102
|
-
|
1192
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1193
|
+
if @remote_mode
|
1194
|
+
@pB.update = true
|
1195
|
+
else
|
1196
|
+
@pR.update = @pB.update = true
|
1197
|
+
end
|
1103
1198
|
end
|
1104
1199
|
|
1105
1200
|
def move_left # {{{3
|
1106
|
-
|
1107
|
-
|
1108
|
-
|
1109
|
-
|
1110
|
-
|
1111
|
-
|
1112
|
-
|
1113
|
-
|
1114
|
-
|
1115
|
-
|
1116
|
-
|
1117
|
-
|
1201
|
+
if @remote_mode
|
1202
|
+
# Remote mode - go to parent directory
|
1203
|
+
return if @remote_path == '/' || @remote_path == '~'
|
1204
|
+
|
1205
|
+
old_path = @remote_path
|
1206
|
+
@remote_path = File.dirname(@remote_path)
|
1207
|
+
@remote_files_cache = [] # Clear cache when changing directories
|
1208
|
+
@index = 0 # Reset selection
|
1209
|
+
@pL.update = true
|
1210
|
+
@pR.update = @pB.update = true
|
1211
|
+
else
|
1212
|
+
# Local mode
|
1213
|
+
old_dir = Dir.pwd
|
1214
|
+
parent = File.dirname(old_dir)
|
1215
|
+
child = File.basename(old_dir)
|
1216
|
+
purels = command(
|
1217
|
+
"ls #{Shellwords.escape(parent)} #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}"
|
1218
|
+
).pure.split("\n")
|
1219
|
+
child_idx = purels.index(child) || 0
|
1220
|
+
@directory[parent] = child_idx
|
1221
|
+
mark_latest
|
1222
|
+
Dir.chdir(parent)
|
1223
|
+
track_directory_access(parent)
|
1224
|
+
@pL.update = true
|
1225
|
+
@pR.update = @pB.update = true
|
1226
|
+
end
|
1118
1227
|
end
|
1119
1228
|
|
1120
1229
|
# dirlist_simple function removed - was only for dual-pane navigation
|
1121
1230
|
|
1122
1231
|
def move_right # {{{3
|
1123
|
-
|
1124
|
-
|
1125
|
-
|
1126
|
-
|
1127
|
-
|
1232
|
+
if @remote_mode
|
1233
|
+
# Remote mode - enter directory or perform action on file
|
1234
|
+
return unless @files && @files[@index] && @remote_files_cache[@index]
|
1235
|
+
|
1236
|
+
selected_file = @remote_files_cache[@index]
|
1237
|
+
|
1238
|
+
if selected_file[:type] == 'directory'
|
1239
|
+
# Enter directory
|
1240
|
+
@remote_path = File.join(@remote_path, selected_file[:name])
|
1241
|
+
@remote_files_cache = [] # Clear cache when changing directories
|
1242
|
+
@index = 0 # Reset selection
|
1243
|
+
@pL.update = true
|
1244
|
+
@pR.update = @pB.update = true
|
1245
|
+
else
|
1246
|
+
# File selected - show file info in right pane
|
1247
|
+
show_remote_file_info(selected_file)
|
1248
|
+
end
|
1249
|
+
else
|
1250
|
+
# Local mode
|
1251
|
+
@directory[Dir.pwd] = @index
|
1252
|
+
mark_latest
|
1253
|
+
open_selected
|
1254
|
+
@index = @directory[Dir.pwd] || 0
|
1255
|
+
@pB.update = true
|
1256
|
+
end
|
1128
1257
|
end
|
1129
1258
|
|
1130
1259
|
def open_force # {{{3
|
@@ -1137,23 +1266,47 @@ end
|
|
1137
1266
|
def page_down # {{{3
|
1138
1267
|
@index += @pL.h - 2
|
1139
1268
|
@index = @max_index if @index > @max_index
|
1140
|
-
@
|
1269
|
+
@pL.update = true
|
1270
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1271
|
+
if @remote_mode
|
1272
|
+
@pB.update = true
|
1273
|
+
else
|
1274
|
+
@pR.update = @pB.update = true
|
1275
|
+
end
|
1141
1276
|
end
|
1142
1277
|
|
1143
1278
|
def page_up # {{{3
|
1144
1279
|
@index -= @pL.h - 2
|
1145
1280
|
@index = @min_index if @index < @min_index
|
1146
|
-
@
|
1281
|
+
@pL.update = true
|
1282
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1283
|
+
if @remote_mode
|
1284
|
+
@pB.update = true
|
1285
|
+
else
|
1286
|
+
@pR.update = @pB.update = true
|
1287
|
+
end
|
1147
1288
|
end
|
1148
1289
|
|
1149
1290
|
def go_last # {{{3
|
1150
1291
|
@index = @max_index
|
1151
|
-
@
|
1292
|
+
@pL.update = true
|
1293
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1294
|
+
if @remote_mode
|
1295
|
+
@pB.update = true
|
1296
|
+
else
|
1297
|
+
@pR.update = @pB.update = true
|
1298
|
+
end
|
1152
1299
|
end
|
1153
1300
|
|
1154
1301
|
def go_first # {{{3
|
1155
1302
|
@index = @min_index
|
1156
|
-
@
|
1303
|
+
@pL.update = true
|
1304
|
+
# In remote mode, only update bottom pane (for file attributes)
|
1305
|
+
if @remote_mode
|
1306
|
+
@pB.update = true
|
1307
|
+
else
|
1308
|
+
@pR.update = @pB.update = true
|
1309
|
+
end
|
1157
1310
|
end
|
1158
1311
|
|
1159
1312
|
# switch_pane function removed - using tabs for multi-directory navigation
|
@@ -1186,7 +1339,7 @@ def jump_to_mark # {{{3
|
|
1186
1339
|
if m =~ /[\w']/ && @marks[m]
|
1187
1340
|
@directory[Dir.pwd] = @index
|
1188
1341
|
dir_before = Dir.pwd
|
1189
|
-
begin; Dir.chdir(@marks[m]); rescue; @pB.say(' No such directory'); end
|
1342
|
+
begin; Dir.chdir(@marks[m]); track_directory_access(@marks[m]); rescue; @pB.say(' No such directory'); end
|
1190
1343
|
mark_latest
|
1191
1344
|
@marks["'"] = dir_before
|
1192
1345
|
end
|
@@ -1285,98 +1438,1635 @@ def toggle_order # {{{3
|
|
1285
1438
|
when '-t'
|
1286
1439
|
@lsorder = '-X'; @pB.say(' Sorting by extension')
|
1287
1440
|
else
|
1288
|
-
@lsorder = ''; @pB.say(' Normal sorting')
|
1441
|
+
@lsorder = ''; @pB.say(' Normal sorting')
|
1442
|
+
end
|
1443
|
+
@pR.update = true; @orderchange = true
|
1444
|
+
end
|
1445
|
+
|
1446
|
+
def toggle_invert # {{{3
|
1447
|
+
@lsinvert = @lsinvert.empty? ? '-r' : ''
|
1448
|
+
@pB.say(' Sorting inverted')
|
1449
|
+
@pR.update = true; @orderchange = true
|
1450
|
+
end
|
1451
|
+
|
1452
|
+
def show_ls_command # {{{3
|
1453
|
+
@pB.say(" Full 'ls' command: ls #{@lsbase} #{@lslong} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}".gsub(/ +/, ' '))
|
1454
|
+
@pB.update = false
|
1455
|
+
end
|
1456
|
+
|
1457
|
+
# TAGGING {{{2
|
1458
|
+
def tag_current # {{{3
|
1459
|
+
if @dual_pane
|
1460
|
+
# In dual-pane mode, get the correct selected item and construct full path
|
1461
|
+
current_dir = @active_pane == :left ? @pwd_left : @pwd_right
|
1462
|
+
current_index = @active_pane == :left ? @index_left : @index_right
|
1463
|
+
current_files = @active_pane == :left ? @files_left : @files_right
|
1464
|
+
|
1465
|
+
if current_files && current_index < current_files.length
|
1466
|
+
selected_item = current_files[current_index]
|
1467
|
+
item = File.join(current_dir, selected_item)
|
1468
|
+
|
1469
|
+
# Tag/untag the item
|
1470
|
+
if @tagged.include?(item)
|
1471
|
+
@tagged.delete(item); @tagsize -= File.size(item) rescue 0
|
1472
|
+
else
|
1473
|
+
@tagged.push(item); @tagsize += File.size(item) rescue 0
|
1474
|
+
end
|
1475
|
+
|
1476
|
+
# Advance to next item in the active pane
|
1477
|
+
max_index = current_files.size - 1
|
1478
|
+
if @active_pane == :left
|
1479
|
+
@index_left = [@index_left + 1, max_index].min
|
1480
|
+
@selected_left = current_files[@index_left] if current_files[@index_left]
|
1481
|
+
@pLeft.update = true
|
1482
|
+
else
|
1483
|
+
@index_right = [@index_right + 1, max_index].min
|
1484
|
+
@selected_right = current_files[@index_right] if current_files[@index_right]
|
1485
|
+
@pRight.update = true
|
1486
|
+
end
|
1487
|
+
|
1488
|
+
# Update compatibility variables
|
1489
|
+
@index = @active_pane == :left ? @index_left : @index_right
|
1490
|
+
@selected = @active_pane == :left ? @selected_left : @selected_right
|
1491
|
+
|
1492
|
+
@pPreview.update = true if @pPreview
|
1493
|
+
end
|
1494
|
+
else
|
1495
|
+
# Original single-pane logic
|
1496
|
+
item = @selected
|
1497
|
+
if @tagged.include?(item)
|
1498
|
+
@tagged.delete(item); @tagsize -= File.size(item) rescue 0
|
1499
|
+
else
|
1500
|
+
@tagged.push(item); @tagsize += File.size(item) rescue 0
|
1501
|
+
end
|
1502
|
+
@index = [@index + 1, (@files.size - 1)].min
|
1503
|
+
@pL.update = true
|
1504
|
+
end
|
1505
|
+
|
1506
|
+
@pB.say(" Tagged #{@tagged.size} files (#{(@tagsize.to_f / 1_000_000).round(2)}MB)".fg(204))
|
1507
|
+
@pB.update = false; @pR.update = true
|
1508
|
+
end
|
1509
|
+
|
1510
|
+
def tag_pattern # {{{3
|
1511
|
+
pat = @pB.ask('Tag pattern (ruby regex): ', '')
|
1512
|
+
re = Regexp.new(pat)
|
1513
|
+
matches = @files.grep(re).map { |t| File.join(Dir.pwd, t) }
|
1514
|
+
matches.each do |f|
|
1515
|
+
@tagsize += File.size(f) rescue nil
|
1516
|
+
end
|
1517
|
+
@tagged.concat(matches)
|
1518
|
+
@tagged.uniq!
|
1519
|
+
@pB.say(" Tagged #{@tagged.size} files (#{(@tagsize.to_f / 1_000_000).round(2)}MB)".fg(204))
|
1520
|
+
@pB.update = false
|
1521
|
+
@pR.update = true
|
1522
|
+
end
|
1523
|
+
|
1524
|
+
def show_tagged # {{{3
|
1525
|
+
tagged_info
|
1526
|
+
@pB.update = true
|
1527
|
+
end
|
1528
|
+
|
1529
|
+
def clear_tagged # {{{3
|
1530
|
+
if @remote_mode
|
1531
|
+
# In remote mode, 'u' key uploads a file
|
1532
|
+
remote_upload_file
|
1533
|
+
return
|
1534
|
+
end
|
1535
|
+
|
1536
|
+
@tagged = []
|
1537
|
+
tagged_info
|
1538
|
+
@pB.update = true
|
1539
|
+
end
|
1540
|
+
|
1541
|
+
# UNDO SYSTEM {{{2
|
1542
|
+
def add_undo_operation(operation) # {{{3
|
1543
|
+
return unless @undo_enabled
|
1544
|
+
|
1545
|
+
@undo_history << operation
|
1546
|
+
# Keep only the most recent operations
|
1547
|
+
@undo_history.shift if @undo_history.length > @max_undo_levels
|
1548
|
+
end
|
1549
|
+
|
1550
|
+
def undo_last_operation # {{{3
|
1551
|
+
return unless @undo_enabled
|
1552
|
+
|
1553
|
+
if @undo_history.empty?
|
1554
|
+
@pB.say("No operations to undo".fg(196))
|
1555
|
+
return
|
1556
|
+
end
|
1557
|
+
|
1558
|
+
operation = @undo_history.pop
|
1559
|
+
|
1560
|
+
begin
|
1561
|
+
case operation[:type]
|
1562
|
+
when 'delete'
|
1563
|
+
undo_delete(operation)
|
1564
|
+
when 'move'
|
1565
|
+
undo_move(operation)
|
1566
|
+
when 'rename'
|
1567
|
+
undo_rename(operation)
|
1568
|
+
when 'copy'
|
1569
|
+
undo_copy(operation)
|
1570
|
+
when 'link'
|
1571
|
+
undo_link(operation)
|
1572
|
+
when 'bulk_rename'
|
1573
|
+
undo_bulk_rename(operation)
|
1574
|
+
else
|
1575
|
+
@pB.say("Unknown operation type: #{operation[:type]}".fg(196))
|
1576
|
+
return
|
1577
|
+
end
|
1578
|
+
|
1579
|
+
dirlist
|
1580
|
+
render
|
1581
|
+
@pB.say("Undid #{operation[:type]} operation".fg(156))
|
1582
|
+
rescue StandardError => e
|
1583
|
+
@pB.say("Undo failed: #{e.message}".fg(196))
|
1584
|
+
# Don't re-add the operation to history if undo failed
|
1585
|
+
end
|
1586
|
+
end
|
1587
|
+
|
1588
|
+
def undo_delete(operation) # {{{3
|
1589
|
+
if operation[:trash]
|
1590
|
+
# Restore from trash
|
1591
|
+
operation[:paths].each do |path_info|
|
1592
|
+
source = File.join(TRASH_DIR, File.basename(path_info[:path]))
|
1593
|
+
dest = path_info[:path]
|
1594
|
+
|
1595
|
+
if File.exist?(source)
|
1596
|
+
FileUtils.mv(source, dest)
|
1597
|
+
else
|
1598
|
+
raise "Cannot restore #{path_info[:path]}: not found in trash"
|
1599
|
+
end
|
1600
|
+
end
|
1601
|
+
else
|
1602
|
+
raise "Cannot undo permanent deletion"
|
1603
|
+
end
|
1604
|
+
end
|
1605
|
+
|
1606
|
+
def undo_move(operation) # {{{3
|
1607
|
+
# Move items back to their original locations
|
1608
|
+
operation[:moves].each do |move_info|
|
1609
|
+
source = move_info[:dest_path]
|
1610
|
+
dest = move_info[:source_path]
|
1611
|
+
|
1612
|
+
if File.exist?(source)
|
1613
|
+
FileUtils.mv(source, dest)
|
1614
|
+
else
|
1615
|
+
raise "Cannot undo move: #{source} not found"
|
1616
|
+
end
|
1617
|
+
end
|
1618
|
+
end
|
1619
|
+
|
1620
|
+
def undo_rename(operation) # {{{3
|
1621
|
+
old_path = operation[:old_path]
|
1622
|
+
new_path = operation[:new_path]
|
1623
|
+
|
1624
|
+
if File.exist?(new_path)
|
1625
|
+
FileUtils.mv(new_path, old_path)
|
1626
|
+
else
|
1627
|
+
raise "Cannot undo rename: #{new_path} not found"
|
1628
|
+
end
|
1629
|
+
end
|
1630
|
+
|
1631
|
+
def undo_copy(operation) # {{{3
|
1632
|
+
# Remove copied files
|
1633
|
+
operation[:copies].each do |copy_info|
|
1634
|
+
dest = copy_info[:dest_path]
|
1635
|
+
|
1636
|
+
if File.exist?(dest)
|
1637
|
+
if File.directory?(dest)
|
1638
|
+
FileUtils.rm_rf(dest)
|
1639
|
+
else
|
1640
|
+
FileUtils.rm(dest)
|
1641
|
+
end
|
1642
|
+
end
|
1643
|
+
end
|
1644
|
+
end
|
1645
|
+
|
1646
|
+
def undo_link(operation) # {{{3
|
1647
|
+
# Remove symlinks
|
1648
|
+
operation[:links].each do |link_info|
|
1649
|
+
dest = link_info[:dest_path]
|
1650
|
+
|
1651
|
+
if File.symlink?(dest)
|
1652
|
+
FileUtils.rm(dest)
|
1653
|
+
elsif File.exist?(dest)
|
1654
|
+
raise "Cannot undo link: #{dest} is not a symlink"
|
1655
|
+
end
|
1656
|
+
end
|
1657
|
+
end
|
1658
|
+
|
1659
|
+
def undo_bulk_rename(operation) # {{{3
|
1660
|
+
# Undo bulk rename operations by reversing each rename
|
1661
|
+
operation[:renames].reverse.each do |rename_info|
|
1662
|
+
old_path = rename_info[:old_path]
|
1663
|
+
new_path = rename_info[:new_path]
|
1664
|
+
|
1665
|
+
if File.exist?(new_path)
|
1666
|
+
FileUtils.mv(new_path, old_path)
|
1667
|
+
else
|
1668
|
+
raise "Cannot undo bulk rename: #{new_path} not found"
|
1669
|
+
end
|
1670
|
+
end
|
1671
|
+
end
|
1672
|
+
|
1673
|
+
# RECENTLY ACCESSED FILES {{{2
|
1674
|
+
def track_file_access(file_path) # {{{3
|
1675
|
+
return unless File.exist?(file_path)
|
1676
|
+
|
1677
|
+
abs_path = File.expand_path(file_path)
|
1678
|
+
@recent_files.delete(abs_path) # Remove if already exists
|
1679
|
+
@recent_files.unshift(abs_path) # Add to front
|
1680
|
+
@recent_files = @recent_files.first(@max_recent_files) # Limit size
|
1681
|
+
end
|
1682
|
+
|
1683
|
+
def track_directory_access(dir_path) # {{{3
|
1684
|
+
return unless File.directory?(dir_path)
|
1685
|
+
|
1686
|
+
abs_path = File.expand_path(dir_path)
|
1687
|
+
@recent_dirs.delete(abs_path) # Remove if already exists
|
1688
|
+
@recent_dirs.unshift(abs_path) # Add to front
|
1689
|
+
@recent_dirs = @recent_dirs.first(@max_recent_dirs) # Limit size
|
1690
|
+
end
|
1691
|
+
|
1692
|
+
def show_recent_files # {{{3
|
1693
|
+
text = "Recently Accessed Files and Directories\n".b.fg(156)
|
1694
|
+
text << "=" * 40 + "\n\n"
|
1695
|
+
|
1696
|
+
unless @recent_files.empty?
|
1697
|
+
text << "Files:\n".fg(226)
|
1698
|
+
@recent_files.first(15).each_with_index do |file, i|
|
1699
|
+
basename = File.basename(file)
|
1700
|
+
dirname = File.dirname(file)
|
1701
|
+
mtime = File.exist?(file) ? File.mtime(file).strftime("%Y-%m-%d %H:%M") : "MISSING"
|
1702
|
+
text << sprintf("%2d. %-30s %s\n", i + 1, basename.fg(156), "#{dirname} (#{mtime})".fg(240))
|
1703
|
+
end
|
1704
|
+
text << "\n"
|
1705
|
+
end
|
1706
|
+
|
1707
|
+
unless @recent_dirs.empty?
|
1708
|
+
text << "Directories:\n".fg(226)
|
1709
|
+
@recent_dirs.first(10).each_with_index do |dir, i|
|
1710
|
+
basename = File.basename(dir)
|
1711
|
+
parent = File.dirname(dir)
|
1712
|
+
exists = File.exist?(dir) ? "✓".fg(156) : "✗".fg(196)
|
1713
|
+
text << sprintf("%2d. %s %-25s %s\n", i + 1, exists, basename.fg(156), parent.fg(240))
|
1714
|
+
end
|
1715
|
+
end
|
1716
|
+
|
1717
|
+
if @recent_files.empty? && @recent_dirs.empty?
|
1718
|
+
text << "No recently accessed files or directories.\n".fg(240)
|
1719
|
+
text << "Files and directories will appear here as you use RTFM.\n".fg(240)
|
1720
|
+
else
|
1721
|
+
text << "\nPress number to jump to item, or any other key to close.".fg(240)
|
1722
|
+
end
|
1723
|
+
|
1724
|
+
@pR.say(text)
|
1725
|
+
@pR.update = false
|
1726
|
+
|
1727
|
+
# Handle selection
|
1728
|
+
chr = getchr
|
1729
|
+
if chr =~ /\d/
|
1730
|
+
num = chr.to_i
|
1731
|
+
if num > 0 && num <= @recent_files.length
|
1732
|
+
# Jump to recent file
|
1733
|
+
target = @recent_files[num - 1]
|
1734
|
+
if File.exist?(target)
|
1735
|
+
Dir.chdir(File.dirname(target))
|
1736
|
+
dirlist
|
1737
|
+
# Select the file in the list
|
1738
|
+
basename = File.basename(target)
|
1739
|
+
@index = @files.index(basename) || 0
|
1740
|
+
@selected = target
|
1741
|
+
render
|
1742
|
+
else
|
1743
|
+
@pB.say("File no longer exists: #{target}".fg(196))
|
1744
|
+
end
|
1745
|
+
elsif num > 0 && num <= @recent_dirs.length + @recent_files.length
|
1746
|
+
# Jump to recent directory
|
1747
|
+
dir_index = num - @recent_files.length - 1
|
1748
|
+
if dir_index >= 0 && dir_index < @recent_dirs.length
|
1749
|
+
target = @recent_dirs[dir_index]
|
1750
|
+
if File.directory?(target)
|
1751
|
+
Dir.chdir(target)
|
1752
|
+
dirlist
|
1753
|
+
render
|
1754
|
+
else
|
1755
|
+
@pB.say("Directory no longer exists: #{target}".fg(196))
|
1756
|
+
end
|
1757
|
+
end
|
1758
|
+
end
|
1759
|
+
end
|
1760
|
+
|
1761
|
+
@pR.update = true
|
1762
|
+
end
|
1763
|
+
|
1764
|
+
# FILE PROPERTIES {{{2
|
1765
|
+
def show_file_properties # {{{3
|
1766
|
+
return unless @selected && File.exist?(@selected)
|
1767
|
+
|
1768
|
+
begin
|
1769
|
+
stat = File.stat(@selected)
|
1770
|
+
text = "File Properties: #{File.basename(@selected)}\n".b.fg(156)
|
1771
|
+
text << "=" * 50 + "\n\n"
|
1772
|
+
|
1773
|
+
# Basic information
|
1774
|
+
text << "Basic Information:\n".fg(226)
|
1775
|
+
text << sprintf(" %-20s %s\n", "Full Path:", @selected.fg(156))
|
1776
|
+
text << sprintf(" %-20s %s\n", "Directory:", File.dirname(@selected).fg(240))
|
1777
|
+
text << sprintf(" %-20s %s\n", "Size:", format_file_size(stat.size))
|
1778
|
+
text << sprintf(" %-20s %s\n", "Type:", File.ftype(@selected).capitalize.fg(156))
|
1779
|
+
|
1780
|
+
# MIME type
|
1781
|
+
begin
|
1782
|
+
mime_output = `file --mime-type #{Shellwords.escape(@selected)} 2>/dev/null`.strip
|
1783
|
+
mime_type = mime_output.split(':')[1]&.strip || "Unknown"
|
1784
|
+
text << sprintf(" %-20s %s\n", "MIME Type:", mime_type.fg(156))
|
1785
|
+
rescue
|
1786
|
+
text << sprintf(" %-20s %s\n", "MIME Type:", "Unknown".fg(240))
|
1787
|
+
end
|
1788
|
+
|
1789
|
+
text << "\n"
|
1790
|
+
|
1791
|
+
# Permissions and ownership
|
1792
|
+
text << "Permissions & Ownership:\n".fg(226)
|
1793
|
+
mode_oct = sprintf("%04o", stat.mode & 0777)
|
1794
|
+
mode_str = File.world_readable?(@selected) ?
|
1795
|
+
sprintf("%s (readable)", mode_oct) :
|
1796
|
+
sprintf("%s (protected)", mode_oct)
|
1797
|
+
text << sprintf(" %-20s %s\n", "Permissions:", mode_str.fg(156))
|
1798
|
+
|
1799
|
+
begin
|
1800
|
+
require 'etc'
|
1801
|
+
owner = Etc.getpwuid(stat.uid).name rescue stat.uid.to_s
|
1802
|
+
group = Etc.getgrgid(stat.gid).name rescue stat.gid.to_s
|
1803
|
+
text << sprintf(" %-20s %s\n", "Owner:Group:", "#{owner}:#{group}".fg(156))
|
1804
|
+
rescue
|
1805
|
+
text << sprintf(" %-20s %s\n", "Owner:Group:", "#{stat.uid}:#{stat.gid}".fg(156))
|
1806
|
+
end
|
1807
|
+
|
1808
|
+
text << "\n"
|
1809
|
+
|
1810
|
+
# Timestamps
|
1811
|
+
text << "Timestamps:\n".fg(226)
|
1812
|
+
text << sprintf(" %-20s %s\n", "Created:", stat.ctime.strftime("%Y-%m-%d %H:%M:%S").fg(156))
|
1813
|
+
text << sprintf(" %-20s %s\n", "Modified:", stat.mtime.strftime("%Y-%m-%d %H:%M:%S").fg(156))
|
1814
|
+
text << sprintf(" %-20s %s\n", "Accessed:", stat.atime.strftime("%Y-%m-%d %H:%M:%S").fg(156))
|
1815
|
+
|
1816
|
+
# Symlink information
|
1817
|
+
if File.symlink?(@selected)
|
1818
|
+
text << "\n"
|
1819
|
+
text << "Symlink Information:\n".fg(226)
|
1820
|
+
begin
|
1821
|
+
target = File.readlink(@selected)
|
1822
|
+
text << sprintf(" %-20s %s\n", "Points to:", target.fg(156))
|
1823
|
+
text << sprintf(" %-20s %s\n", "Target exists:", File.exist?(target) ? "Yes".fg(156) : "No".fg(196))
|
1824
|
+
rescue
|
1825
|
+
text << sprintf(" %-20s %s\n", "Target:", "Cannot read link".fg(196))
|
1826
|
+
end
|
1827
|
+
end
|
1828
|
+
|
1829
|
+
# Directory-specific information
|
1830
|
+
if File.directory?(@selected)
|
1831
|
+
text << "\n"
|
1832
|
+
text << "Directory Information:\n".fg(226)
|
1833
|
+
begin
|
1834
|
+
entries = Dir.entries(@selected).reject { |e| e == '.' || e == '..' }
|
1835
|
+
text << sprintf(" %-20s %d\n", "Total entries:", entries.length)
|
1836
|
+
dirs = entries.select { |e| File.directory?(File.join(@selected, e)) }
|
1837
|
+
files = entries.select { |e| File.file?(File.join(@selected, e)) }
|
1838
|
+
text << sprintf(" %-20s %s\n", "Breakdown:", "#{dirs.length} directories, #{files.length} files")
|
1839
|
+
rescue
|
1840
|
+
text << sprintf(" %-20s %s\n", "Contents:", "Cannot read directory".fg(196))
|
1841
|
+
end
|
1842
|
+
end
|
1843
|
+
|
1844
|
+
# File-specific information
|
1845
|
+
if File.file?(@selected)
|
1846
|
+
text << "\n"
|
1847
|
+
text << "File Information:\n".fg(226)
|
1848
|
+
|
1849
|
+
# Try to get checksum for regular files
|
1850
|
+
if stat.size < 100 * 1024 * 1024 # Only for files under 100MB
|
1851
|
+
begin
|
1852
|
+
require 'digest'
|
1853
|
+
checksum = Digest::SHA256.file(@selected).hexdigest[0, 16]
|
1854
|
+
text << sprintf(" %-20s %s...\n", "SHA256 (partial):", checksum.fg(156))
|
1855
|
+
rescue
|
1856
|
+
text << sprintf(" %-20s %s\n", "Checksum:", "Cannot calculate".fg(240))
|
1857
|
+
end
|
1858
|
+
else
|
1859
|
+
text << sprintf(" %-20s %s\n", "Checksum:", "File too large".fg(240))
|
1860
|
+
end
|
1861
|
+
|
1862
|
+
# Check if binary or text
|
1863
|
+
begin
|
1864
|
+
File.open(@selected, 'rb') do |f|
|
1865
|
+
chunk = f.read(1024)
|
1866
|
+
is_binary = chunk && chunk.encoding == Encoding::ASCII_8BIT &&
|
1867
|
+
(chunk.bytes.any? { |b| b < 32 && ![9, 10, 13].include?(b) })
|
1868
|
+
text << sprintf(" %-20s %s\n", "Content type:", is_binary ? "Binary".fg(196) : "Text".fg(156))
|
1869
|
+
end
|
1870
|
+
rescue
|
1871
|
+
text << sprintf(" %-20s %s\n", "Content type:", "Unknown".fg(240))
|
1872
|
+
end
|
1873
|
+
end
|
1874
|
+
|
1875
|
+
text << "\n"
|
1876
|
+
text << "Press any key to close...".fg(240)
|
1877
|
+
|
1878
|
+
@pR.say(text)
|
1879
|
+
@pR.update = false
|
1880
|
+
getchr
|
1881
|
+
@pR.update = true
|
1882
|
+
|
1883
|
+
rescue StandardError => e
|
1884
|
+
@pB.say("Error getting file properties: #{e.message}".fg(196))
|
1885
|
+
end
|
1886
|
+
end
|
1887
|
+
|
1888
|
+
def format_file_size(bytes) # {{{3
|
1889
|
+
units = ['B', 'KB', 'MB', 'GB', 'TB']
|
1890
|
+
size = bytes.to_f
|
1891
|
+
unit_index = 0
|
1892
|
+
|
1893
|
+
while size >= 1024 && unit_index < units.length - 1
|
1894
|
+
size /= 1024.0
|
1895
|
+
unit_index += 1
|
1896
|
+
end
|
1897
|
+
|
1898
|
+
if unit_index == 0
|
1899
|
+
"#{size.to_i} #{units[unit_index]}".fg(156)
|
1900
|
+
else
|
1901
|
+
"#{sprintf('%.1f', size)} #{units[unit_index]}".fg(156)
|
1902
|
+
end
|
1903
|
+
end
|
1904
|
+
|
1905
|
+
# BULK RENAME {{{2
|
1906
|
+
def bulk_rename # {{{3
|
1907
|
+
if @tagged.empty?
|
1908
|
+
@pB.say("No files tagged for bulk rename. Tag files first with 't'".fg(196))
|
1909
|
+
return
|
1910
|
+
end
|
1911
|
+
|
1912
|
+
@pB.say("Bulk rename pattern: ".fg(156))
|
1913
|
+
@pR.say(build_pattern_help)
|
1914
|
+
@pR.update = false
|
1915
|
+
|
1916
|
+
pattern = @pCmd.ask('Pattern: ', '')
|
1917
|
+
return if pattern.nil? || pattern.strip.empty?
|
1918
|
+
|
1919
|
+
# Parse and preview renames
|
1920
|
+
rename_operations = []
|
1921
|
+
errors = []
|
1922
|
+
|
1923
|
+
@tagged.each do |file|
|
1924
|
+
next unless File.exist?(file)
|
1925
|
+
|
1926
|
+
old_name = File.basename(file)
|
1927
|
+
new_name = apply_rename_pattern(old_name, pattern)
|
1928
|
+
|
1929
|
+
if new_name && new_name != old_name
|
1930
|
+
old_path = file
|
1931
|
+
new_path = File.join(File.dirname(file), new_name)
|
1932
|
+
|
1933
|
+
if File.exist?(new_path) && new_path != old_path
|
1934
|
+
errors << "#{old_name} -> #{new_name} (target exists)"
|
1935
|
+
else
|
1936
|
+
rename_operations << {
|
1937
|
+
old_path: old_path,
|
1938
|
+
new_path: new_path,
|
1939
|
+
old_name: old_name,
|
1940
|
+
new_name: new_name
|
1941
|
+
}
|
1942
|
+
end
|
1943
|
+
end
|
1944
|
+
end
|
1945
|
+
|
1946
|
+
# Show preview
|
1947
|
+
show_rename_preview(rename_operations, errors, pattern)
|
1948
|
+
|
1949
|
+
if rename_operations.empty?
|
1950
|
+
@pB.say("No valid renames to perform".fg(196))
|
1951
|
+
return
|
1952
|
+
end
|
1953
|
+
|
1954
|
+
# Confirm and execute
|
1955
|
+
@pB.say("Apply #{rename_operations.length} renames? (y/N): ".fg(226))
|
1956
|
+
|
1957
|
+
if getchr.downcase == 'y'
|
1958
|
+
successful_renames = []
|
1959
|
+
failed_renames = []
|
1960
|
+
|
1961
|
+
rename_operations.each do |op|
|
1962
|
+
begin
|
1963
|
+
FileUtils.mv(op[:old_path], op[:new_path])
|
1964
|
+
successful_renames << op
|
1965
|
+
rescue StandardError => e
|
1966
|
+
failed_renames << { operation: op, error: e.message }
|
1967
|
+
end
|
1968
|
+
end
|
1969
|
+
|
1970
|
+
# Record undo information for successful renames
|
1971
|
+
unless successful_renames.empty?
|
1972
|
+
add_undo_operation({
|
1973
|
+
type: 'bulk_rename',
|
1974
|
+
renames: successful_renames,
|
1975
|
+
timestamp: Time.now
|
1976
|
+
})
|
1977
|
+
end
|
1978
|
+
|
1979
|
+
# Show results
|
1980
|
+
result_msg = "Renamed #{successful_renames.length} files"
|
1981
|
+
result_msg += ", #{failed_renames.length} failed" unless failed_renames.empty?
|
1982
|
+
@pB.say(result_msg.fg(successful_renames.empty? ? 196 : 156))
|
1983
|
+
|
1984
|
+
# Update file listing and clear tags
|
1985
|
+
@tagged.clear
|
1986
|
+
dirlist
|
1987
|
+
render
|
1988
|
+
else
|
1989
|
+
@pB.say("Bulk rename cancelled".fg(240))
|
1990
|
+
end
|
1991
|
+
|
1992
|
+
@pR.update = true
|
1993
|
+
end
|
1994
|
+
|
1995
|
+
def apply_rename_pattern(old_name, pattern) # {{{3
|
1996
|
+
case pattern
|
1997
|
+
when /^s\/(.+?)\/(.+?)\/([gimx]*)$/
|
1998
|
+
# Regex substitution: s/old/new/flags
|
1999
|
+
regex_pattern, replacement, flags = $1, $2, $3
|
2000
|
+
options = 0
|
2001
|
+
options |= Regexp::IGNORECASE if flags.include?('i')
|
2002
|
+
options |= Regexp::MULTILINE if flags.include?('m')
|
2003
|
+
options |= Regexp::EXTENDED if flags.include?('x')
|
2004
|
+
|
2005
|
+
begin
|
2006
|
+
regex = Regexp.new(regex_pattern, options)
|
2007
|
+
if flags.include?('g')
|
2008
|
+
old_name.gsub(regex, replacement)
|
2009
|
+
else
|
2010
|
+
old_name.sub(regex, replacement)
|
2011
|
+
end
|
2012
|
+
rescue StandardError
|
2013
|
+
nil # Invalid regex
|
2014
|
+
end
|
2015
|
+
|
2016
|
+
when /^\*(.+)$/
|
2017
|
+
# Append suffix: *_backup
|
2018
|
+
suffix = $1
|
2019
|
+
base = File.basename(old_name, '.*')
|
2020
|
+
ext = File.extname(old_name)
|
2021
|
+
"#{base}#{suffix}#{ext}"
|
2022
|
+
|
2023
|
+
when /^(.+)\*$/
|
2024
|
+
# Prepend prefix: backup_*
|
2025
|
+
prefix = $1
|
2026
|
+
"#{prefix}#{old_name}"
|
2027
|
+
|
2028
|
+
when /^(.+)\*(.+)$/
|
2029
|
+
# Prefix and suffix: backup_*_old
|
2030
|
+
prefix, suffix = $1, $2
|
2031
|
+
base = File.basename(old_name, '.*')
|
2032
|
+
ext = File.extname(old_name)
|
2033
|
+
"#{prefix}#{base}#{suffix}#{ext}"
|
2034
|
+
|
2035
|
+
when /^upper$/i
|
2036
|
+
# Convert to uppercase
|
2037
|
+
old_name.upcase
|
2038
|
+
|
2039
|
+
when /^lower$/i
|
2040
|
+
# Convert to lowercase
|
2041
|
+
old_name.downcase
|
2042
|
+
|
2043
|
+
when /^title$/i
|
2044
|
+
# Convert to title case
|
2045
|
+
old_name.split(/[-_\s]/).map(&:capitalize).join('_')
|
2046
|
+
|
2047
|
+
when /^\*\.(.+)$/
|
2048
|
+
# Change extension: *.txt
|
2049
|
+
new_ext = $1
|
2050
|
+
base = File.basename(old_name, '.*')
|
2051
|
+
"#{base}.#{new_ext}"
|
2052
|
+
|
2053
|
+
when /^(\d+)-(\d+)$/
|
2054
|
+
# Sequential numbering: 1-100 (start-end)
|
2055
|
+
# This will be handled by the caller with an index
|
2056
|
+
nil
|
2057
|
+
|
2058
|
+
else
|
2059
|
+
# Direct replacement
|
2060
|
+
pattern
|
2061
|
+
end
|
2062
|
+
end
|
2063
|
+
|
2064
|
+
def build_pattern_help # {{{3
|
2065
|
+
<<~HELP
|
2066
|
+
Bulk Rename Patterns:
|
2067
|
+
|
2068
|
+
Regex substitution:
|
2069
|
+
s/old/new/ - Replace first occurrence
|
2070
|
+
s/old/new/g - Replace all occurrences
|
2071
|
+
s/old/new/i - Case insensitive
|
2072
|
+
|
2073
|
+
Template patterns:
|
2074
|
+
prefix_* - Add prefix: "backup_filename.txt"
|
2075
|
+
*_suffix - Add suffix: "filename_backup.txt"
|
2076
|
+
prefix_*_suffix - Both: "backup_filename_old.txt"
|
2077
|
+
|
2078
|
+
Extension change:
|
2079
|
+
*.txt - Change all extensions to .txt
|
2080
|
+
|
2081
|
+
Case conversion:
|
2082
|
+
upper - Convert to UPPERCASE
|
2083
|
+
lower - convert to lowercase
|
2084
|
+
title - Convert To Title_Case
|
2085
|
+
|
2086
|
+
Examples:
|
2087
|
+
s/IMG/Photo/g - Replace "IMG" with "Photo"
|
2088
|
+
backup_* - Add "backup_" prefix
|
2089
|
+
*_old - Add "_old" suffix
|
2090
|
+
*.backup - Change extension to .backup
|
2091
|
+
lower - Convert to lowercase
|
2092
|
+
HELP
|
2093
|
+
end
|
2094
|
+
|
2095
|
+
def show_rename_preview(operations, errors, pattern) # {{{3
|
2096
|
+
text = "Bulk Rename Preview: #{pattern}\n".b.fg(156)
|
2097
|
+
text << "=" * 50 + "\n\n"
|
2098
|
+
|
2099
|
+
unless operations.empty?
|
2100
|
+
text << "Successful renames (#{operations.length}):\n".fg(156)
|
2101
|
+
operations.first(10).each do |op|
|
2102
|
+
text << " #{op[:old_name].fg(240)} -> #{op[:new_name].fg(156)}\n"
|
2103
|
+
end
|
2104
|
+
|
2105
|
+
if operations.length > 10
|
2106
|
+
text << " ... and #{operations.length - 10} more\n".fg(240)
|
2107
|
+
end
|
2108
|
+
text << "\n"
|
2109
|
+
end
|
2110
|
+
|
2111
|
+
unless errors.empty?
|
2112
|
+
text << "Errors (#{errors.length}):\n".fg(196)
|
2113
|
+
errors.first(5).each do |error|
|
2114
|
+
text << " #{error}\n".fg(196)
|
2115
|
+
end
|
2116
|
+
|
2117
|
+
if errors.length > 5
|
2118
|
+
text << " ... and #{errors.length - 5} more errors\n".fg(240)
|
2119
|
+
end
|
2120
|
+
text << "\n"
|
2121
|
+
end
|
2122
|
+
|
2123
|
+
if operations.empty? && errors.empty?
|
2124
|
+
text << "No changes would be made with this pattern.\n".fg(240)
|
2125
|
+
end
|
2126
|
+
|
2127
|
+
@pR.say(text)
|
2128
|
+
end
|
2129
|
+
|
2130
|
+
# FILE COMPARISON {{{2
|
2131
|
+
def compare_files # {{{3
|
2132
|
+
if @tagged.length == 0
|
2133
|
+
@pB.say("No files tagged for comparison. Tag 2 files with 't'".fg(196))
|
2134
|
+
return
|
2135
|
+
elsif @tagged.length == 1
|
2136
|
+
@pB.say("Only 1 file tagged. Tag a second file to compare with #{File.basename(@tagged[0])}".fg(196))
|
2137
|
+
return
|
2138
|
+
elsif @tagged.length > 2
|
2139
|
+
@pB.say("Too many files tagged (#{@tagged.length}). Tag exactly 2 files for comparison.".fg(196))
|
2140
|
+
return
|
2141
|
+
end
|
2142
|
+
|
2143
|
+
file1, file2 = @tagged
|
2144
|
+
|
2145
|
+
# Validate files exist
|
2146
|
+
unless File.exist?(file1) && File.exist?(file2)
|
2147
|
+
@pB.say("One or both tagged files no longer exist".fg(196))
|
2148
|
+
return
|
2149
|
+
end
|
2150
|
+
|
2151
|
+
# Show comparison
|
2152
|
+
show_file_comparison(file1, file2)
|
2153
|
+
end
|
2154
|
+
|
2155
|
+
def show_file_comparison(file1, file2) # {{{3
|
2156
|
+
basename1 = File.basename(file1)
|
2157
|
+
basename2 = File.basename(file2)
|
2158
|
+
|
2159
|
+
text = "File Comparison\n".b.fg(156)
|
2160
|
+
text << "=" * 50 + "\n\n"
|
2161
|
+
text << sprintf("%-25s vs %s\n", basename1.fg(156), basename2.fg(156))
|
2162
|
+
text << "=" * 50 + "\n\n"
|
2163
|
+
|
2164
|
+
# Basic file info comparison
|
2165
|
+
stat1 = File.stat(file1)
|
2166
|
+
stat2 = File.stat(file2)
|
2167
|
+
|
2168
|
+
text << "File Information:\n".fg(226)
|
2169
|
+
text << sprintf(" %-20s %-25s %s\n", "Size:", format_size_simple(stat1.size), format_size_simple(stat2.size))
|
2170
|
+
text << sprintf(" %-20s %-25s %s\n", "Modified:", stat1.mtime.strftime("%Y-%m-%d %H:%M"), stat2.mtime.strftime("%Y-%m-%d %H:%M"))
|
2171
|
+
text << sprintf(" %-20s %-25s %s\n", "Type:", File.ftype(file1), File.ftype(file2))
|
2172
|
+
|
2173
|
+
# Check if files are identical
|
2174
|
+
if stat1.size == stat2.size && files_identical?(file1, file2)
|
2175
|
+
text << "\n"
|
2176
|
+
text << "Files are identical! ✓".fg(156).b
|
2177
|
+
@pR.say(text)
|
2178
|
+
@pR.update = false
|
2179
|
+
getchr
|
2180
|
+
@pR.update = true
|
2181
|
+
return
|
2182
|
+
end
|
2183
|
+
|
2184
|
+
text << "\n"
|
2185
|
+
|
2186
|
+
# Determine comparison type
|
2187
|
+
if binary_file?(file1) || binary_file?(file2)
|
2188
|
+
text << show_binary_comparison(file1, file2, stat1, stat2)
|
2189
|
+
else
|
2190
|
+
text << show_text_comparison(file1, file2)
|
2191
|
+
end
|
2192
|
+
|
2193
|
+
text << "\n"
|
2194
|
+
text << "Press any key to close...".fg(240)
|
2195
|
+
|
2196
|
+
@pR.say(text)
|
2197
|
+
@pR.update = false
|
2198
|
+
getchr
|
2199
|
+
@pR.update = true
|
2200
|
+
end
|
2201
|
+
|
2202
|
+
def show_binary_comparison(file1, file2, stat1, stat2) # {{{3
|
2203
|
+
text = "Binary File Comparison:\n".fg(226)
|
2204
|
+
|
2205
|
+
# Size comparison
|
2206
|
+
size_diff = stat2.size - stat1.size
|
2207
|
+
if size_diff == 0
|
2208
|
+
text << " Same size: #{format_size_simple(stat1.size)}\n".fg(156)
|
2209
|
+
else
|
2210
|
+
sign = size_diff > 0 ? "+" : ""
|
2211
|
+
text << " Size difference: #{sign}#{format_size_simple(size_diff.abs)}\n".fg(size_diff > 0 ? 196 : 156)
|
2212
|
+
end
|
2213
|
+
|
2214
|
+
# Checksum comparison
|
2215
|
+
text << "\n Computing checksums...\n".fg(240)
|
2216
|
+
|
2217
|
+
begin
|
2218
|
+
require 'digest'
|
2219
|
+
hash1 = Digest::SHA256.file(file1).hexdigest
|
2220
|
+
hash2 = Digest::SHA256.file(file2).hexdigest
|
2221
|
+
|
2222
|
+
if hash1 == hash2
|
2223
|
+
text << " SHA256: Identical ✓\n".fg(156)
|
2224
|
+
else
|
2225
|
+
text << " SHA256: Different ✗\n".fg(196)
|
2226
|
+
text << " File 1: #{hash1[0, 16]}...\n".fg(240)
|
2227
|
+
text << " File 2: #{hash2[0, 16]}...\n".fg(240)
|
2228
|
+
end
|
2229
|
+
rescue StandardError => e
|
2230
|
+
text << " Checksum: Error - #{e.message}\n".fg(196)
|
2231
|
+
end
|
2232
|
+
|
2233
|
+
# File type analysis
|
2234
|
+
begin
|
2235
|
+
type1 = `file #{Shellwords.escape(file1)} 2>/dev/null`.strip
|
2236
|
+
type2 = `file #{Shellwords.escape(file2)} 2>/dev/null`.strip
|
2237
|
+
|
2238
|
+
text << "\n File types:\n".fg(226)
|
2239
|
+
text << " #{File.basename(file1)}: #{type1.split(':')[1]&.strip || 'Unknown'}\n".fg(240)
|
2240
|
+
text << " #{File.basename(file2)}: #{type2.split(':')[1]&.strip || 'Unknown'}\n".fg(240)
|
2241
|
+
rescue
|
2242
|
+
# Ignore file type detection errors
|
2243
|
+
end
|
2244
|
+
|
2245
|
+
text
|
2246
|
+
end
|
2247
|
+
|
2248
|
+
def show_text_comparison(file1, file2) # {{{3
|
2249
|
+
text = "Text File Comparison:\n".fg(226)
|
2250
|
+
|
2251
|
+
begin
|
2252
|
+
lines1 = File.readlines(file1, chomp: true)
|
2253
|
+
lines2 = File.readlines(file2, chomp: true)
|
2254
|
+
rescue StandardError => e
|
2255
|
+
return "Error reading files: #{e.message}\n".fg(196)
|
2256
|
+
end
|
2257
|
+
|
2258
|
+
# Line count comparison
|
2259
|
+
if lines1.length == lines2.length
|
2260
|
+
text << " Same line count: #{lines1.length}\n".fg(156)
|
2261
|
+
else
|
2262
|
+
diff = lines2.length - lines1.length
|
2263
|
+
sign = diff > 0 ? "+" : ""
|
2264
|
+
text << " Line count: #{lines1.length} vs #{lines2.length} (#{sign}#{diff})\n".fg(diff > 0 ? 196 : 156)
|
2265
|
+
end
|
2266
|
+
|
2267
|
+
# Generate and show diff
|
2268
|
+
diff_lines = generate_unified_diff(lines1, lines2, File.basename(file1), File.basename(file2))
|
2269
|
+
|
2270
|
+
if diff_lines.empty?
|
2271
|
+
text << " Content: Identical ✓\n".fg(156)
|
2272
|
+
else
|
2273
|
+
text << "\n Differences (unified diff):\n".fg(226)
|
2274
|
+
|
2275
|
+
# Show first 15 lines of diff
|
2276
|
+
diff_lines.first(15).each do |line|
|
2277
|
+
color = case line[0]
|
2278
|
+
when '+' then 156 # Green for additions
|
2279
|
+
when '-' then 196 # Red for deletions
|
2280
|
+
when '@' then 226 # Yellow for headers
|
2281
|
+
else 240 # Gray for context
|
2282
|
+
end
|
2283
|
+
text << " #{line}\n".fg(color)
|
2284
|
+
end
|
2285
|
+
|
2286
|
+
if diff_lines.length > 15
|
2287
|
+
text << " ... and #{diff_lines.length - 15} more lines\n".fg(240)
|
2288
|
+
end
|
2289
|
+
end
|
2290
|
+
|
2291
|
+
text
|
2292
|
+
end
|
2293
|
+
|
2294
|
+
def generate_unified_diff(lines1, lines2, name1, name2) # {{{3
|
2295
|
+
# Simple unified diff implementation
|
2296
|
+
diff_lines = []
|
2297
|
+
|
2298
|
+
# Find differences using basic LCS-like approach
|
2299
|
+
i1 = i2 = 0
|
2300
|
+
context_size = 3
|
2301
|
+
|
2302
|
+
while i1 < lines1.length || i2 < lines2.length
|
2303
|
+
if i1 < lines1.length && i2 < lines2.length && lines1[i1] == lines2[i2]
|
2304
|
+
# Lines match, move forward
|
2305
|
+
i1 += 1
|
2306
|
+
i2 += 1
|
2307
|
+
else
|
2308
|
+
# Found a difference, create a hunk
|
2309
|
+
hunk_start1, hunk_start2 = i1, i2
|
2310
|
+
|
2311
|
+
# Find the end of differences
|
2312
|
+
temp_i1, temp_i2 = i1, i2
|
2313
|
+
while temp_i1 < lines1.length || temp_i2 < lines2.length
|
2314
|
+
if temp_i1 < lines1.length && temp_i2 < lines2.length && lines1[temp_i1] == lines2[temp_i2]
|
2315
|
+
break
|
2316
|
+
end
|
2317
|
+
temp_i1 += 1 if temp_i1 < lines1.length
|
2318
|
+
temp_i2 += 1 if temp_i2 < lines2.length
|
2319
|
+
end
|
2320
|
+
|
2321
|
+
# Add hunk header
|
2322
|
+
diff_lines << "@@ -#{hunk_start1 + 1},#{temp_i1 - hunk_start1} +#{hunk_start2 + 1},#{temp_i2 - hunk_start2} @@"
|
2323
|
+
|
2324
|
+
# Add removed lines
|
2325
|
+
(hunk_start1...temp_i1).each do |idx|
|
2326
|
+
diff_lines << "-#{lines1[idx]}" if idx < lines1.length
|
2327
|
+
end
|
2328
|
+
|
2329
|
+
# Add added lines
|
2330
|
+
(hunk_start2...temp_i2).each do |idx|
|
2331
|
+
diff_lines << "+#{lines2[idx]}" if idx < lines2.length
|
2332
|
+
end
|
2333
|
+
|
2334
|
+
i1, i2 = temp_i1, temp_i2
|
2335
|
+
|
2336
|
+
break if diff_lines.length > 50 # Prevent huge diffs
|
2337
|
+
end
|
2338
|
+
end
|
2339
|
+
|
2340
|
+
diff_lines
|
2341
|
+
end
|
2342
|
+
|
2343
|
+
def files_identical?(file1, file2) # {{{3
|
2344
|
+
# Quick check for identical files
|
2345
|
+
return false unless File.size(file1) == File.size(file2)
|
2346
|
+
|
2347
|
+
File.open(file1, 'rb') do |f1|
|
2348
|
+
File.open(file2, 'rb') do |f2|
|
2349
|
+
while (chunk1 = f1.read(8192))
|
2350
|
+
chunk2 = f2.read(8192)
|
2351
|
+
return false if chunk1 != chunk2
|
2352
|
+
end
|
2353
|
+
end
|
2354
|
+
end
|
2355
|
+
|
2356
|
+
true
|
2357
|
+
rescue
|
2358
|
+
false
|
2359
|
+
end
|
2360
|
+
|
2361
|
+
def binary_file?(file) # {{{3
|
2362
|
+
# Check if file appears to be binary
|
2363
|
+
File.open(file, 'rb') do |f|
|
2364
|
+
chunk = f.read(1024)
|
2365
|
+
return false if chunk.nil? || chunk.empty?
|
2366
|
+
|
2367
|
+
# Consider file binary if it contains null bytes or too many non-printable chars
|
2368
|
+
null_count = chunk.count("\x00")
|
2369
|
+
non_printable = chunk.bytes.count { |b| b < 32 && ![9, 10, 13].include?(b) }
|
2370
|
+
|
2371
|
+
null_count > 0 || (non_printable.to_f / chunk.length) > 0.3
|
2372
|
+
end
|
2373
|
+
rescue
|
2374
|
+
true # Assume binary if we can't read it
|
2375
|
+
end
|
2376
|
+
|
2377
|
+
def format_size_simple(bytes) # {{{3
|
2378
|
+
units = ['B', 'KB', 'MB', 'GB']
|
2379
|
+
size = bytes.to_f
|
2380
|
+
unit_index = 0
|
2381
|
+
|
2382
|
+
while size >= 1024 && unit_index < units.length - 1
|
2383
|
+
size /= 1024.0
|
2384
|
+
unit_index += 1
|
2385
|
+
end
|
2386
|
+
|
2387
|
+
if unit_index == 0
|
2388
|
+
"#{size.to_i} #{units[unit_index]}"
|
2389
|
+
else
|
2390
|
+
"#{sprintf('%.1f', size)} #{units[unit_index]}"
|
2391
|
+
end
|
2392
|
+
end
|
2393
|
+
|
2394
|
+
# REMOTE BROWSING {{{2
|
2395
|
+
def browse_remote # {{{3
|
2396
|
+
if @remote_mode
|
2397
|
+
# Exit remote mode
|
2398
|
+
exit_remote_mode
|
2399
|
+
else
|
2400
|
+
# Enter remote mode
|
2401
|
+
@pB.say("Remote connection: ".fg(156))
|
2402
|
+
@pR.say(build_remote_help)
|
2403
|
+
@pR.update = false
|
2404
|
+
|
2405
|
+
connection_string = @pSsh.ask('SSH connect to: ', '')
|
2406
|
+
|
2407
|
+
# Check if user cancelled (ESC or empty input)
|
2408
|
+
if connection_string.nil? || connection_string.strip.empty?
|
2409
|
+
@pB.clear; @pB.update = true
|
2410
|
+
@pR.update = true
|
2411
|
+
return
|
2412
|
+
end
|
2413
|
+
|
2414
|
+
# Parse connection string
|
2415
|
+
remote_info = parse_remote_connection(connection_string)
|
2416
|
+
unless remote_info
|
2417
|
+
@pB.clear; @pB.update = true
|
2418
|
+
@pR.update = true
|
2419
|
+
return
|
2420
|
+
end
|
2421
|
+
|
2422
|
+
# Test connection and enter remote mode
|
2423
|
+
begin
|
2424
|
+
connect_remote(remote_info)
|
2425
|
+
enter_remote_mode(remote_info[:path])
|
2426
|
+
rescue StandardError => e
|
2427
|
+
@pB.say("Connection failed: #{e.message}".fg(196))
|
2428
|
+
@pR.update = true
|
2429
|
+
end
|
2430
|
+
end
|
2431
|
+
end
|
2432
|
+
|
2433
|
+
def enter_remote_mode(path = '~') # {{{3
|
2434
|
+
@remote_mode = true
|
2435
|
+
@remote_path = path
|
2436
|
+
@index = 0 # Reset selection
|
2437
|
+
@remote_files_cache = [] # Clear cache when entering remote mode
|
2438
|
+
@pB.say("Remote mode: #{@current_remote[:user]}@#{@current_remote[:host]}:#{@remote_path} (Ctrl+E to exit)".fg(156))
|
2439
|
+
@pR.update = true
|
2440
|
+
dirlist
|
2441
|
+
render
|
2442
|
+
end
|
2443
|
+
|
2444
|
+
def exit_remote_mode # {{{3
|
2445
|
+
@remote_mode = false
|
2446
|
+
@current_remote = nil
|
2447
|
+
@remote_path = '~'
|
2448
|
+
@remote_files_cache = [] # Clear cache when exiting remote mode
|
2449
|
+
@index = 0 # Reset selection
|
2450
|
+
@pB.say("Returned to local browsing".fg(156))
|
2451
|
+
dirlist
|
2452
|
+
render
|
2453
|
+
end
|
2454
|
+
|
2455
|
+
def show_remote_file_info(file) # {{{3
|
2456
|
+
info_text = "Remote File Information\n".b.fg(156)
|
2457
|
+
info_text << "=" * 40 + "\n\n"
|
2458
|
+
|
2459
|
+
info_text << "Name: #{file[:name]}\n".fg(255)
|
2460
|
+
info_text << "Type: #{file[:type].capitalize}\n".fg(255)
|
2461
|
+
info_text << "Size: #{format_size_simple(file[:size])}\n".fg(255)
|
2462
|
+
info_text << "Permissions: #{file[:permissions]}\n".fg(255)
|
2463
|
+
info_text << "Owner: #{file[:owner]}\n".fg(255)
|
2464
|
+
info_text << "Group: #{file[:group]}\n".fg(255)
|
2465
|
+
info_text << "Modified: #{file[:modified]}\n".fg(255)
|
2466
|
+
info_text << "Remote Path: #{@remote_path}/#{file[:name]}\n".fg(240)
|
2467
|
+
|
2468
|
+
info_text << "\n"
|
2469
|
+
info_text << "Actions:\n".fg(226)
|
2470
|
+
if file[:type] == 'directory'
|
2471
|
+
info_text << " Enter = Navigate into directory\n".fg(240)
|
2472
|
+
info_text << " ← = Go to parent directory\n".fg(240)
|
2473
|
+
else
|
2474
|
+
info_text << " d = Download file\n".fg(240)
|
2475
|
+
info_text << " Enter = Show this information\n".fg(240)
|
2476
|
+
end
|
2477
|
+
info_text << " s = Open SSH shell in current directory\n".fg(240)
|
2478
|
+
info_text << " u = Upload file to current directory\n".fg(240)
|
2479
|
+
info_text << " Ctrl+E = Exit remote mode\n".fg(240)
|
2480
|
+
|
2481
|
+
@pR.say(info_text)
|
2482
|
+
@pR.update = true
|
2483
|
+
end
|
2484
|
+
|
2485
|
+
def remote_download_selected # {{{3
|
2486
|
+
return unless @remote_mode && @files && @files[@index] && @remote_files_cache[@index]
|
2487
|
+
|
2488
|
+
selected_file = @remote_files_cache[@index]
|
2489
|
+
|
2490
|
+
if selected_file[:type] == 'directory'
|
2491
|
+
@pB.say("Cannot download directories directly".fg(196))
|
2492
|
+
return
|
2493
|
+
end
|
2494
|
+
|
2495
|
+
# Show download prompt in right pane to avoid interfering with top pane
|
2496
|
+
default_dest = File.join(Dir.pwd, selected_file[:name])
|
2497
|
+
prompt_text = "Download: #{selected_file[:name]}\n\n"
|
2498
|
+
prompt_text << "Default destination:\n#{default_dest}\n\n"
|
2499
|
+
prompt_text << "Press Enter to use default,\ntype new path, or clear field to cancel:"
|
2500
|
+
|
2501
|
+
@pR.say(prompt_text.fg(156))
|
2502
|
+
@pR.update = true
|
2503
|
+
|
2504
|
+
# Ask for local destination with clearer instructions
|
2505
|
+
destination = @pSsh.ask('Download to (Enter=default, clear field to cancel): ', default_dest)
|
2506
|
+
|
2507
|
+
# Check if user cancelled by clearing the field
|
2508
|
+
if destination.nil? || destination.strip.empty?
|
2509
|
+
@pB.say("Download cancelled".fg(240))
|
2510
|
+
# Restore file info display
|
2511
|
+
show_remote_file_info(selected_file)
|
2512
|
+
return
|
2513
|
+
end
|
2514
|
+
|
2515
|
+
# Download the file
|
2516
|
+
remote_file = File.join(@remote_path, selected_file[:name])
|
2517
|
+
scp_cmd = build_scp_command(@current_remote, destination, remote_file, :download)
|
2518
|
+
|
2519
|
+
@pB.say("Downloading #{selected_file[:name]}...".fg(156))
|
2520
|
+
|
2521
|
+
begin
|
2522
|
+
result = system(scp_cmd)
|
2523
|
+
if result
|
2524
|
+
@pB.say("Downloaded: #{selected_file[:name]} -> #{destination}".fg(156))
|
2525
|
+
# Refresh local directory if we downloaded to current directory
|
2526
|
+
if File.dirname(destination) == Dir.pwd
|
2527
|
+
dirlist(left: false) # Refresh right pane if needed
|
2528
|
+
end
|
2529
|
+
# Show success and restore file info
|
2530
|
+
show_remote_file_info(selected_file)
|
2531
|
+
else
|
2532
|
+
@pB.say("Download failed".fg(196))
|
2533
|
+
show_remote_file_info(selected_file)
|
2534
|
+
end
|
2535
|
+
rescue StandardError => e
|
2536
|
+
@pB.say("Download error: #{e.message}".fg(196))
|
2537
|
+
show_remote_file_info(selected_file)
|
2538
|
+
end
|
2539
|
+
end
|
2540
|
+
|
2541
|
+
def open_remote_shell # {{{3
|
2542
|
+
return unless @remote_mode && @current_remote
|
2543
|
+
|
2544
|
+
@pB.say("Launching SSH shell...".fg(156))
|
2545
|
+
|
2546
|
+
begin
|
2547
|
+
# Build SSH command with proper directory navigation
|
2548
|
+
ssh_opts = "-t"
|
2549
|
+
ssh_opts += " -i #{Shellwords.escape(@current_remote[:ssh_key])}" if @current_remote[:ssh_key]
|
2550
|
+
|
2551
|
+
ssh_target = "#{@current_remote[:user]}@#{@current_remote[:host]}"
|
2552
|
+
|
2553
|
+
# Create SSH command that changes to the remote directory
|
2554
|
+
ssh_cmd = "ssh #{ssh_opts} #{ssh_target} -t 'cd #{Shellwords.escape(@remote_path)} 2>/dev/null || cd ~; exec bash -l'"
|
2555
|
+
|
2556
|
+
# Use RTFM's interactive program pattern
|
2557
|
+
system("stty #{ORIG_STTY} < /dev/tty")
|
2558
|
+
system('clear < /dev/tty > /dev/tty')
|
2559
|
+
Cursor.show
|
2560
|
+
|
2561
|
+
# Show connection info
|
2562
|
+
puts "Connecting to #{ssh_target}..."
|
2563
|
+
puts "Starting in directory: #{@remote_path}"
|
2564
|
+
puts "Type 'exit' to return to RTFM"
|
2565
|
+
puts "=" * 50
|
2566
|
+
puts
|
2567
|
+
|
2568
|
+
# Launch SSH on real TTY using Process.spawn like RTFM does
|
2569
|
+
pid = Process.spawn(ssh_cmd,
|
2570
|
+
in: '/dev/tty',
|
2571
|
+
out: '/dev/tty',
|
2572
|
+
err: '/dev/tty')
|
2573
|
+
begin
|
2574
|
+
Process.wait(pid)
|
2575
|
+
rescue Interrupt
|
2576
|
+
Process.kill('TERM', pid) rescue nil
|
2577
|
+
retry
|
2578
|
+
end
|
2579
|
+
|
2580
|
+
# Restore RTFM's terminal state
|
2581
|
+
system('stty raw -echo isig < /dev/tty')
|
2582
|
+
$stdin.raw!
|
2583
|
+
$stdin.echo = false
|
2584
|
+
Rcurses.init! # Reinitialize rcurses to fix input handling
|
2585
|
+
Cursor.hide
|
2586
|
+
Rcurses.clear_screen
|
2587
|
+
|
2588
|
+
# Refresh RTFM interface
|
2589
|
+
@pL.update = true
|
2590
|
+
@pR.update = true
|
2591
|
+
@pB.say("Returned from SSH shell session".fg(118))
|
2592
|
+
dirlist
|
2593
|
+
refresh
|
2594
|
+
render
|
2595
|
+
|
2596
|
+
rescue StandardError => e
|
2597
|
+
# Error handling with proper terminal restoration
|
2598
|
+
system('stty raw -echo isig < /dev/tty') rescue nil
|
2599
|
+
$stdin.raw! rescue nil
|
2600
|
+
$stdin.echo = false rescue nil
|
2601
|
+
Cursor.hide rescue nil
|
2602
|
+
Rcurses.clear_screen rescue nil
|
2603
|
+
|
2604
|
+
@pL.update = true
|
2605
|
+
@pR.update = true
|
2606
|
+
@pB.say("SSH shell failed: #{e.message}".fg(196))
|
2607
|
+
dirlist
|
2608
|
+
refresh
|
2609
|
+
render
|
2610
|
+
end
|
2611
|
+
end
|
2612
|
+
|
2613
|
+
def remote_upload_file # {{{3
|
2614
|
+
return unless @remote_mode
|
2615
|
+
|
2616
|
+
# Check if there are tagged files first
|
2617
|
+
unless @tagged.empty?
|
2618
|
+
# Show tagged files for upload confirmation
|
2619
|
+
upload_text = "Upload Tagged Files to Remote Directory\n\n".fg(156)
|
2620
|
+
upload_text << "Current remote path: #{@remote_path}\n\n".fg(240)
|
2621
|
+
upload_text << "Tagged files to upload:\n".fg(226)
|
2622
|
+
|
2623
|
+
@tagged.each_with_index do |file, i|
|
2624
|
+
if File.exist?(file)
|
2625
|
+
upload_text << sprintf(" %d. %s\n", i + 1, file.fg(255))
|
2626
|
+
else
|
2627
|
+
upload_text << sprintf(" %d. %s [MISSING]\n", i + 1, file.fg(196))
|
2628
|
+
end
|
2629
|
+
end
|
2630
|
+
|
2631
|
+
total_size = @tagged.sum { |f| File.exist?(f) ? File.size(f) : 0 }
|
2632
|
+
upload_text << "\nTotal size: #{(total_size.to_f / 1_000_000).round(2)}MB\n".fg(240)
|
2633
|
+
upload_text << "\nPress 'y' to upload all tagged files\n".fg(226)
|
2634
|
+
upload_text << "Press 'i' to upload individual files\n".fg(226)
|
2635
|
+
upload_text << "Press any other key to cancel\n".fg(240)
|
2636
|
+
|
2637
|
+
@pR.say(upload_text)
|
2638
|
+
@pR.update = true
|
2639
|
+
|
2640
|
+
choice = getchr
|
2641
|
+
case choice
|
2642
|
+
when 'y', 'Y'
|
2643
|
+
upload_tagged_files
|
2644
|
+
when 'i', 'I'
|
2645
|
+
upload_individual_file
|
2646
|
+
else
|
2647
|
+
@pB.say("Upload cancelled".fg(240))
|
2648
|
+
@pR.update = true
|
2649
|
+
end
|
2650
|
+
else
|
2651
|
+
# No tagged files, show instructions
|
2652
|
+
upload_text = "Upload Files to Remote Directory\n\n".fg(156)
|
2653
|
+
upload_text << "Current remote path: #{@remote_path}\n\n".fg(240)
|
2654
|
+
upload_text << "No files are currently tagged.\n\n".fg(226)
|
2655
|
+
upload_text << "Workflow:\n".fg(226)
|
2656
|
+
upload_text << " 1. Press Ctrl+E to exit remote mode\n".fg(240)
|
2657
|
+
upload_text << " 2. Navigate and tag files with 't'\n".fg(240)
|
2658
|
+
upload_text << " 3. Press Ctrl+E to return to remote mode\n".fg(240)
|
2659
|
+
upload_text << " 4. Press 'u' to upload tagged files\n\n".fg(240)
|
2660
|
+
upload_text << "Options:\n".fg(226)
|
2661
|
+
upload_text << " 'i' = Upload individual file\n".fg(240)
|
2662
|
+
upload_text << " 'l' = Show local directory for tagging\n".fg(240)
|
2663
|
+
upload_text << " Any other key = Cancel\n".fg(240)
|
2664
|
+
|
2665
|
+
@pR.say(upload_text)
|
2666
|
+
@pR.update = true
|
2667
|
+
|
2668
|
+
choice = getchr
|
2669
|
+
case choice
|
2670
|
+
when 'i', 'I'
|
2671
|
+
upload_individual_file
|
2672
|
+
when 'l', 'L'
|
2673
|
+
show_local_for_tagging
|
2674
|
+
else
|
2675
|
+
@pB.say("Upload cancelled".fg(240))
|
2676
|
+
@pR.update = true
|
2677
|
+
end
|
2678
|
+
end
|
2679
|
+
end
|
2680
|
+
|
2681
|
+
def upload_tagged_files # {{{3
|
2682
|
+
return unless @remote_mode
|
2683
|
+
|
2684
|
+
existing_files = @tagged.select { |f| File.exist?(f) }
|
2685
|
+
|
2686
|
+
if existing_files.empty?
|
2687
|
+
@pB.say("No valid tagged files to upload".fg(196))
|
2688
|
+
@pR.update = true
|
2689
|
+
return
|
2690
|
+
end
|
2691
|
+
|
2692
|
+
uploaded_count = 0
|
2693
|
+
failed_count = 0
|
2694
|
+
|
2695
|
+
existing_files.each do |local_file|
|
2696
|
+
begin
|
2697
|
+
remote_name = File.basename(local_file)
|
2698
|
+
remote_destination = File.join(@remote_path, remote_name)
|
2699
|
+
scp_cmd = build_scp_command(@current_remote, local_file, remote_destination, :upload)
|
2700
|
+
|
2701
|
+
@pB.say("Uploading #{File.basename(local_file)}...".fg(156))
|
2702
|
+
|
2703
|
+
if system(scp_cmd)
|
2704
|
+
uploaded_count += 1
|
2705
|
+
@pB.say("✓ #{File.basename(local_file)} uploaded".fg(156))
|
2706
|
+
else
|
2707
|
+
failed_count += 1
|
2708
|
+
@pB.say("✗ Failed to upload #{File.basename(local_file)}".fg(196))
|
2709
|
+
end
|
2710
|
+
rescue => e
|
2711
|
+
failed_count += 1
|
2712
|
+
@pB.say("✗ Error uploading #{File.basename(local_file)}: #{e.message}".fg(196))
|
2713
|
+
end
|
2714
|
+
end
|
2715
|
+
|
2716
|
+
@pB.say("Upload complete: #{uploaded_count} successful, #{failed_count} failed. Press 'r' to refresh if files don't appear.".fg(226))
|
2717
|
+
|
2718
|
+
# Refresh remote directory listing
|
2719
|
+
@remote_files_cache = []
|
2720
|
+
@pL.update = true
|
2721
|
+
@pR.update = true
|
2722
|
+
end
|
2723
|
+
|
2724
|
+
def upload_individual_file # {{{3
|
2725
|
+
return unless @remote_mode
|
2726
|
+
|
2727
|
+
# Show upload prompt in right pane
|
2728
|
+
upload_text = "Upload Individual File to Remote Directory\n\n".fg(156)
|
2729
|
+
upload_text << "Current remote path: #{@remote_path}\n\n".fg(240)
|
2730
|
+
upload_text << "Enter local file path to upload\n(or ESC to cancel):"
|
2731
|
+
|
2732
|
+
@pR.say(upload_text)
|
2733
|
+
@pR.update = true
|
2734
|
+
|
2735
|
+
# Ask for local file to upload
|
2736
|
+
local_file = @pSsh.ask('Local file (clear to cancel): ', '')
|
2737
|
+
|
2738
|
+
# Check if user cancelled
|
2739
|
+
if local_file.nil? || local_file.strip.empty?
|
2740
|
+
@pB.say("Upload cancelled".fg(240))
|
2741
|
+
@pR.update = true # Restore normal right pane
|
2742
|
+
return
|
2743
|
+
end
|
2744
|
+
|
2745
|
+
unless File.exist?(local_file)
|
2746
|
+
@pB.say("File not found: #{local_file}".fg(196))
|
2747
|
+
@pR.update = true
|
2748
|
+
return
|
2749
|
+
end
|
2750
|
+
|
2751
|
+
if File.directory?(local_file)
|
2752
|
+
@pB.say("Cannot upload directories directly".fg(196))
|
2753
|
+
@pR.update = true
|
2754
|
+
return
|
2755
|
+
end
|
2756
|
+
|
2757
|
+
# Ask for remote destination name (default to same name)
|
2758
|
+
default_name = File.basename(local_file)
|
2759
|
+
remote_name = @pSsh.ask('Remote filename (Enter=default, clear to cancel): ', default_name)
|
2760
|
+
|
2761
|
+
# Check if user cancelled
|
2762
|
+
if remote_name.nil? || remote_name.strip.empty?
|
2763
|
+
@pB.say("Upload cancelled".fg(240))
|
2764
|
+
@pR.update = true
|
2765
|
+
return
|
2766
|
+
end
|
2767
|
+
|
2768
|
+
# Upload the file
|
2769
|
+
remote_destination = File.join(@remote_path, remote_name)
|
2770
|
+
scp_cmd = build_scp_command(@current_remote, local_file, remote_destination, :upload)
|
2771
|
+
|
2772
|
+
@pB.say("Uploading #{File.basename(local_file)}...".fg(156))
|
2773
|
+
|
2774
|
+
begin
|
2775
|
+
result = system(scp_cmd)
|
2776
|
+
if result
|
2777
|
+
@pB.say("Uploaded: #{local_file} -> #{remote_name}. Press 'r' to refresh if not visible.".fg(156))
|
2778
|
+
# Clear cache and refresh remote directory
|
2779
|
+
connection_id = "#{@current_remote[:user]}@#{@current_remote[:host]}"
|
2780
|
+
cache_key = "#{connection_id}:#{@remote_path}"
|
2781
|
+
@remote_cache.delete(cache_key)
|
2782
|
+
@remote_files_cache = [] # Clear file cache too
|
2783
|
+
@pL.update = true # Force refresh of left pane
|
2784
|
+
else
|
2785
|
+
@pB.say("Upload failed".fg(196))
|
2786
|
+
end
|
2787
|
+
rescue StandardError => e
|
2788
|
+
@pB.say("Upload error: #{e.message}".fg(196))
|
2789
|
+
end
|
2790
|
+
|
2791
|
+
@pR.update = true # Restore normal right pane
|
2792
|
+
end
|
2793
|
+
|
2794
|
+
def show_local_for_tagging # {{{3
|
2795
|
+
return unless @remote_mode
|
2796
|
+
|
2797
|
+
# Temporarily exit remote mode for tagging
|
2798
|
+
@pB.say("Temporarily exiting remote mode for file tagging...".fg(156))
|
2799
|
+
|
2800
|
+
# Store current remote state
|
2801
|
+
saved_remote_mode = @remote_mode
|
2802
|
+
saved_remote_path = @remote_path
|
2803
|
+
saved_current_remote = @current_remote
|
2804
|
+
saved_remote_files_cache = @remote_files_cache
|
2805
|
+
|
2806
|
+
# Exit remote mode
|
2807
|
+
@remote_mode = false
|
2808
|
+
@current_remote = nil
|
2809
|
+
@remote_path = '~'
|
2810
|
+
@remote_files_cache = []
|
2811
|
+
|
2812
|
+
# Refresh local directory
|
2813
|
+
dirlist
|
2814
|
+
render
|
2815
|
+
|
2816
|
+
# Show tagging instructions
|
2817
|
+
tag_text = "Local File Tagging Mode\n\n".fg(156)
|
2818
|
+
tag_text << "You are now in local mode for tagging files.\n\n".fg(240)
|
2819
|
+
tag_text << "Instructions:\n".fg(226)
|
2820
|
+
tag_text << " 't' = Tag/untag files\n".fg(240)
|
2821
|
+
tag_text << " 'T' = Show tagged files\n".fg(240)
|
2822
|
+
tag_text << " 'u' = Clear all tags\n".fg(240)
|
2823
|
+
tag_text << " Navigate with arrow keys\n".fg(240)
|
2824
|
+
tag_text << " Tab/Shift+Tab = Switch tabs\n\n".fg(240)
|
2825
|
+
tag_text << "Press 'r' when done tagging to return to remote mode\n".fg(226)
|
2826
|
+
tag_text << "Press any other key to continue in local mode\n".fg(240)
|
2827
|
+
|
2828
|
+
@pR.say(tag_text)
|
2829
|
+
@pR.update = true
|
2830
|
+
|
2831
|
+
choice = getchr
|
2832
|
+
if choice == 'r' || choice == 'R'
|
2833
|
+
# Restore remote mode
|
2834
|
+
@remote_mode = saved_remote_mode
|
2835
|
+
@remote_path = saved_remote_path
|
2836
|
+
@current_remote = saved_current_remote
|
2837
|
+
@remote_files_cache = saved_remote_files_cache
|
2838
|
+
|
2839
|
+
# Refresh remote directory
|
2840
|
+
@pL.update = true
|
2841
|
+
@pR.update = true
|
2842
|
+
render
|
2843
|
+
|
2844
|
+
@pB.say("Returned to remote mode. Press 'u' to upload tagged files.".fg(156))
|
2845
|
+
else
|
2846
|
+
@pB.say("Staying in local mode. Press Ctrl+E to return to remote mode when ready.".fg(240))
|
2847
|
+
end
|
2848
|
+
end
|
2849
|
+
|
2850
|
+
def parse_remote_connection(connection_string) # {{{3
|
2851
|
+
# Support various formats:
|
2852
|
+
# ssh://user@host/path
|
2853
|
+
# user@host:/path
|
2854
|
+
# host:/path
|
2855
|
+
# user@host (defaults to home directory)
|
2856
|
+
# -i ~/.ssh/keyfile user@host:/path
|
2857
|
+
# user@host:/path -i ~/.ssh/keyfile
|
2858
|
+
# Comments are supported: user@host:/path # My server
|
2859
|
+
|
2860
|
+
# Strip comments (everything after #)
|
2861
|
+
connection_string = connection_string.split('#').first.strip
|
2862
|
+
|
2863
|
+
parts = connection_string.split(/\s+/)
|
2864
|
+
ssh_key = nil
|
2865
|
+
main_connection = nil
|
2866
|
+
|
2867
|
+
# Look for -i flag and extract key file
|
2868
|
+
if parts.include?('-i')
|
2869
|
+
key_index = parts.index('-i')
|
2870
|
+
if key_index && key_index + 1 < parts.length
|
2871
|
+
ssh_key = parts[key_index + 1]
|
2872
|
+
# Remove -i and keyfile from parts
|
2873
|
+
parts.delete_at(key_index + 1) # Remove keyfile
|
2874
|
+
parts.delete_at(key_index) # Remove -i flag
|
2875
|
+
end
|
2876
|
+
end
|
2877
|
+
|
2878
|
+
# Join remaining parts back (in case there were spaces in paths)
|
2879
|
+
main_connection = parts.join(' ')
|
2880
|
+
|
2881
|
+
# Parse the main connection string
|
2882
|
+
result = case main_connection
|
2883
|
+
when %r{^ssh://([^@]+@)?([^/]+)(/.*)?$}
|
2884
|
+
user_part, host, path = $1, $2, $3
|
2885
|
+
user = user_part ? user_part.chomp('@') : ENV['USER']
|
2886
|
+
path ||= '~'
|
2887
|
+
{ protocol: 'ssh', user: user, host: host, path: path }
|
2888
|
+
|
2889
|
+
when %r{^([^@]+@)?([^:]+):(.*)$}
|
2890
|
+
user_part, host, path = $1, $2, $3
|
2891
|
+
user = user_part ? user_part.chomp('@') : ENV['USER']
|
2892
|
+
{ protocol: 'ssh', user: user, host: host, path: path }
|
2893
|
+
|
2894
|
+
when %r{^([^@]+@)?([^:]+)$}
|
2895
|
+
user_part, host = $1, $2
|
2896
|
+
user = user_part ? user_part.chomp('@') : ENV['USER']
|
2897
|
+
{ protocol: 'ssh', user: user, host: host, path: '~' }
|
2898
|
+
|
2899
|
+
else
|
2900
|
+
@pB.say("Invalid connection format. Use: user@host:/path or -i ~/.ssh/key user@host:/path".fg(196))
|
2901
|
+
nil
|
1289
2902
|
end
|
1290
|
-
|
2903
|
+
|
2904
|
+
# Add SSH key to result if specified
|
2905
|
+
result[:ssh_key] = ssh_key if result && ssh_key
|
2906
|
+
result
|
1291
2907
|
end
|
1292
2908
|
|
1293
|
-
def
|
1294
|
-
|
1295
|
-
|
1296
|
-
|
2909
|
+
def build_ssh_command(remote_info, command = nil) # {{{3
|
2910
|
+
# Build SSH command with optional key file
|
2911
|
+
ssh_opts = "-o ConnectTimeout=10 -o BatchMode=yes"
|
2912
|
+
ssh_opts += " -i #{Shellwords.escape(remote_info[:ssh_key])}" if remote_info[:ssh_key]
|
2913
|
+
|
2914
|
+
ssh_cmd = "ssh #{ssh_opts} #{remote_info[:user]}@#{remote_info[:host]}"
|
2915
|
+
ssh_cmd += " '#{command}'" if command
|
2916
|
+
ssh_cmd
|
1297
2917
|
end
|
1298
2918
|
|
1299
|
-
def
|
1300
|
-
|
1301
|
-
|
2919
|
+
def build_scp_command(remote_info, local_path, remote_path, direction = :download) # {{{3
|
2920
|
+
# Build SCP command with optional key file
|
2921
|
+
scp_opts = ""
|
2922
|
+
scp_opts += " -i #{Shellwords.escape(remote_info[:ssh_key])}" if remote_info[:ssh_key]
|
2923
|
+
|
2924
|
+
ssh_target = "#{remote_info[:user]}@#{remote_info[:host]}"
|
2925
|
+
|
2926
|
+
# Handle tilde expansion for SCP
|
2927
|
+
scp_remote_path = if remote_path.start_with?('~')
|
2928
|
+
"#{ssh_target}:#{remote_path}"
|
2929
|
+
else
|
2930
|
+
"#{ssh_target}:#{Shellwords.escape(remote_path)}"
|
2931
|
+
end
|
2932
|
+
|
2933
|
+
if direction == :download
|
2934
|
+
"scp#{scp_opts} #{scp_remote_path} #{Shellwords.escape(local_path)}"
|
2935
|
+
else # upload
|
2936
|
+
"scp#{scp_opts} #{Shellwords.escape(local_path)} #{scp_remote_path}"
|
2937
|
+
end
|
1302
2938
|
end
|
1303
2939
|
|
1304
|
-
#
|
1305
|
-
|
1306
|
-
|
1307
|
-
|
1308
|
-
|
1309
|
-
|
1310
|
-
|
1311
|
-
|
1312
|
-
|
1313
|
-
|
1314
|
-
item = File.join(current_dir, selected_item)
|
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
|
1329
|
-
else
|
1330
|
-
@index_right = [@index_right + 1, max_index].min
|
1331
|
-
@selected_right = current_files[@index_right] if current_files[@index_right]
|
1332
|
-
@pRight.update = true
|
1333
|
-
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
|
-
end
|
1349
|
-
@index = [@index + 1, (@files.size - 1)].min
|
1350
|
-
@pL.update = true
|
2940
|
+
def connect_remote(remote_info) # {{{3
|
2941
|
+
@pB.say("Connecting to #{remote_info[:user]}@#{remote_info[:host]}...".fg(156))
|
2942
|
+
|
2943
|
+
# Test SSH connection
|
2944
|
+
ssh_cmd = build_ssh_command(remote_info, 'echo connected')
|
2945
|
+
result = `#{ssh_cmd} 2>&1`
|
2946
|
+
|
2947
|
+
unless $?.success?
|
2948
|
+
key_hint = remote_info[:ssh_key] ? " with key #{remote_info[:ssh_key]}" : ""
|
2949
|
+
raise "SSH connection failed#{key_hint}. Check your SSH keys or try: ssh #{remote_info[:user]}@#{remote_info[:host]}"
|
1351
2950
|
end
|
1352
2951
|
|
1353
|
-
|
1354
|
-
@
|
2952
|
+
# Store connection info
|
2953
|
+
@current_remote = remote_info
|
2954
|
+
connection_id = "#{remote_info[:user]}@#{remote_info[:host]}"
|
2955
|
+
@remote_connections[connection_id] = remote_info
|
2956
|
+
|
2957
|
+
@pB.say("Connected successfully!".fg(156))
|
1355
2958
|
end
|
1356
2959
|
|
1357
|
-
|
1358
|
-
|
1359
|
-
|
1360
|
-
|
1361
|
-
|
1362
|
-
|
2960
|
+
|
2961
|
+
def list_remote_directory(remote_path) # {{{3
|
2962
|
+
# Handle tilde expansion - don't escape ~ as it needs shell expansion
|
2963
|
+
escaped_path = if remote_path.start_with?('~')
|
2964
|
+
remote_path # Don't escape paths starting with ~
|
2965
|
+
else
|
2966
|
+
Shellwords.escape(remote_path)
|
2967
|
+
end
|
2968
|
+
|
2969
|
+
# Use ls -la to get detailed file listing
|
2970
|
+
ls_cmd = build_ssh_command(@current_remote, "cd #{escaped_path} && ls -la 2>/dev/null")
|
2971
|
+
output = `#{ls_cmd} 2>&1`
|
2972
|
+
|
2973
|
+
unless $?.success?
|
2974
|
+
raise "Failed to list remote directory: #{remote_path}"
|
1363
2975
|
end
|
1364
|
-
|
1365
|
-
|
1366
|
-
|
1367
|
-
|
1368
|
-
|
2976
|
+
|
2977
|
+
# Parse ls output
|
2978
|
+
files = []
|
2979
|
+
output.lines.each do |line|
|
2980
|
+
next if line.match?(/^total \d+/) # Skip total line
|
2981
|
+
|
2982
|
+
parts = line.strip.split(/\s+/, 9)
|
2983
|
+
next if parts.length < 9
|
2984
|
+
|
2985
|
+
permissions, links, owner, group, size, month, day, time_or_year, name = parts
|
2986
|
+
next if name == '.' || name == '..'
|
2987
|
+
|
2988
|
+
# Determine file type
|
2989
|
+
type = case permissions[0]
|
2990
|
+
when 'd' then 'directory'
|
2991
|
+
when 'l' then 'symlink'
|
2992
|
+
when '-' then 'file'
|
2993
|
+
else 'other'
|
2994
|
+
end
|
2995
|
+
|
2996
|
+
files << {
|
2997
|
+
name: name,
|
2998
|
+
type: type,
|
2999
|
+
size: size.to_i,
|
3000
|
+
permissions: permissions,
|
3001
|
+
owner: owner,
|
3002
|
+
group: group,
|
3003
|
+
modified: "#{month} #{day} #{time_or_year}"
|
3004
|
+
}
|
3005
|
+
end
|
3006
|
+
|
3007
|
+
# Sort: directories first, then by name
|
3008
|
+
files.sort_by { |f| [f[:type] == 'directory' ? 0 : 1, f[:name].downcase] }
|
1369
3009
|
end
|
1370
3010
|
|
1371
|
-
def show_tagged # {{{3
|
1372
|
-
tagged_info
|
1373
|
-
@pB.update = true
|
1374
|
-
end
|
1375
3011
|
|
1376
|
-
def
|
1377
|
-
|
1378
|
-
|
1379
|
-
|
3012
|
+
def build_remote_help # {{{3
|
3013
|
+
help_text = "Remote Connection Setup\n".b.fg(156)
|
3014
|
+
help_text << "=" * 50 + "\n\n"
|
3015
|
+
|
3016
|
+
# Show recent SSH connections if any exist
|
3017
|
+
unless @pSsh.history.empty?
|
3018
|
+
help_text << "Recent Connections:\n".fg(226)
|
3019
|
+
@pSsh.history.reverse.first(5).each_with_index do |connection, i|
|
3020
|
+
if connection =~ /([^@]+@[^:\s]+)/
|
3021
|
+
host_part = $1
|
3022
|
+
help_text << sprintf(" %d. %s\n", i + 1, host_part.fg(156))
|
3023
|
+
else
|
3024
|
+
help_text << sprintf(" %d. %s\n", i + 1, connection.fg(156))
|
3025
|
+
end
|
3026
|
+
end
|
3027
|
+
help_text << "\nUse " + "↑/↓".fg(156) + " in prompt to recall connections\n\n".fg(249)
|
3028
|
+
end
|
3029
|
+
|
3030
|
+
help_text << "Connection Examples:\n".fg(226)
|
3031
|
+
help_text << " Basic SSH/SFTP connections:\n".fg(249)
|
3032
|
+
help_text << " user@hostname:/path - Connect to specific path\n"
|
3033
|
+
help_text << " user@hostname - Connect to home directory\n"
|
3034
|
+
help_text << " ssh://user@host/path - Full SSH URI format\n"
|
3035
|
+
help_text << " myserver.com:/var/www - Connect to web directory\n\n"
|
3036
|
+
|
3037
|
+
help_text << " With SSH keys:\n".fg(249)
|
3038
|
+
help_text << " -i ~/.ssh/keyfile user@host:/path - Use specific SSH key\n"
|
3039
|
+
help_text << " user@host:/path -i ~/.ssh/keyfile - SSH key at end\n"
|
3040
|
+
help_text << " -i ~/.ssh/pf-do pfadmin@server.com - Custom key example\n\n"
|
3041
|
+
|
3042
|
+
help_text << " Example connections:\n".fg(249)
|
3043
|
+
help_text << " john@server.com:/home/john/documents\n".fg(240)
|
3044
|
+
help_text << " admin@192.168.1.100:/var/log\n".fg(240)
|
3045
|
+
help_text << " ssh://deploy@prod.server.com/app\n".fg(240)
|
3046
|
+
help_text << " -i ~/.ssh/aws-key ec2-user@1.2.3.4\n".fg(240)
|
3047
|
+
help_text << "\n"
|
3048
|
+
|
3049
|
+
help_text << " Comments for organization:\n".fg(249)
|
3050
|
+
help_text << " user@server.com:/path # Production server\n".fg(240)
|
3051
|
+
help_text << " admin@192.168.1.10 # Local development\n".fg(240)
|
3052
|
+
help_text << " root@backup.server.com # Backup storage\n".fg(240)
|
3053
|
+
help_text << " (Comments after # are ignored)\n".fg(240)
|
3054
|
+
help_text << "\n"
|
3055
|
+
|
3056
|
+
help_text << "Requirements:\n".fg(226)
|
3057
|
+
help_text << " • SSH access to remote host\n"
|
3058
|
+
help_text << " • SSH key authentication (recommended)\n"
|
3059
|
+
help_text << " • Or password authentication enabled\n\n"
|
3060
|
+
|
3061
|
+
help_text << "Remote Navigation:\n".fg(226)
|
3062
|
+
help_text << " • Use arrow keys to navigate directories\n"
|
3063
|
+
help_text << " • Press " + "d".fg(156) + " to download files\n"
|
3064
|
+
help_text << " • Press " + "u".fg(156) + " to upload files\n"
|
3065
|
+
help_text << " • Press " + "s".fg(156) + " to open SSH shell\n"
|
3066
|
+
help_text << " • Press " + "→".fg(156) + " to view file info\n"
|
3067
|
+
help_text << " • Press " + "Ctrl+E".fg(156) + " to return to local browsing\n"
|
3068
|
+
|
3069
|
+
help_text
|
1380
3070
|
end
|
1381
3071
|
|
1382
3072
|
# MANIPULATE ITEMS {{{2
|
@@ -1400,44 +3090,108 @@ def rename_item # {{{3
|
|
1400
3090
|
cmd = @pCmd.ask(': ', tpl).pure
|
1401
3091
|
match = cmd.match(/mv\s+"[^"]+"\s+"([^"]+)"/)
|
1402
3092
|
new_basename = match ? match[1] : basename
|
1403
|
-
|
1404
|
-
|
1405
|
-
|
1406
|
-
|
1407
|
-
|
1408
|
-
|
1409
|
-
|
1410
|
-
|
1411
|
-
|
1412
|
-
|
3093
|
+
|
3094
|
+
# Only proceed if name actually changed
|
3095
|
+
if new_basename != basename
|
3096
|
+
old_path = @selected
|
3097
|
+
old_esc = Shellwords.escape(old_path)
|
3098
|
+
new_path = File.join(dir, new_basename)
|
3099
|
+
new_esc = Shellwords.escape(new_path)
|
3100
|
+
|
3101
|
+
# Record undo information before rename
|
3102
|
+
undo_info = {
|
3103
|
+
type: 'rename',
|
3104
|
+
old_path: old_path,
|
3105
|
+
new_path: new_path,
|
3106
|
+
timestamp: Time.now
|
3107
|
+
}
|
3108
|
+
|
3109
|
+
shellexec("mv #{old_esc} #{new_esc}")
|
3110
|
+
|
3111
|
+
# Only add to undo history if rename was successful
|
3112
|
+
if File.exist?(new_path)
|
3113
|
+
add_undo_operation(undo_info)
|
3114
|
+
end
|
3115
|
+
|
3116
|
+
dirlist
|
3117
|
+
# point @selected and @index at the new name
|
3118
|
+
@selected = new_path
|
3119
|
+
new_idx = @files.index(new_basename)
|
3120
|
+
@index = new_idx if new_idx
|
3121
|
+
render
|
3122
|
+
end
|
1413
3123
|
end
|
1414
3124
|
|
1415
3125
|
def link_items # {{{3
|
3126
|
+
if @remote_mode
|
3127
|
+
# In remote mode, 's' key opens SSH shell
|
3128
|
+
open_remote_shell
|
3129
|
+
return
|
3130
|
+
end
|
3131
|
+
|
1416
3132
|
copy_move_link('link')
|
1417
3133
|
# Dual-pane refresh is handled in copy_move_link function
|
1418
3134
|
@pR.update = true
|
1419
3135
|
end
|
1420
3136
|
|
1421
3137
|
def delete_items # {{{3
|
3138
|
+
if @remote_mode
|
3139
|
+
# In remote mode, 'd' key downloads the selected file
|
3140
|
+
remote_download_selected
|
3141
|
+
return
|
3142
|
+
end
|
3143
|
+
|
1422
3144
|
tagged_info
|
1423
|
-
|
3145
|
+
|
3146
|
+
# Add deletion warning to the right pane
|
3147
|
+
warning_text = "\n" + "=" * 50 + "\n"
|
3148
|
+
action = @trash ? 'Move to Trash' : 'PERMANENT DELETE'
|
3149
|
+
action_color = @trash ? 220 : 196
|
3150
|
+
warning_text << action.fg(action_color).b + "\n\n"
|
3151
|
+
|
3152
|
+
if @trash
|
3153
|
+
warning_text << "Items will be moved to:\n".fg(249)
|
3154
|
+
warning_text << " ~/.rtfm/trash/\n".fg(240)
|
3155
|
+
warning_text << "\nYou can restore them with " + "C-z".fg(156) + "\n".fg(249)
|
3156
|
+
else
|
3157
|
+
warning_text << "⚠️ WARNING: PERMANENT DELETION!\n".fg(196).b
|
3158
|
+
warning_text << "Files will be permanently removed\n".fg(196)
|
3159
|
+
warning_text << "This action CANNOT be undone!\n".fg(196).b
|
3160
|
+
end
|
3161
|
+
|
3162
|
+
warning_text << "\n" + "Press " + "y".fg(156).b + " to confirm, any other key to cancel".fg(249)
|
3163
|
+
|
3164
|
+
@pR.text << warning_text
|
1424
3165
|
@pR.refresh
|
1425
|
-
|
1426
|
-
|
1427
|
-
|
1428
|
-
@pB.say(" #{
|
3166
|
+
|
3167
|
+
# Bottom pane prompt
|
3168
|
+
prompt_text = @trash ? "Move to trash? (y/n)" : "⚠️ PERMANENTLY DELETE? (y/n)"
|
3169
|
+
@pB.say(" #{prompt_text}".fg(action_color))
|
1429
3170
|
if getchr == 'y'
|
1430
3171
|
# collect & escape every path with existence verification
|
1431
3172
|
paths = (@tagged + [@selected]).uniq.select { |p| File.exist?(p) }
|
1432
3173
|
if paths.empty?
|
1433
3174
|
@pB.say("No valid items to #{action.downcase}".fg(196))
|
1434
3175
|
else
|
3176
|
+
# Record undo information before deletion
|
3177
|
+
if @trash
|
3178
|
+
undo_info = {
|
3179
|
+
type: 'delete',
|
3180
|
+
trash: true,
|
3181
|
+
paths: paths.map { |p| { path: p } },
|
3182
|
+
timestamp: Time.now
|
3183
|
+
}
|
3184
|
+
end
|
3185
|
+
|
1435
3186
|
esc = paths.map { |p| Shellwords.escape(p) }.join(' ')
|
1436
3187
|
if @trash
|
1437
3188
|
esc_trash = Shellwords.escape(TRASH_DIR)
|
1438
3189
|
command("mv -f #{esc} #{esc_trash}")
|
3190
|
+
# Only add to undo history if operation succeeded and we can undo it
|
3191
|
+
add_undo_operation(undo_info)
|
1439
3192
|
else
|
1440
3193
|
command("rm -rf #{esc}")
|
3194
|
+
# Cannot undo permanent deletion, so don't add to undo history
|
1441
3195
|
end
|
1442
3196
|
@tagged.clear
|
1443
3197
|
refresh_right
|
@@ -1846,70 +3600,141 @@ end
|
|
1846
3600
|
|
1847
3601
|
# SYSTEM SHORTCUTS {{{2
|
1848
3602
|
def system_info # {{{3
|
1849
|
-
text =
|
3603
|
+
text = "System Information\n".b.fg(156)
|
3604
|
+
text << "=" * 50 + "\n\n"
|
3605
|
+
|
1850
3606
|
begin
|
1851
|
-
|
1852
|
-
|
1853
|
-
|
1854
|
-
|
1855
|
-
|
1856
|
-
text
|
1857
|
-
|
1858
|
-
|
1859
|
-
|
1860
|
-
|
1861
|
-
|
1862
|
-
|
1863
|
-
|
1864
|
-
|
1865
|
-
|
1866
|
-
|
3607
|
+
# Operating System Information
|
3608
|
+
text << "Operating System:\n".fg(226)
|
3609
|
+
os_name = `awk -F '"' '/PRETTY/ {print $2}' /etc/os-release 2>/dev/null`.chomp
|
3610
|
+
kernel_version = `uname -r 2>/dev/null`.chomp
|
3611
|
+
architecture = `uname -m 2>/dev/null`.chomp
|
3612
|
+
text << sprintf(" %-15s %s\n", "Distribution:", os_name.fg(156))
|
3613
|
+
text << sprintf(" %-15s %s\n", "Kernel:", kernel_version.fg(156))
|
3614
|
+
text << sprintf(" %-15s %s\n", "Architecture:", architecture.fg(156))
|
3615
|
+
text << "\n"
|
3616
|
+
rescue # rubocop:disable Lint/SuppressedException
|
3617
|
+
end
|
3618
|
+
|
3619
|
+
begin
|
3620
|
+
# Hardware Information
|
3621
|
+
text << "Hardware:\n".fg(226)
|
3622
|
+
cpu_count = `nproc 2>/dev/null`.chomp
|
3623
|
+
cpuinfo = `lscpu 2>/dev/null`
|
3624
|
+
cpu_model = cpuinfo[/^.*Model name:\s*(.*)/, 1] || "Unknown"
|
3625
|
+
cpu_max = cpuinfo[/^.*CPU max MHz:\s*(.*)/, 1]&.to_i || 0
|
3626
|
+
cpu_min = cpuinfo[/^.*CPU min MHz:\s*(.*)/, 1]&.to_i || 0
|
3627
|
+
|
3628
|
+
text << sprintf(" %-15s %s cores\n", "CPU Count:", cpu_count.fg(156))
|
3629
|
+
text << sprintf(" %-15s %s\n", "CPU Model:", cpu_model.fg(156))
|
3630
|
+
if cpu_max > 0 && cpu_min > 0
|
3631
|
+
text << sprintf(" %-15s %d-%d MHz\n", "CPU Speed:", cpu_min, cpu_max)
|
3632
|
+
end
|
3633
|
+
text << "\n"
|
1867
3634
|
rescue # rubocop:disable Lint/SuppressedException
|
1868
3635
|
end
|
3636
|
+
|
1869
3637
|
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)
|
3638
|
+
# Memory Information
|
3639
|
+
text << "Memory Usage:\n".fg(226)
|
3640
|
+
mem_output = `free -h 2>/dev/null`
|
3641
|
+
if mem_output && !mem_output.empty?
|
3642
|
+
mem_lines = mem_output.lines
|
3643
|
+
mem_lines.each_with_index do |line, i|
|
3644
|
+
if i == 0 # Header
|
3645
|
+
text << " " + line.strip.fg(240) + "\n"
|
3646
|
+
else
|
3647
|
+
text << " " + line.strip.fg(156) + "\n"
|
3648
|
+
end
|
3649
|
+
end
|
3650
|
+
end
|
3651
|
+
text << "\n"
|
1886
3652
|
rescue # rubocop:disable Lint/SuppressedException
|
1887
3653
|
end
|
3654
|
+
|
1888
3655
|
begin
|
1889
|
-
|
1890
|
-
text
|
3656
|
+
# Environment Information
|
3657
|
+
text << "Environment:\n".fg(226)
|
3658
|
+
shell = `echo $SHELL 2>/dev/null`.sub(%r{.*/}, '').chomp
|
3659
|
+
terminal = `echo $TERM 2>/dev/null`.chomp
|
3660
|
+
packages = `dpkg-query -l 2>/dev/null | grep -c '^.i'`.chomp
|
3661
|
+
packages = `pacman -Q 2>/dev/null | wc -l`.chomp if packages == '0'
|
3662
|
+
packages = "Unknown" if packages == '0'
|
3663
|
+
|
3664
|
+
text << sprintf(" %-15s %s\n", "Shell:", shell.fg(156))
|
3665
|
+
text << sprintf(" %-15s %s\n", "Terminal:", terminal.fg(156))
|
3666
|
+
text << sprintf(" %-15s %s\n", "Packages:", packages.fg(156))
|
3667
|
+
text << "\n"
|
1891
3668
|
rescue # rubocop:disable Lint/SuppressedException
|
1892
3669
|
end
|
3670
|
+
|
1893
3671
|
begin
|
1894
|
-
|
1895
|
-
text
|
3672
|
+
# Disk Usage
|
3673
|
+
text << "Disk Usage:\n".fg(226)
|
3674
|
+
disk_output = `df -h 2>/dev/null | head -8`
|
3675
|
+
if disk_output && !disk_output.empty?
|
3676
|
+
disk_lines = disk_output.lines
|
3677
|
+
disk_lines.each_with_index do |line, i|
|
3678
|
+
if i == 0 # Header
|
3679
|
+
text << " " + line.strip.fg(240) + "\n"
|
3680
|
+
else
|
3681
|
+
# Highlight usage percentage
|
3682
|
+
colored_line = line.gsub(/(\d+)%/) do |match|
|
3683
|
+
percent = $1.to_i
|
3684
|
+
color = percent > 90 ? 196 : percent > 80 ? 220 : 156
|
3685
|
+
match.fg(color)
|
3686
|
+
end
|
3687
|
+
text << " " + colored_line.strip.fg(249) + "\n"
|
3688
|
+
end
|
3689
|
+
end
|
3690
|
+
end
|
3691
|
+
text << "\n"
|
1896
3692
|
rescue # rubocop:disable Lint/SuppressedException
|
1897
3693
|
end
|
3694
|
+
|
1898
3695
|
begin
|
1899
|
-
|
1900
|
-
text
|
3696
|
+
# Top Processes
|
3697
|
+
text << "Top Processes (CPU & Memory):\n".fg(226)
|
3698
|
+
ps_output = `ps -eo comm,pid,user,pcpu,pmem,stat --sort -pcpu,-pmem 2>/dev/null | head -8`
|
3699
|
+
if ps_output && !ps_output.empty?
|
3700
|
+
ps_lines = ps_output.lines
|
3701
|
+
ps_lines.each_with_index do |line, i|
|
3702
|
+
if i == 0 # Header
|
3703
|
+
text << " " + line.strip.fg(240) + "\n"
|
3704
|
+
else
|
3705
|
+
text << " " + line.strip.fg(249) + "\n"
|
3706
|
+
end
|
3707
|
+
end
|
3708
|
+
end
|
3709
|
+
text << "\n"
|
1901
3710
|
rescue # rubocop:disable Lint/SuppressedException
|
1902
3711
|
end
|
3712
|
+
|
1903
3713
|
begin
|
1904
|
-
|
1905
|
-
|
1906
|
-
|
1907
|
-
|
3714
|
+
# System Messages
|
3715
|
+
text << "Recent System Messages:\n".fg(226)
|
3716
|
+
dmesg_output = `dmesg 2>/dev/null | tail -5`
|
3717
|
+
if dmesg_output && !dmesg_output.empty?
|
3718
|
+
dmesg_output.lines.reverse.each do |line|
|
3719
|
+
# Color code different message types
|
3720
|
+
colored_line = case line
|
3721
|
+
when /error|fail|critical/i then line.fg(196)
|
3722
|
+
when /warn/i then line.fg(220)
|
3723
|
+
when /info/i then line.fg(156)
|
3724
|
+
else line.fg(249)
|
3725
|
+
end
|
3726
|
+
text << " " + colored_line.strip + "\n"
|
3727
|
+
end
|
3728
|
+
else
|
3729
|
+
text << " " + "dmesg requires root access".fg(240) + "\n"
|
3730
|
+
text << " " + "Run: sudo sysctl kernel.dmesg_restrict=0".fg(240) + "\n"
|
3731
|
+
end
|
1908
3732
|
rescue # rubocop:disable Lint/SuppressedException
|
1909
3733
|
end
|
3734
|
+
|
1910
3735
|
@pR.say(text)
|
1911
3736
|
rescue
|
1912
|
-
@pR.say('Unable to show system info')
|
3737
|
+
@pR.say('Unable to show system info'.fg(196))
|
1913
3738
|
end
|
1914
3739
|
|
1915
3740
|
def make_directory # {{{3
|
@@ -1999,6 +3824,34 @@ def show_history # {{{3
|
|
1999
3824
|
@pB.update = true
|
2000
3825
|
end
|
2001
3826
|
|
3827
|
+
def show_ssh_history # {{{3
|
3828
|
+
history_text = "SSH Connection History\n".b.fg(156)
|
3829
|
+
history_text << "=" * 50 + "\n\n"
|
3830
|
+
|
3831
|
+
if @pSsh.history.empty?
|
3832
|
+
history_text << "No SSH connections in history\n".fg(240)
|
3833
|
+
history_text << "\nPress " + "Ctrl+E".fg(156) + " to start browsing remote directories\n".fg(249)
|
3834
|
+
else
|
3835
|
+
history_text << "Recent connections:\n".fg(226)
|
3836
|
+
@pSsh.history.reverse.each_with_index do |connection, i|
|
3837
|
+
# Parse and format connection for display
|
3838
|
+
if connection =~ /([^@]+@[^:\s]+)/
|
3839
|
+
host_part = $1
|
3840
|
+
history_text << sprintf(" %2d. %s\n", i + 1, host_part.fg(156))
|
3841
|
+
if connection.length > host_part.length + 10
|
3842
|
+
history_text << sprintf(" %s\n", connection.fg(240))
|
3843
|
+
end
|
3844
|
+
else
|
3845
|
+
history_text << sprintf(" %2d. %s\n", i + 1, connection.fg(156))
|
3846
|
+
end
|
3847
|
+
end
|
3848
|
+
history_text << "\n" + "Use " + "↑/↓".fg(156) + " in SSH prompt to recall connections".fg(249)
|
3849
|
+
end
|
3850
|
+
|
3851
|
+
@pR.say(history_text)
|
3852
|
+
@pB.update = true
|
3853
|
+
end
|
3854
|
+
|
2002
3855
|
def add_interactive # {{{
|
2003
3856
|
@interactive = @pB.ask('Add program to @interactive: '.fg(213), @interactive.fg(213))
|
2004
3857
|
@pB.clear; @pB.update = true
|
@@ -2008,6 +3861,13 @@ end
|
|
2008
3861
|
def ruby_debug # {{{3
|
2009
3862
|
require 'stringio'
|
2010
3863
|
cmd = @pRuby.ask('Ruby command: ', '')
|
3864
|
+
|
3865
|
+
# If user cancelled (ESC) or entered empty command, don't execute or output anything
|
3866
|
+
if cmd.nil? || cmd.strip.empty?
|
3867
|
+
@pB.clear; @pB.update = true
|
3868
|
+
return
|
3869
|
+
end
|
3870
|
+
|
2011
3871
|
@pR.text = "Command: #{cmd}\n\n".fg(205)
|
2012
3872
|
original_stdout = $stdout
|
2013
3873
|
original_stderr = $stderr
|
@@ -2031,24 +3891,9 @@ end
|
|
2031
3891
|
|
2032
3892
|
# GENERIC FUNCTIONS {{{1
|
2033
3893
|
def get_cached_dirlist(dir, ls_options) # {{{2
|
2034
|
-
#
|
3894
|
+
# Directory caching disabled for stability
|
2035
3895
|
return nil
|
2036
3896
|
|
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
3897
|
# Clean old cache entries if cache is getting too large
|
2053
3898
|
if @dir_cache_size >= @max_cache_entries
|
2054
3899
|
@dir_cache.clear
|
@@ -2118,10 +3963,93 @@ def get_cached_file_metadata(file_path) # {{{2
|
|
2118
3963
|
end
|
2119
3964
|
end
|
2120
3965
|
|
3966
|
+
def dirlist_remote # {{{2
|
3967
|
+
return '' unless @current_remote && @remote_mode
|
3968
|
+
|
3969
|
+
current_index = @index || 0
|
3970
|
+
current_index = current_index.to_i
|
3971
|
+
width = @pL.w
|
3972
|
+
|
3973
|
+
# Check if we need to refresh the directory listing
|
3974
|
+
connection_id = "#{@current_remote[:user]}@#{@current_remote[:host]}"
|
3975
|
+
cache_key = "#{connection_id}:#{@remote_path}"
|
3976
|
+
|
3977
|
+
# Use cache if available and recent (60 seconds for navigation)
|
3978
|
+
if @remote_cache[cache_key] && (Time.now - @remote_cache[cache_key][:timestamp]) < 60
|
3979
|
+
files = @remote_cache[cache_key][:files]
|
3980
|
+
else
|
3981
|
+
# Only fetch remote listing when cache is stale or missing
|
3982
|
+
begin
|
3983
|
+
files = list_remote_directory(@remote_path)
|
3984
|
+
# Cache the result
|
3985
|
+
@remote_cache[cache_key] = {
|
3986
|
+
files: files,
|
3987
|
+
timestamp: Time.now
|
3988
|
+
}
|
3989
|
+
rescue StandardError => e
|
3990
|
+
@pB.say("Remote listing failed: #{e.message}".fg(196))
|
3991
|
+
return ''
|
3992
|
+
end
|
3993
|
+
end
|
3994
|
+
|
3995
|
+
# Update global @files for navigation (simple names array)
|
3996
|
+
@files = files.map { |f| f[:name] }
|
3997
|
+
|
3998
|
+
# Store the full file info for later use (avoid repeated SSH calls)
|
3999
|
+
@remote_files_cache = files
|
4000
|
+
|
4001
|
+
# Update @selected for current selection
|
4002
|
+
if @files[@index] && files[@index]
|
4003
|
+
@selected = "#{@remote_path}/#{@files[@index]}"
|
4004
|
+
selected_file = files[@index]
|
4005
|
+
@fileattr = "#{selected_file[:owner]}:#{selected_file[:group]} #{selected_file[:permissions]} #{format_size_simple(selected_file[:size])} #{selected_file[:modified]}"
|
4006
|
+
end
|
4007
|
+
|
4008
|
+
# Format the listing with remote-specific styling
|
4009
|
+
search_regex = @searched.empty? ? nil : /#{@searched}/
|
4010
|
+
|
4011
|
+
result = files.map.with_index do |file, i|
|
4012
|
+
name = file[:name]
|
4013
|
+
|
4014
|
+
# Color coding for different file types
|
4015
|
+
color = case file[:type]
|
4016
|
+
when 'directory' then 156 # Blue-ish
|
4017
|
+
when 'symlink' then 226 # Yellow
|
4018
|
+
when 'file' then 255 # White
|
4019
|
+
else 240 # Gray
|
4020
|
+
end
|
4021
|
+
|
4022
|
+
# Apply color and decorations
|
4023
|
+
n = name.fg(color)
|
4024
|
+
n = n.inject('@', -1) if file[:type] == 'symlink'
|
4025
|
+
n = n.inject('/', -1) if file[:type] == 'directory'
|
4026
|
+
n = n.bg(238) if search_regex && name.match(search_regex)
|
4027
|
+
|
4028
|
+
# Truncate if too long
|
4029
|
+
n = n.shorten(width - 5).inject('…', -1) if name.length > width - 6
|
4030
|
+
|
4031
|
+
# Add selection indicator and remote mode indicator (red background)
|
4032
|
+
if i == current_index
|
4033
|
+
n = '→ ' + n.u.bg(52) # Red background for remote mode selection
|
4034
|
+
else
|
4035
|
+
n = ' ' + n.bg(52) # Red background for remote mode
|
4036
|
+
end
|
4037
|
+
|
4038
|
+
n
|
4039
|
+
end
|
4040
|
+
|
4041
|
+
result.join("\n")
|
4042
|
+
end
|
4043
|
+
|
2121
4044
|
def dirlist(left: true, directory: nil) # LIST DIRECTORIES {{{2
|
2122
4045
|
current_index = @index || 0
|
2123
4046
|
current_index = current_index.to_i
|
2124
4047
|
|
4048
|
+
# Handle remote mode for left pane
|
4049
|
+
if left && @remote_mode
|
4050
|
+
return dirlist_remote
|
4051
|
+
end
|
4052
|
+
|
2125
4053
|
if left
|
2126
4054
|
dir = directory || Dir.pwd
|
2127
4055
|
width = @pL.w
|
@@ -2279,9 +4207,6 @@ end
|
|
2279
4207
|
def render # RENDER ALL PANES {{{2
|
2280
4208
|
return unless needs_render?
|
2281
4209
|
|
2282
|
-
# TEMPORARILY DISABLED: Use batch updates for rcurses 4.9.0+ performance improvement
|
2283
|
-
# Rcurses.batch_refresh do
|
2284
|
-
|
2285
4210
|
# LEFT pane {{{3
|
2286
4211
|
if @pL.update
|
2287
4212
|
lefttext = @pL.text
|
@@ -2422,8 +4347,6 @@ def render # RENDER ALL PANES {{{2
|
|
2422
4347
|
@pB.text = info
|
2423
4348
|
@pB.refresh unless @pB.text == bottomtext
|
2424
4349
|
end
|
2425
|
-
|
2426
|
-
# end # Rcurses.batch_refresh - TEMPORARILY DISABLED
|
2427
4350
|
end
|
2428
4351
|
|
2429
4352
|
def refresh # {{{2
|
@@ -2471,6 +4394,8 @@ def refresh # {{{2
|
|
2471
4394
|
@pCmd.w = @w
|
2472
4395
|
@pRuby.y = @h
|
2473
4396
|
@pRuby.w = @w
|
4397
|
+
@pSsh.y = @h
|
4398
|
+
@pSsh.w = @w
|
2474
4399
|
end
|
2475
4400
|
|
2476
4401
|
def setborder # {{{2
|
@@ -2607,6 +4532,9 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
|
|
2607
4532
|
Dir.pwd
|
2608
4533
|
end
|
2609
4534
|
|
4535
|
+
# Track operations for undo
|
4536
|
+
operations = []
|
4537
|
+
|
2610
4538
|
@tagged.each do |item|
|
2611
4539
|
dest = File.join(dest_dir, File.basename(item))
|
2612
4540
|
dest += '1' if File.exist?(dest)
|
@@ -2618,18 +4546,46 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
|
|
2618
4546
|
case type
|
2619
4547
|
when 'copy'
|
2620
4548
|
FileUtils.cp_r(item, dest)
|
4549
|
+
operations << { source_path: item, dest_path: dest }
|
2621
4550
|
@pB.say(' Item(s) copied here.')
|
2622
4551
|
when 'move'
|
2623
4552
|
FileUtils.mv(item, dest)
|
4553
|
+
operations << { source_path: item, dest_path: dest }
|
2624
4554
|
@pB.say(' Item(s) moved here.')
|
2625
4555
|
when 'link'
|
2626
4556
|
FileUtils.ln_s(item, dest)
|
4557
|
+
operations << { source_path: item, dest_path: dest }
|
2627
4558
|
@pB.say(' Item(s) symlinked here.')
|
2628
4559
|
end
|
2629
4560
|
rescue => e
|
2630
4561
|
@pB.say(e.to_s)
|
2631
4562
|
end
|
2632
4563
|
end
|
4564
|
+
|
4565
|
+
# Record undo information if operations were successful
|
4566
|
+
unless operations.empty?
|
4567
|
+
case type
|
4568
|
+
when 'copy'
|
4569
|
+
add_undo_operation({
|
4570
|
+
type: 'copy',
|
4571
|
+
copies: operations,
|
4572
|
+
timestamp: Time.now
|
4573
|
+
})
|
4574
|
+
when 'move'
|
4575
|
+
add_undo_operation({
|
4576
|
+
type: 'move',
|
4577
|
+
moves: operations,
|
4578
|
+
timestamp: Time.now
|
4579
|
+
})
|
4580
|
+
when 'link'
|
4581
|
+
add_undo_operation({
|
4582
|
+
type: 'link',
|
4583
|
+
links: operations,
|
4584
|
+
timestamp: Time.now
|
4585
|
+
})
|
4586
|
+
end
|
4587
|
+
end
|
4588
|
+
|
2633
4589
|
@tagged = []
|
2634
4590
|
|
2635
4591
|
# Set update flags for proper refresh - let render system handle the actual refresh
|
@@ -2709,9 +4665,13 @@ def open_selected(html = nil) # OPEN SELECTED FILE {{{2
|
|
2709
4665
|
if File.directory?(@selected) # Dir? just cd into it
|
2710
4666
|
mark_latest
|
2711
4667
|
Dir.chdir(@selected) rescue nil
|
4668
|
+
track_directory_access(@selected)
|
2712
4669
|
return
|
2713
4670
|
end
|
2714
4671
|
|
4672
|
+
# Track file access when opening files
|
4673
|
+
track_file_access(@selected)
|
4674
|
+
|
2715
4675
|
# Check if this file should use an interactive program (same as § prefix logic)
|
2716
4676
|
if !html && (prog = get_interactive_program(@selected))
|
2717
4677
|
cmd = "#{prog} #{Shellwords.escape(@selected)}"
|
@@ -2799,7 +4759,8 @@ def conf_write(all: false) # WRITE TO ~/.rtfm/conf {{{2
|
|
2799
4759
|
'hash' => "@hash = #{@hash}",
|
2800
4760
|
'history' => "@history = #{@pCmd.history.reverse.uniq.reverse.last(40)}",
|
2801
4761
|
'rubyhistory' => "@rubyhistory = #{@pRuby.history.reverse.uniq.reverse.last(40)}",
|
2802
|
-
'aihistory' => "@aihistory = #{@pAI.history.reverse.uniq.reverse.last(40)}"
|
4762
|
+
'aihistory' => "@aihistory = #{@pAI.history.reverse.uniq.reverse.last(40)}",
|
4763
|
+
'sshhistory' => "@sshhistory = #{@pSsh.history.reverse.uniq.reverse.last(40)}"
|
2803
4764
|
}
|
2804
4765
|
if all
|
2805
4766
|
assignments.merge!(
|
@@ -2865,6 +4826,13 @@ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
|
|
2865
4826
|
@pR.clear
|
2866
4827
|
end
|
2867
4828
|
begin
|
4829
|
+
# Handle remote mode separately
|
4830
|
+
if @remote_mode && @files && @files[@index] && @remote_files_cache[@index]
|
4831
|
+
selected_file = @remote_files_cache[@index]
|
4832
|
+
show_remote_file_info(selected_file)
|
4833
|
+
return
|
4834
|
+
end
|
4835
|
+
|
2868
4836
|
if @selected && File.directory?(@selected)
|
2869
4837
|
@pR.say(dirlist(left: false))
|
2870
4838
|
else # Look up first matching handler
|
@@ -3034,24 +5002,77 @@ end
|
|
3034
5002
|
|
3035
5003
|
def marks_info # SHOW MARKS IN RIGHT WINDOW {{{2
|
3036
5004
|
@marks = @marks.sort.to_h
|
3037
|
-
info =
|
5005
|
+
info = "Directory Marks".b.fg(156) + "\n"
|
5006
|
+
info << "=" * 50 + "\n\n"
|
5007
|
+
|
3038
5008
|
if @marks.empty?
|
3039
|
-
info
|
5009
|
+
info << "No marks set".fg(240) + "\n\n"
|
5010
|
+
info << "Use 'm' followed by a letter to set a mark\n".fg(249)
|
5011
|
+
info << "Use " + "'" + " followed by a letter to jump to mark".fg(249)
|
3040
5012
|
else
|
5013
|
+
info << "Current marks:".fg(226) + "\n\n"
|
3041
5014
|
@marks.each do |mark, dir|
|
3042
|
-
|
3043
|
-
|
3044
|
-
|
5015
|
+
# Color special marks differently
|
5016
|
+
mark_color = case mark
|
5017
|
+
when "'" then 196 # Red for 'latest' mark
|
5018
|
+
when /[0-9]/ then 220 # Yellow for number marks
|
5019
|
+
else 156 # Green for letter marks
|
5020
|
+
end
|
5021
|
+
|
5022
|
+
# Truncate long paths for better display
|
5023
|
+
display_dir = dir.length > 45 ? "..." + dir[-42..-1] : dir
|
5024
|
+
info << sprintf(" %s → %s\n", mark.fg(mark_color).b, display_dir.fg(249))
|
5025
|
+
|
5026
|
+
# Add spacing after special marks
|
5027
|
+
info << "\n" if mark == "'"
|
5028
|
+
info << "\n" if mark == '5'
|
3045
5029
|
end
|
5030
|
+
info << "\n" + "Press " + "'" + " + letter to jump".fg(240)
|
3046
5031
|
end
|
3047
|
-
@pR.say(info
|
5032
|
+
@pR.say(info)
|
3048
5033
|
end
|
3049
5034
|
|
3050
5035
|
def tagged_info # SHOW THE LIST OF TAGGED ITEMS IN @pR {{{2
|
3051
|
-
info =
|
3052
|
-
info
|
3053
|
-
|
3054
|
-
|
5036
|
+
info = "Tagged Items".b.fg(204) + "\n"
|
5037
|
+
info << "=" * 50 + "\n\n"
|
5038
|
+
|
5039
|
+
# Summary information
|
5040
|
+
size_mb = (@tagsize.to_f / 1_000_000).round(2)
|
5041
|
+
info << "Summary:\n".fg(226)
|
5042
|
+
info << sprintf(" %-12s %d\n", "Items:", @tagged.size)
|
5043
|
+
info << sprintf(" %-12s %.2f MB\n", "Total size:", size_mb)
|
5044
|
+
info << "\n"
|
5045
|
+
|
5046
|
+
if @tagged.empty?
|
5047
|
+
info << "No items tagged\n".fg(240)
|
5048
|
+
info << "\nUse " + "SPACE".fg(156) + " to tag/untag items\n".fg(249)
|
5049
|
+
else
|
5050
|
+
info << "Tagged files:\n".fg(226)
|
5051
|
+
@tagged.each_with_index do |item, i|
|
5052
|
+
# Show just filename for long paths
|
5053
|
+
display_name = File.basename(item)
|
5054
|
+
full_path = item.length > 50 ? "..." + item[-47..-1] : item
|
5055
|
+
|
5056
|
+
# Color based on file type
|
5057
|
+
color = File.directory?(item) ? 156 : 249
|
5058
|
+
info << sprintf(" %2d. %s\n", i + 1, display_name.fg(color))
|
5059
|
+
|
5060
|
+
# Show full path on separate line if truncated
|
5061
|
+
if item.length > 50
|
5062
|
+
info << sprintf(" %s\n", full_path.fg(240))
|
5063
|
+
end
|
5064
|
+
end
|
5065
|
+
end
|
5066
|
+
|
5067
|
+
info << "\n" + "Currently selected:\n".fg(226)
|
5068
|
+
selected_name = File.basename(@selected)
|
5069
|
+
selected_color = File.directory?(@selected) ? 156 : 249
|
5070
|
+
info << " \u2192 " + selected_name.fg(selected_color).b + "\n"
|
5071
|
+
if @selected.length > 50
|
5072
|
+
info << " " + @selected.fg(240) + "\n"
|
5073
|
+
end
|
5074
|
+
|
5075
|
+
@pR.say(info)
|
3055
5076
|
end
|
3056
5077
|
|
3057
5078
|
# MAIN PROGRAM {{{1
|
@@ -3087,8 +5108,8 @@ end
|
|
3087
5108
|
@pSearch = Pane.new( 1, @h, @w, 1, 255, @searchcolor)
|
3088
5109
|
@pRuby = Pane.new( 1, @h, @w, 1, 255, @rubycolor)
|
3089
5110
|
@pAI = Pane.new( 1, @h, @w, 1, 255, @aicolor)
|
5111
|
+
@pSsh = Pane.new( 1, @h, @w, 1, 255, @sshcolor)
|
3090
5112
|
# rubocop:enable Naming/VariableName
|
3091
|
-
#checkpoint("Panes created")
|
3092
5113
|
|
3093
5114
|
## Set pane properties {{{2
|
3094
5115
|
@pTab.update = true
|
@@ -3103,6 +5124,8 @@ end
|
|
3103
5124
|
@pRuby.history = @rubyhistory
|
3104
5125
|
@pAI.record = true
|
3105
5126
|
@pAI.history = @aihistory
|
5127
|
+
@pSsh.record = true
|
5128
|
+
@pSsh.history = @sshhistory
|
3106
5129
|
|
3107
5130
|
# Report plugin errors {{{2
|
3108
5131
|
@pR.say("Plugin load errors:\n" + @plugin_errors.join("\n").fg(196)) if @plugin_errors.any?
|
@@ -3123,7 +5146,6 @@ end
|
|
3123
5146
|
$stdin.getc while $stdin.wait_readable(0)
|
3124
5147
|
|
3125
5148
|
## THE LOOP {{{2
|
3126
|
-
#checkpoint("Program started")
|
3127
5149
|
loop do
|
3128
5150
|
@dir_old = Dir.pwd
|
3129
5151
|
|