rtfm-filemanager 5.10.4 → 6.0.0

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