rtfm-filemanager 5.10.4 → 6.0.1

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