rtfm-filemanager 5.9 → 5.10.3

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 (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +25 -4
  3. data/bin/rtfm +988 -111
  4. metadata +5 -5
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.9' # Delete/move message in bottom pane, bug fix on terminal restoration
21
+ @version = '5.10.3' # Fixed color rendering issues with rcurses 4.9.4
22
22
 
23
23
  # SAVE & STORE TERMINAL {{{1
24
24
  ORIG_STTY = `stty -g`.chomp
@@ -51,6 +51,32 @@ require 'bootsnap/setup' # Speed up subsequent requires
51
51
  # $last_checkpoint = now
52
52
  #end
53
53
 
54
+ def check_image_redraw # {{{2
55
+ # Only check periodically to avoid performance impact
56
+ now = Time.now
57
+ return if now - @last_focus_check < @focus_check_interval
58
+ @last_focus_check = now
59
+
60
+ # Only check if we have an image currently displayed
61
+ return unless @image && @current_image_path
62
+
63
+ begin
64
+ # Check if terminal has focus and if image needs redrawing
65
+ active_window = `xdotool getactivewindow 2>/dev/null`.chomp
66
+ return if active_window.empty?
67
+
68
+ # Simple heuristic: if we can't find any w3mimgdisplay processes,
69
+ # the image overlay was probably cleared and needs redrawing
70
+ img_processes = `pgrep w3mimgdisplay 2>/dev/null`.chomp
71
+ if img_processes.empty? && File.exist?(@current_image_path)
72
+ # Redraw the image
73
+ showimage(Shellwords.escape(@current_image_path))
74
+ end
75
+ rescue
76
+ # Silently fail - we don't want focus checking to break anything
77
+ end
78
+ end
79
+
54
80
  # LOAD LIBRARIES {{{1
55
81
  begin
56
82
  require 'rcurses'
@@ -63,12 +89,14 @@ rescue StandardError => e
63
89
  exit 1
64
90
  end
65
91
  require 'tmpdir'
92
+ require 'set' # For symlink loop detection
66
93
  # Lazy-load to speed up startup
67
94
  autoload :Shellwords, 'shellwords'
68
95
  autoload :Timeout, 'timeout'
69
96
  autoload :Open3, 'open3'
70
97
  autoload :PTY, 'pty'
71
98
  autoload :OpenAI, 'ruby/openai'
99
+ autoload :Tempfile, 'tempfile'
72
100
  #checkpoint("Libraries loaded")
73
101
 
74
102
  # FIX TERMINAL MESSAGE BLEED-THROUGH {{{1
@@ -91,15 +119,47 @@ $stderr.reopen(logfile)
91
119
 
92
120
  # RCURSES CLASS EXTENSION {{{1
93
121
  module Rcurses
94
- # Add attributes, amend 'say' to set update to false
122
+ # Add location attribute for compatibility
95
123
  class Pane
96
- attr_accessor :update, :locate
124
+ attr_accessor :locate
125
+
126
+ # Compatibility layer for the new update control system
127
+ def update=(value)
128
+ # TEMPORARILY DISABLED: If rcurses 4.9.0+ is available, use new suspend/resume system
129
+ # if self.respond_to?(:suspend_updates)
130
+ # if value
131
+ # resume_updates
132
+ # else
133
+ # suspend_updates
134
+ # end
135
+ # else
136
+ # Fallback for older rcurses versions
137
+ @update = value
138
+ # end
139
+ end
140
+
141
+ def update
142
+ # For older rcurses versions compatibility
143
+ @update.nil? ? true : @update
144
+ end
145
+
146
+ # Restore original say method override for compatibility
97
147
  alias original_say say
98
148
  def say(text)
99
149
  original_say(text)
100
150
  self.update = false
101
151
  end
102
152
  end
153
+
154
+ # Batch update helper for rcurses 4.9.0+
155
+ def self.batch_refresh(&block)
156
+ if Rcurses.respond_to?(:batch_updates)
157
+ Rcurses.batch_updates(&block)
158
+ else
159
+ # Fallback for older versions - just execute the block
160
+ block.call
161
+ end
162
+ end
103
163
  end
104
164
 
105
165
  # CREATE DIRS & SET FILE CONSTS {{{1
@@ -170,6 +230,15 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
170
230
  T = Show currently tagged items in right pane
171
231
  u = Untag all tagged items
172
232
 
233
+ TAB MANAGEMENT
234
+ ] = Create new tab in current directory
235
+ [ = Close current tab (keeps at least one tab open)
236
+ J = Previous tab (wraps around)
237
+ K = Next tab (wraps around)
238
+ } = Duplicate current tab (creates a copy with same directory)
239
+ { = Rename current tab
240
+ 1-9 = Switch to tab by number (1 = first tab, 2 = second tab, etc.)
241
+
173
242
  MANIPULATE ITEMS
174
243
  p = Put (copy) tagged items here
175
244
  P = PUT (move) tagged items here
@@ -411,11 +480,28 @@ else
411
480
  end
412
481
  @showimage = false unless cmd?('xwininfo') && cmd?('xdotool')
413
482
 
483
+ # Image auto-redraw variables
484
+ @current_image_path = nil # Path of currently displayed image
485
+ @last_focus_check = Time.now
486
+ @focus_check_interval = 0.5 # Check focus every 500ms
487
+
414
488
  @bat = cmd?('bat') ? 'bat' : 'batcat'
415
489
 
416
490
  ## Set encoding for $stdin to utf-8 {{{2
417
491
  $stdin.set_encoding(Encoding::UTF_8)
418
492
 
493
+ ## Initialize rendering optimization variables {{{2
494
+ @last_render_state = nil
495
+
496
+ # Custom caching system - could be replaced with rcurses 4.9.0+ built-in cache
497
+ # when Rcurses.cache_set/cache_get becomes available
498
+ @dir_cache = {}
499
+ @dir_cache_size = 0
500
+ @max_cache_entries = 50
501
+ @metadata_cache = {}
502
+ @metadata_cache_size = 0
503
+ @max_metadata_entries = 100
504
+
419
505
  # INITIALIZE VARIABLES {{{1
420
506
  ## These can be set by user in ~/.rtfm/conf
421
507
  ### Saved on quit ('q')
@@ -459,6 +545,211 @@ $stdin.set_encoding(Encoding::UTF_8)
459
545
  @index = 0 # Set chosen item to first on startup
460
546
  @tagsize = 0 # Size (in MB) of tagged items
461
547
  @navi = '' # Navi result when navi is invoked
548
+ # @dual_pane removed - using tabs for multi-directory navigation
549
+
550
+ ## Dual-pane directory navigation variables
551
+ # Dual-pane variables removed - using tabs for multi-directory navigation
552
+ # @active_pane, @index_left/right, @files_left/right, @selected_left/right
553
+ # @directory_left/right, @pwd_left/right all removed
554
+
555
+ ## Tab system variables
556
+ @tabs = [] # Array of tab objects
557
+ @current_tab = 0 # Index of currently active tab
558
+ @tab_counter = 0 # Counter for generating unique tab IDs
559
+ @tab_bar_visible = false # Whether tab bar is currently shown
560
+ @tab_bar_hide_time = 0 # Time when tab bar should be hidden
561
+
562
+ # TAB MANAGEMENT FUNCTIONS {{{1
563
+ def create_tab(directory = Dir.pwd, name = nil) # {{{2
564
+ @tab_counter += 1
565
+ tab = {
566
+ id: @tab_counter,
567
+ name: name || File.basename(directory),
568
+ directory: directory,
569
+ index: 0,
570
+ tagged: [],
571
+ searched: '',
572
+ lsfiles: '',
573
+ lsmatch: '',
574
+ directory_memory: {},
575
+ files: []
576
+ }
577
+ @tabs << tab
578
+ tab
579
+ end
580
+
581
+ def current_tab # {{{2
582
+ @tabs[@current_tab] || create_tab
583
+ end
584
+
585
+ def switch_to_tab(tab_index) # {{{2
586
+ return if tab_index < 0 || tab_index >= @tabs.size
587
+
588
+ # Save current tab state
589
+ save_tab_state
590
+
591
+ # Switch to new tab
592
+ @current_tab = tab_index
593
+ restore_tab_state
594
+
595
+ # Update display
596
+ @pL.update = @pR.update = @pT.update = @pTab.update = true
597
+ end
598
+
599
+ def save_tab_state # {{{2
600
+ return unless @tabs[@current_tab]
601
+
602
+ tab = @tabs[@current_tab]
603
+ tab[:directory] = Dir.pwd
604
+ tab[:name] = File.basename(Dir.pwd) # Update tab name to current directory
605
+ tab[:index] = @index
606
+ tab[:tagged] = @tagged.dup
607
+ tab[:searched] = @searched
608
+ tab[:lsfiles] = @lsfiles
609
+ tab[:lsmatch] = @lsmatch
610
+ tab[:directory_memory] = @directory.dup
611
+ tab[:files] = @files.dup if defined?(@files)
612
+
613
+ # Save dual-pane state if in dual-pane mode
614
+ # Dual-pane tab state removed - using single-pane mode only
615
+ end
616
+
617
+ def restore_tab_state # {{{2
618
+ tab = current_tab
619
+
620
+ begin
621
+ Dir.chdir(tab[:directory]) if Dir.exist?(tab[:directory])
622
+ rescue
623
+ # If directory doesn't exist, go to home
624
+ Dir.chdir
625
+ tab[:directory] = Dir.pwd
626
+ end
627
+
628
+ @index = tab[:index]
629
+ @tagged = tab[:tagged].dup
630
+ @searched = tab[:searched]
631
+ @lsfiles = tab[:lsfiles]
632
+ @lsmatch = tab[:lsmatch]
633
+ @directory = tab[:directory_memory].dup
634
+ @files = tab[:files].dup if tab[:files]
635
+
636
+ # Dual-pane state restoration removed - using single-pane mode only
637
+ end
638
+
639
+ def close_tab(tab_index = @current_tab) # {{{2
640
+ return if @tabs.size <= 1 # Always keep at least one tab
641
+
642
+ @tabs.delete_at(tab_index)
643
+
644
+ # Adjust current tab index if necessary
645
+ if @current_tab >= @tabs.size
646
+ @current_tab = @tabs.size - 1
647
+ elsif tab_index < @current_tab
648
+ @current_tab -= 1
649
+ end
650
+
651
+ restore_tab_state
652
+ @pL.update = @pR.update = @pT.update = true
653
+ end
654
+
655
+ def new_tab # {{{2
656
+ save_tab_state
657
+ tab = create_tab(Dir.pwd)
658
+ @current_tab = @tabs.size - 1
659
+ @pL.update = @pR.update = @pT.update = true
660
+ end
661
+
662
+ def duplicate_tab # {{{2
663
+ # Duplicate current tab with same directory and state
664
+ save_tab_state
665
+ current = current_tab
666
+ # Create a smart copy name
667
+ base_name = current[:name].gsub(/ Copy.*$/, '') # Remove existing " Copy" suffixes
668
+ copy_name = "#{base_name} Copy"
669
+ tab = create_tab(current[:directory], copy_name)
670
+ @current_tab = @tabs.size - 1
671
+ @pL.update = @pR.update = @pT.update = true
672
+ end
673
+
674
+ def rename_tab # {{{2
675
+ @pB.say("Enter new tab name: ".fg(156))
676
+ name = gets_custom("Tab name: ", current_tab[:name])
677
+ return if name.nil? || name.strip.empty?
678
+
679
+ current_tab[:name] = name.strip
680
+ @pT.update = true # Update tab bar display
681
+ @pB.clear
682
+ end
683
+
684
+
685
+ # Keyboard handler functions for tabs {{{2
686
+ def prev_tab # {{{2
687
+ new_index = @current_tab > 0 ? @current_tab - 1 : @tabs.size - 1
688
+ switch_to_tab(new_index)
689
+ end
690
+
691
+ def next_tab # {{{2
692
+ new_index = @current_tab < @tabs.size - 1 ? @current_tab + 1 : 0
693
+ switch_to_tab(new_index)
694
+ end
695
+
696
+ def switch_tab_1; switch_to_tab(0); end
697
+ def switch_tab_2; switch_to_tab(1); end
698
+ def switch_tab_3; switch_to_tab(2); end
699
+ def switch_tab_4; switch_to_tab(3); end
700
+ def switch_tab_5; switch_to_tab(4); end
701
+ def switch_tab_6; switch_to_tab(5); end
702
+ def switch_tab_7; switch_to_tab(6); end
703
+ def switch_tab_8; switch_to_tab(7); end
704
+ def switch_tab_9; switch_to_tab(8); end
705
+
706
+ def show_tab_overlay # {{{2
707
+ # Show tab overlay for 2 seconds, then restore top pane
708
+ @tab_bar_visible = true
709
+ @tab_bar_hide_time = Time.now + 2
710
+
711
+ # Show tab overlay immediately
712
+ @pTab.text = render_tab_bar
713
+ @pTab.refresh
714
+ end
715
+
716
+ def update_tab_overlay # {{{2
717
+ # Check if we should hide the tab overlay and restore top pane
718
+ if @tab_bar_visible && Time.now >= @tab_bar_hide_time
719
+ @tab_bar_visible = false
720
+ @pT.refresh # Restore the top pane display
721
+ end
722
+ end
723
+
724
+ def render_tab_bar # {{{2
725
+ tab_display = []
726
+ @tabs.each_with_index do |tab, i|
727
+ name = tab[:name]
728
+ # Show current directory in tab if different from name
729
+ dir = File.basename(tab[:directory] || '')
730
+ display_name = (name == "Main" && dir != name) ? "#{name}:#{dir}" : name
731
+ display_name = display_name[0..12] + '…' if display_name.length > 14 # Truncate long names
732
+
733
+ if i == @current_tab
734
+ # Active tab with bright background and bold text
735
+ tab_display << " #{i+1}:#{display_name} ".bg(226).fg(0).b # Yellow background, black text, bold
736
+ else
737
+ # Inactive tab with subtle styling
738
+ tab_display << " #{i+1}:#{display_name} ".fg(244) # Gray text
739
+ end
740
+ end
741
+
742
+ # Enhanced tab bar with better shortcuts
743
+ if @tabs.size == 1
744
+ # Show enhanced help for single tab
745
+ "#{tab_display.join('')} | ]:new }:dup {:rename | Auto-hiding in #{(@tab_bar_hide_time - Time.now).ceil}s"
746
+ else
747
+ # Show all available tab shortcuts
748
+ "#{tab_display.join('')} | ]:new }:dup {:rename [:close J/K:nav 1-9:switch"
749
+ end
750
+ end
751
+
752
+ # DUAL-PANE HELPER FUNCTIONS REMOVED - using tabs for multi-directory navigation
462
753
 
463
754
  # LOAD CONFIG {{{1
464
755
  ## Get variables from config file (written back to ~/.rtfm/conf when exit via 'q')
@@ -480,6 +771,9 @@ load_config
480
771
  # Handle start dir {{{2
481
772
  Dir.chdir(ARGV.shift) if ARGV[0] && File.directory?(ARGV[0])
482
773
 
774
+ # Initialize first tab {{{2
775
+ create_tab(Dir.pwd, "Main")
776
+
483
777
  # OPENAI SETUP {{{1
484
778
  def chat_history # {{{2
485
779
  @chat_history ||= [
@@ -561,6 +855,7 @@ KEYMAP = { # {{{2
561
855
  '-' => :toggle_preview,
562
856
  '_' => :toggle_image,
563
857
  'b' => :toggle_syntax,
858
+ # '{' key available for future use (dual-pane mode removed)
564
859
 
565
860
  # MOTION {{{3
566
861
  'DOWN' => :move_down,
@@ -580,6 +875,7 @@ KEYMAP = { # {{{2
580
875
  'PgUP' => :page_up,
581
876
  'END' => :go_last,
582
877
  'HOME' => :go_first,
878
+ # '}' key available for future use (dual-pane mode removed)
583
879
 
584
880
  # MARKS & JUMPING {{{3
585
881
  'm' => :set_mark,
@@ -601,6 +897,23 @@ KEYMAP = { # {{{2
601
897
  'T' => :show_tagged,
602
898
  'u' => :clear_tagged,
603
899
 
900
+ # TAB MANAGEMENT {{{3
901
+ ']' => :new_tab,
902
+ '[' => :close_tab,
903
+ 'J' => :prev_tab,
904
+ 'K' => :next_tab,
905
+ '}' => :duplicate_tab, # Duplicate current tab
906
+ '{' => :rename_tab, # Rename current tab
907
+ '1' => :switch_tab_1,
908
+ '2' => :switch_tab_2,
909
+ '3' => :switch_tab_3,
910
+ '4' => :switch_tab_4,
911
+ '5' => :switch_tab_5,
912
+ '6' => :switch_tab_6,
913
+ '7' => :switch_tab_7,
914
+ '8' => :switch_tab_8,
915
+ '9' => :switch_tab_9,
916
+
604
917
  # MANIPULATE ITEMS {{{3
605
918
  'p' => :copy_items,
606
919
  'P' => :move_items,
@@ -645,8 +958,8 @@ KEYMAP = { # {{{2
645
958
  'S-TAB' => :page_up_right,
646
959
 
647
960
  # CLIPBOARD COPY {{{3
648
- 'y' => :copy_path,
649
- 'Y' => :copy_path,
961
+ 'y' => :copy_path_primary,
962
+ 'Y' => :copy_path_clipboard,
650
963
  'C-Y' => :copy_right,
651
964
 
652
965
  # SYSTEM SHORTCUTS {{{3
@@ -675,6 +988,10 @@ end
675
988
  # MAIN GETKEY FOR USER INPUT {{{2
676
989
  def getkey # {{{3
677
990
  chr = getchr(1)
991
+
992
+ # Check for image redraw on focus regain (even if no key was pressed)
993
+ check_image_redraw if @showimage
994
+
678
995
  return unless chr
679
996
 
680
997
  showimage('clear') if @image
@@ -685,6 +1002,7 @@ def getkey # {{{3
685
1002
  end
686
1003
  rescue Errno::EIO
687
1004
  # ignore transient TTY/read errors when upon focus switch
1005
+ # Note: rcurses 4.9.0+ has enhanced error handling, but keeping this for compatibility
688
1006
  return
689
1007
  rescue StandardError => e
690
1008
  errormsg('⚷ Error in getkey', e)
@@ -727,13 +1045,28 @@ end
727
1045
  def change_width # {{{3
728
1046
  @width += 1
729
1047
  @width = 2 if @width == 8
1048
+
1049
+ if @dual_pane
1050
+ # Show width setting info for dual-pane mode using the new ratio calculation
1051
+ dir_panes_ratio = [0.5 - (@width - 2) * 0.034, 0.33].max
1052
+ preview_ratio = 1.0 - dir_panes_ratio
1053
+ @pB.say("Width: #{@width} (Dir panes: #{(dir_panes_ratio * 100).to_i}%, Preview: #{(preview_ratio * 100).to_i}%)")
1054
+ else
1055
+ @pB.say("Width: #{@width}")
1056
+ end
1057
+
730
1058
  refresh
731
- @pR.update = @pB.update = true
1059
+ @pL.update = @pR.update = @pT.update = @pB.update = true
1060
+ # Also update dual-pane objects if they exist
1061
+ if @dual_pane && @pLeft && @pRight && @pPreview
1062
+ @pLeft.update = @pRight.update = @pPreview.update = true
1063
+ end
732
1064
  end
733
1065
 
734
1066
  def toggle_border # {{{3
735
1067
  @border = (@border + 1) % 4
736
1068
  setborder
1069
+ @pL.update = @pR.update = @pT.update = @pB.update = true
737
1070
  end
738
1071
 
739
1072
  def toggle_preview # {{{3
@@ -754,14 +1087,18 @@ def toggle_syntax # {{{3
754
1087
  @pR.update = true
755
1088
  end
756
1089
 
1090
+ # toggle_dual_pane function removed - using tabs for multi-directory navigation
1091
+
757
1092
  # MOTION {{{2
758
1093
  def move_down # {{{3
759
1094
  @index = @index >= @max_index ? @min_index : @index + 1
1095
+ @pL.update = true
760
1096
  @pR.update = @pB.update = true
761
1097
  end
762
1098
 
763
1099
  def move_up # {{{3
764
1100
  @index = @index <= @min_index ? @max_index : @index - 1
1101
+ @pL.update = true
765
1102
  @pR.update = @pB.update = true
766
1103
  end
767
1104
 
@@ -776,9 +1113,12 @@ def move_left # {{{3
776
1113
  @directory[parent] = child_idx
777
1114
  mark_latest
778
1115
  Dir.chdir(parent)
779
- @pL.update = @pR.update = @pB.update = true
1116
+ @pL.update = true
1117
+ @pR.update = @pB.update = true
780
1118
  end
781
1119
 
1120
+ # dirlist_simple function removed - was only for dual-pane navigation
1121
+
782
1122
  def move_right # {{{3
783
1123
  @directory[Dir.pwd] = @index
784
1124
  mark_latest
@@ -816,6 +1156,8 @@ def go_first # {{{3
816
1156
  @pR.update = @pB.update = true
817
1157
  end
818
1158
 
1159
+ # switch_pane function removed - using tabs for multi-directory navigation
1160
+
819
1161
  # MARKS & JUMPING {{{2
820
1162
  def set_mark # {{{3
821
1163
  marks_info
@@ -852,9 +1194,46 @@ def jump_to_mark # {{{3
852
1194
  end
853
1195
 
854
1196
  def go_home # {{{3
855
- @directory[Dir.pwd] = @index
856
- mark_latest
857
- Dir.chdir
1197
+ if @dual_pane
1198
+ # In dual-pane mode, navigate the active pane to home directory
1199
+ home_dir = Dir.home
1200
+
1201
+ # Get home directory listing
1202
+ newfiles = command(
1203
+ "ls #{Shellwords.escape(home_dir)} #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}"
1204
+ ).pure.split("\n")
1205
+
1206
+ # Update the active pane's state
1207
+ if @active_pane == :left
1208
+ @directory_left[@pwd_left] = @index_left
1209
+ @pwd_left = home_dir
1210
+ @index_left = 0
1211
+ @files_left = newfiles
1212
+ @selected_left = newfiles[0] if newfiles.length > 0
1213
+ @directory_left[home_dir] = 0
1214
+ @pLeft.update = true
1215
+ else
1216
+ @directory_right[@pwd_right] = @index_right
1217
+ @pwd_right = home_dir
1218
+ @index_right = 0
1219
+ @files_right = newfiles
1220
+ @selected_right = newfiles[0] if newfiles.length > 0
1221
+ @directory_right[home_dir] = 0
1222
+ @pRight.update = true
1223
+ end
1224
+
1225
+ # Update compatibility variables
1226
+ @index = @active_pane == :left ? @index_left : @index_right
1227
+ @files = @active_pane == :left ? @files_left : @files_right
1228
+ @selected = @active_pane == :left ? @selected_left : @selected_right
1229
+
1230
+ @pPreview.update = true if @pPreview
1231
+ else
1232
+ # Original single-pane logic
1233
+ @directory[Dir.pwd] = @index
1234
+ mark_latest
1235
+ Dir.chdir
1236
+ end
858
1237
  @pR.update = @pB.update = true
859
1238
  end
860
1239
 
@@ -862,11 +1241,25 @@ def follow_symlink # {{{3
862
1241
  @directory[Dir.pwd] = @index; mark_latest
863
1242
  if File.symlink?(@selected)
864
1243
  begin
1244
+ # Track visited symlinks to prevent loops
1245
+ @symlink_history ||= Set.new
1246
+ resolved_path = File.realpath(@selected)
1247
+
1248
+ if @symlink_history.include?(resolved_path)
1249
+ @pB.say("Error: Symlink loop detected".fg(196))
1250
+ return
1251
+ end
1252
+
1253
+ @symlink_history.add(resolved_path)
865
1254
  target = File.readlink(@selected)
866
1255
  target = File.expand_path(target, File.dirname(@selected)) unless target.start_with?('/')
867
1256
  Dir.chdir(target)
1257
+
1258
+ # Clear history after successful navigation
1259
+ @symlink_history.clear
868
1260
  rescue => e
869
1261
  @pB.say("Error following symlink: #{e}")
1262
+ @symlink_history&.clear
870
1263
  end
871
1264
  end
872
1265
  @pB.update = true
@@ -910,15 +1303,55 @@ end
910
1303
 
911
1304
  # TAGGING {{{2
912
1305
  def tag_current # {{{3
913
- item = @selected
914
- if @tagged.include?(item)
915
- @tagged.delete(item); @tagsize -= File.size(item) rescue 0
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
916
1341
  else
917
- @tagged.push(item); @tagsize += File.size(item) rescue 0
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
918
1351
  end
919
- @index = [@index + 1, (@files.size - 1)].min
1352
+
920
1353
  @pB.say(" Tagged #{@tagged.size} files (#{(@tagsize.to_f / 1_000_000).round(2)}MB)".fg(204))
921
- @pB.update = false; @pR.update = true; @pL.update = true
1354
+ @pB.update = false; @pR.update = true
922
1355
  end
923
1356
 
924
1357
  def tag_pattern # {{{3
@@ -949,11 +1382,13 @@ end
949
1382
  # MANIPULATE ITEMS {{{2
950
1383
  def copy_items # {{{3
951
1384
  copy_move_link('copy')
1385
+ # Dual-pane refresh is handled in copy_move_link function
952
1386
  @pR.update = true
953
1387
  end
954
1388
 
955
1389
  def move_items # {{{3
956
1390
  copy_move_link('move')
1391
+ # Dual-pane refresh is handled in copy_move_link function
957
1392
  @pR.update = true
958
1393
  end
959
1394
 
@@ -979,6 +1414,7 @@ end
979
1414
 
980
1415
  def link_items # {{{3
981
1416
  copy_move_link('link')
1417
+ # Dual-pane refresh is handled in copy_move_link function
982
1418
  @pR.update = true
983
1419
  end
984
1420
 
@@ -991,18 +1427,22 @@ def delete_items # {{{3
991
1427
  past_action = @trash ? 'Moved' : 'Deleted'
992
1428
  @pB.say(" #{action} selected and tagged? (press 'y')")
993
1429
  if getchr == 'y'
994
- # collect & escape every path
995
- paths = (@tagged + [@selected]).uniq
996
- esc = paths.map { |p| Shellwords.escape(p) }.join(' ')
997
- if @trash
998
- esc_trash = Shellwords.escape(TRASH_DIR)
999
- command("mv -f #{esc} #{esc_trash}")
1430
+ # collect & escape every path with existence verification
1431
+ paths = (@tagged + [@selected]).uniq.select { |p| File.exist?(p) }
1432
+ if paths.empty?
1433
+ @pB.say("No valid items to #{action.downcase}".fg(196))
1000
1434
  else
1001
- command("rm -rf #{esc}")
1435
+ esc = paths.map { |p| Shellwords.escape(p) }.join(' ')
1436
+ if @trash
1437
+ esc_trash = Shellwords.escape(TRASH_DIR)
1438
+ command("mv -f #{esc} #{esc_trash}")
1439
+ else
1440
+ command("rm -rf #{esc}")
1441
+ end
1442
+ @tagged.clear
1443
+ refresh_right
1444
+ @pB.say("#{past_action} #{paths.size} items#{@trash ? " to #{TRASH_DIR}" : ''}".fg(204))
1002
1445
  end
1003
- @tagged.clear
1004
- refresh_right
1005
- @pB.say("#{past_action} #{paths.size} items#{@trash ? " to #{TRASH_DIR}" : ''}".fg(204))
1006
1446
  else
1007
1447
  @pB.update = true
1008
1448
  end
@@ -1327,16 +1767,76 @@ def page_up_right # {{{3
1327
1767
  end
1328
1768
 
1329
1769
  # CLIPBOARD COPY {{{2
1330
- def copy_path # {{{3
1770
+ def copy_path_primary # {{{3
1771
+ if @selected
1772
+ # Get the correct path in dual-pane mode
1773
+ path = get_selected_full_path
1774
+ if path
1775
+ copy_to_clipboard(path, 'primary')
1776
+ @pB.say(' Path copied to primary selection (middle-click to paste)')
1777
+ else
1778
+ @pB.say(' No selected item path to copy')
1779
+ end
1780
+ else
1781
+ @pB.say(' No selected item path to copy')
1782
+ end
1783
+ end
1784
+
1785
+ def copy_path_clipboard # {{{3
1331
1786
  if @selected
1332
- clip = "xclip -selection #{getchr == 'Y' ? 'clipboard' : 'primary'} -in -loops 1"
1333
- @pB.say(' Path copied')
1334
- shell("echo -n '#{@selected}' | #{clip}")
1787
+ # Get the correct path in dual-pane mode
1788
+ path = get_selected_full_path
1789
+ if path
1790
+ copy_to_clipboard(path, 'clipboard')
1791
+ @pB.say(' Path copied to clipboard (Ctrl+V to paste)')
1792
+ else
1793
+ @pB.say(' No selected item path to copy')
1794
+ end
1335
1795
  else
1336
1796
  @pB.say(' No selected item path to copy')
1337
1797
  end
1338
1798
  end
1339
1799
 
1800
+ def copy_to_clipboard(text, selection = 'clipboard') # {{{3
1801
+ # Robust clipboard copying with fallback mechanisms
1802
+ escaped_text = text.gsub("'", "'\"'\"'") # Escape single quotes for shell
1803
+
1804
+ # Try xsel first (often more reliable), then fall back to xclip
1805
+ success = false
1806
+
1807
+ if cmd?('xsel')
1808
+ cmd = case selection
1809
+ when 'primary' then "echo -n '#{escaped_text}' | xsel --primary --input"
1810
+ else "echo -n '#{escaped_text}' | xsel --clipboard --input"
1811
+ end
1812
+ success = system(cmd + ' 2>/dev/null')
1813
+ end
1814
+
1815
+ unless success
1816
+ # Fallback to xclip with timeout to prevent hanging
1817
+ cmd = case selection
1818
+ when 'primary' then "echo -n '#{escaped_text}' | timeout 2 xclip -selection primary -in"
1819
+ else "echo -n '#{escaped_text}' | timeout 2 xclip -selection clipboard -in"
1820
+ end
1821
+ success = system(cmd + ' 2>/dev/null')
1822
+ end
1823
+
1824
+ unless success
1825
+ @pB.say(' Warning: Clipboard copy may have failed')
1826
+ end
1827
+ end
1828
+
1829
+ def get_selected_full_path # {{{3
1830
+ # Helper function to get the correct full path in both single and dual-pane modes
1831
+ if @dual_pane
1832
+ active_dir = @active_pane == :left ? @pwd_left : @pwd_right
1833
+ active_selected = @active_pane == :left ? @selected_left : @selected_right
1834
+ return active_selected ? File.join(active_dir, active_selected) : nil
1835
+ else
1836
+ return @selected
1837
+ end
1838
+ end
1839
+
1340
1840
  def copy_right # {{{3
1341
1841
  clip = 'xclip -selection clipboard'
1342
1842
  @pB.say(' Right pane copied to clipboard')
@@ -1530,26 +2030,133 @@ def ruby_debug # {{{3
1530
2030
  end
1531
2031
 
1532
2032
  # GENERIC FUNCTIONS {{{1
1533
- def dirlist(left: true) # LIST DIRECTORIES {{{2
1534
- @index ||= 0
1535
- @index = @index.to_i
2033
+ def get_cached_dirlist(dir, ls_options) # {{{2
2034
+ # TEMPORARILY DISABLED: Always bypass cache to debug flickering
2035
+ return nil
2036
+
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
+ # Clean old cache entries if cache is getting too large
2053
+ if @dir_cache_size >= @max_cache_entries
2054
+ @dir_cache.clear
2055
+ @dir_cache_size = 0
2056
+ end
2057
+
2058
+ # Generate new directory listing
2059
+ begin
2060
+ purels = command("ls #{Shellwords.escape(dir)} #{ls_options}").pure.split("\n")
2061
+ colorls = command("ls --color #{Shellwords.escape(dir)} #{ls_options} #{@lslong}").split("\n")
2062
+
2063
+ result = { purels: purels, colorls: colorls }
2064
+ @dir_cache[cache_with_time] = result
2065
+ @dir_cache_size += 1
2066
+
2067
+ # Clean up old entries for this directory
2068
+ @dir_cache.delete_if { |key, _| key.start_with?("#{dir}:") && key != cache_with_time }
2069
+
2070
+ result
2071
+ rescue => e
2072
+ # Return empty result on error
2073
+ { purels: [], colorls: [] }
2074
+ end
2075
+ end
2076
+
2077
+ def get_cached_file_metadata(file_path) # {{{2
2078
+ return nil unless file_path && File.exist?(file_path)
2079
+
2080
+ begin
2081
+ file_stat = File.stat(file_path)
2082
+ cache_key = "#{file_path}:#{file_stat.mtime.to_i}:#{file_stat.size}"
2083
+ rescue
2084
+ return nil
2085
+ end
2086
+
2087
+ # Check if we have cached metadata
2088
+ cached_metadata = @metadata_cache[cache_key]
2089
+ return cached_metadata if cached_metadata
2090
+
2091
+ # Clean old cache entries if cache is getting too large
2092
+ if @metadata_cache_size >= @max_metadata_entries
2093
+ @metadata_cache.clear
2094
+ @metadata_cache_size = 0
2095
+ end
2096
+
2097
+ # Generate new metadata
2098
+ metadata = nil
2099
+ begin
2100
+ if file_path.match(@imagefile) && cmd?('identify')
2101
+ metadata = `identify -format " [%wx%h %m %[colorspace] %[bit-depth]-bit]" #{Shellwords.escape(file_path)} 2>/dev/null`.strip
2102
+ elsif file_path.match(@pdffile)
2103
+ info = `pdfinfo #{Shellwords.escape(file_path)} 2>/dev/null`
2104
+ pages = info[/^Pages:\s+(\d+)/, 1]
2105
+ metadata = pages ? " [#{pages} pages]" : nil
2106
+ end
2107
+
2108
+ # Cache the result (even if nil)
2109
+ @metadata_cache[cache_key] = metadata
2110
+ @metadata_cache_size += 1
2111
+
2112
+ # Clean up old entries for this file
2113
+ @metadata_cache.delete_if { |key, _| key.start_with?("#{file_path}:") && key != cache_key }
2114
+
2115
+ metadata
2116
+ rescue
2117
+ nil
2118
+ end
2119
+ end
2120
+
2121
+ def dirlist(left: true, directory: nil) # LIST DIRECTORIES {{{2
2122
+ current_index = @index || 0
2123
+ current_index = current_index.to_i
2124
+
1536
2125
  if left
1537
- dir = Dir.pwd
2126
+ dir = directory || Dir.pwd
1538
2127
  width = @pL.w
1539
2128
  else
1540
- dir = if @selected && File.directory?(@selected)
2129
+ dir = if directory
2130
+ directory
2131
+ elsif @selected && File.directory?(@selected)
1541
2132
  File.symlink?(@selected) ? File.realpath(@selected) : @selected.to_s
1542
2133
  else
1543
2134
  File.dirname(@selected)
1544
2135
  end
1545
2136
  width = @pR.w
1546
2137
  end
1547
- # Fetch plain names + colored lines
1548
- purels = command("ls #{Shellwords.escape(dir)} #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").pure.split("\n")
1549
- colorls = command("ls --color #{Shellwords.escape(dir)} #{@lsbase} #{@lsall} #{@lslong} #{@lsorder} #{@lsinvert} #{@lsuser}").split("\n")
2138
+ # Use cached directory listing
2139
+ ls_options = "#{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}"
2140
+ cached = get_cached_dirlist(dir, ls_options)
2141
+
2142
+ if cached
2143
+ purels = cached[:purels]
2144
+ colorls = cached[:colorls]
2145
+ else
2146
+ # No cache available, generate fresh directory listing
2147
+ begin
2148
+ color_output = command("ls #{Shellwords.escape(dir)} --color=always #{ls_options}")
2149
+ pure_output = command("ls #{Shellwords.escape(dir)} #{ls_options}")
2150
+ purels = pure_output.pure.split("\n")
2151
+ colorls = color_output.split("\n")
2152
+ rescue => e
2153
+ purels = []
2154
+ colorls = []
2155
+ end
2156
+ end
1550
2157
  colorls.shift if colorls[0]&.strip == "#{dir}:"
1551
2158
  colorls.shift if colorls[0]&.match?(/^total/)
1552
- if left && @orderchange # Keep the same @selected even when we re-sort
2159
+ if left && @orderchange && !directory # Keep the same @selected even when we re-sort (only for single-pane)
1553
2160
  basename = File.basename(@selected.to_s)
1554
2161
  new_idx = purels.index(basename)
1555
2162
  @index = new_idx if new_idx
@@ -1568,37 +2175,113 @@ def dirlist(left: true) # LIST DIRECTORIES {{{2
1568
2175
  t = entries.transpose
1569
2176
  purels = t[0] || []
1570
2177
  ls = t[1] || []
1571
- @files = purels if left
1572
- # Update @selected & @fileattr for left pane
1573
- if left && purels[@index]
1574
- @selected = Dir.pwd + '/' + purels[@index]
1575
- sfile = @selected.dup
1576
- sfile += '/' if File.directory?(@selected)
1577
- slsl_cmd = "ls -ldHlh --time-style=long-iso #{Shellwords.escape(sfile)}"
1578
- slsl = command(slsl_cmd)
1579
- a = slsl.split
1580
- @fileattr = a.size >= 7 ? "#{a[2]}:#{a[3]} #{a[0]} #{a[4]} #{a[5]} #{a[6]}" : ''
1581
- end
1582
- # Map & decorate each colored line
2178
+ # Only update global variables in single-pane mode
2179
+ if left && !directory
2180
+ @files = purels
2181
+ # Update @selected & @fileattr for left pane
2182
+ if purels[@index]
2183
+ @selected = Dir.pwd + '/' + purels[@index]
2184
+ sfile = @selected.dup
2185
+ sfile += '/' if File.directory?(@selected)
2186
+ slsl_cmd = "ls -ldHlh --time-style=long-iso #{Shellwords.escape(sfile)}"
2187
+ slsl = command(slsl_cmd)
2188
+ a = slsl.split
2189
+ @fileattr = a.size >= 7 ? "#{a[2]}:#{a[3]} #{a[0]} #{a[4]} #{a[5]} #{a[6]}" : ''
2190
+ end
2191
+ end
2192
+ # Map & decorate each colored line - optimized version
2193
+ base_dir = left ? (directory || Dir.pwd) : dir
2194
+ search_regex = @searched.empty? ? nil : /#{@searched}/
2195
+
1583
2196
  ls.map!.with_index do |el, i|
1584
2197
  n = el.to_s.clean_ansi
1585
2198
  n = n.shorten(width - 5).inject('…', -1) if n.pure.length > width - 6
1586
2199
  raw_name = (purels[i] || '').strip
1587
- base = left ? Dir.pwd : dir
1588
- fullpath = "#{base}/#{raw_name}"
1589
- n = n.inject('@', -1) if File.symlink?(fullpath)
1590
- n = n.inject('/', -1) if File.directory?(fullpath)
1591
- n = n.bg(238) if !raw_name.empty? && raw_name.match(/#{@searched}/) && @searched != ''
2200
+ next n if raw_name.empty?
2201
+
2202
+ fullpath = "#{base_dir}/#{raw_name}"
2203
+
2204
+ # Batch file system checks to reduce system calls
2205
+ is_symlink = File.symlink?(fullpath)
2206
+ is_directory = File.directory?(fullpath)
2207
+
2208
+ n = n.inject('@', -1) if is_symlink
2209
+ n = n.inject('/', -1) if is_directory
2210
+ n = n.bg(238) if search_regex && raw_name.match(search_regex)
1592
2211
  n = n.r if @tagged.include?(fullpath)
2212
+
1593
2213
  if left
1594
- n = (i == @index ? '→ ' + n.u : ' ' + n)
2214
+ if i == current_index
2215
+ n = '→ ' + n.u # Default terminal color
2216
+ else
2217
+ n = ' ' + n
2218
+ end
1595
2219
  end
1596
2220
  n
1597
2221
  end
1598
2222
  ls.join("\n")
1599
2223
  end
1600
2224
 
2225
+ def current_render_state # {{{2
2226
+ # Create a hash representing current state for conditional rendering
2227
+ # Note: pane_updates removed to avoid oscillation - panes update themselves when needed
2228
+ if @dual_pane
2229
+ # Dual-pane specific state tracking
2230
+ {
2231
+ dual_pane: true,
2232
+ active_pane: @active_pane,
2233
+ pwd_left: @pwd_left,
2234
+ pwd_right: @pwd_right,
2235
+ index_left: @index_left,
2236
+ index_right: @index_right,
2237
+ selected_left: @selected_left,
2238
+ selected_right: @selected_right,
2239
+ tagged_count: @tagged.size,
2240
+ preview: @preview,
2241
+ showimage: @showimage,
2242
+ searched: @searched,
2243
+ filters: [@filter, @filtered].join,
2244
+ tabs_state: [@tabs.size, @current_tab, @tabs.map { |t| t[:name] }.join('|')]
2245
+ }
2246
+ else
2247
+ # Single-pane state tracking
2248
+ {
2249
+ dual_pane: false,
2250
+ dir: Dir.pwd,
2251
+ index: @index,
2252
+ files_mtime: File.mtime(Dir.pwd).to_i,
2253
+ selected: @selected,
2254
+ tagged_count: @tagged.size,
2255
+ preview: @preview,
2256
+ showimage: @showimage,
2257
+ searched: @searched,
2258
+ filters: [@filter, @filtered].join,
2259
+ tabs_state: [@tabs.size, @current_tab, @tabs.map { |t| t[:name] }.join('|')]
2260
+ }
2261
+ end
2262
+ end
2263
+
2264
+ def needs_render? # {{{2
2265
+ current_state = current_render_state
2266
+ state_changed = @last_render_state != current_state
2267
+
2268
+ # Also check if any panes need updating (for refresh operations)
2269
+ panes_need_update = @pL&.update || @pR&.update || @pT&.update || @pB&.update
2270
+ if @dual_pane
2271
+ panes_need_update ||= @pLeft&.update || @pRight&.update || @pPreview&.update
2272
+ end
2273
+
2274
+ needs_update = state_changed || panes_need_update
2275
+ @last_render_state = current_state if state_changed
2276
+ needs_update
2277
+ end
2278
+
1601
2279
  def render # RENDER ALL PANES {{{2
2280
+ return unless needs_render?
2281
+
2282
+ # TEMPORARILY DISABLED: Use batch updates for rcurses 4.9.0+ performance improvement
2283
+ # Rcurses.batch_refresh do
2284
+
1602
2285
  # LEFT pane {{{3
1603
2286
  if @pL.update
1604
2287
  lefttext = @pL.text
@@ -1623,16 +2306,49 @@ def render # RENDER ALL PANES {{{2
1623
2306
  @index = @max_index if @index > @max_index
1624
2307
  @pL.refresh unless @pL.text == lefttext
1625
2308
  end
2309
+
2310
+ # DUAL-PANE render section removed - using tabs for multi-directory navigation
1626
2311
 
1627
- # RIGHT pane {{{3
2312
+ # RIGHT pane (or Preview pane in dual-pane mode) {{{3
1628
2313
  if @pR.update && @preview
1629
2314
  showimage('clear') if @image; @image = false
1630
2315
  righttext = @pR.text
1631
- if @selected && File.directory?(@selected)
1632
- @pR.text = dirlist(left: false)
2316
+
2317
+ # In dual-pane mode, construct full path from active pane
2318
+ if @dual_pane
2319
+ active_dir = @active_pane == :left ? @pwd_left : @pwd_right
2320
+ active_selected = @active_pane == :left ? @selected_left : @selected_right
2321
+
2322
+ if active_selected && !active_selected.empty?
2323
+ # Ensure we're only using the basename for the selected file
2324
+ selected_basename = File.basename(active_selected)
2325
+ full_selected_path = File.join(active_dir, selected_basename)
2326
+
2327
+ # Temporarily set @selected for existing preview functions to work
2328
+ old_selected = @selected
2329
+ @selected = full_selected_path
2330
+
2331
+ if File.exist?(full_selected_path) && File.directory?(full_selected_path)
2332
+ @pR.text = dirlist(left: false)
2333
+ else
2334
+ # Use existing showcontent function for files
2335
+ showcontent
2336
+ end
2337
+
2338
+ # Restore original @selected
2339
+ @selected = old_selected
2340
+ else
2341
+ @pR.text = "No file selected"
2342
+ end
1633
2343
  else
1634
- showcontent
2344
+ # Original single-pane logic
2345
+ if @selected && File.directory?(@selected)
2346
+ @pR.text = dirlist(left: false)
2347
+ else
2348
+ showcontent
2349
+ end
1635
2350
  end
2351
+
1636
2352
  @pR.full_refresh unless @pR.text == righttext || @image
1637
2353
  end
1638
2354
 
@@ -1646,14 +2362,17 @@ def render # RENDER ALL PANES {{{2
1646
2362
  end
1647
2363
  # File attributes
1648
2364
  text += " (#{@fileattr})" if defined?(@fileattr)
1649
- # Image or PDF metadata
2365
+ # Image or PDF metadata using cache
1650
2366
  begin
1651
- if @selected&.match(@imagefile)
1652
- if cmd?('identify')
1653
- meta = `identify -format " [%wx%h %m %[colorspace] %[bit-depth]-bit]" #{Shellwords.escape(@selected)} 2>/dev/null`
1654
- text += meta
1655
- end
2367
+ cached_meta = get_cached_file_metadata(@selected)
2368
+ if cached_meta
2369
+ text += cached_meta
2370
+ elsif @selected&.match(@imagefile) && cmd?('identify')
2371
+ # Fallback for non-cached image metadata
2372
+ meta = `identify -format " [%wx%h %m %[colorspace] %[bit-depth]-bit]" #{Shellwords.escape(@selected)} 2>/dev/null`
2373
+ text += meta
1656
2374
  elsif @selected&.match(@pdffile)
2375
+ # Fallback for non-cached PDF metadata
1657
2376
  info = `pdfinfo #{Shellwords.escape(@selected)} 2>/dev/null`
1658
2377
  pages = info[/^Pages:\s+(\d+)/, 1]
1659
2378
  size = info[/^Page size:.*\((.*)\)/, 1]
@@ -1672,7 +2391,22 @@ def render # RENDER ALL PANES {{{2
1672
2391
  text += ' [Denied]'
1673
2392
  end
1674
2393
  end
2394
+
2395
+ # Add tab indicator to the right side if there are multiple tabs
2396
+ if @tabs.size > 1
2397
+ tab_indicator = "[#{@current_tab + 1}/#{@tabs.size}]"
2398
+ # Calculate available space for the main text (including space for right-justification)
2399
+ available_width = @pT.w - tab_indicator.length - 1 # -1 for space between text and indicator
2400
+ if text.length > available_width
2401
+ text = text[0, available_width - 3] + "..."
2402
+ end
2403
+ # Right-justify the tab indicator by padding with spaces
2404
+ padding = @pT.w - text.length - tab_indicator.length
2405
+ text += " " * padding + tab_indicator
2406
+ end
2407
+
1675
2408
  @pT.text = text.b
2409
+
1676
2410
  @pT.bg = @topmatch.find { |name, _| name.empty? || Dir.pwd.include?(name) }&.last
1677
2411
  @pT.refresh unless @pT.text == toptext
1678
2412
  end
@@ -1688,6 +2422,8 @@ def render # RENDER ALL PANES {{{2
1688
2422
  @pB.text = info
1689
2423
  @pB.refresh unless @pB.text == bottomtext
1690
2424
  end
2425
+
2426
+ # end # Rcurses.batch_refresh - TEMPORARILY DISABLED
1691
2427
  end
1692
2428
 
1693
2429
  def refresh # {{{2
@@ -1696,16 +2432,41 @@ def refresh # {{{2
1696
2432
  @p0.clear
1697
2433
  @pT.w = @w
1698
2434
  @pT.clear; @pT.update = true
2435
+ @pTab.w = @w # Keep tab overlay same width as top pane
1699
2436
  @pB.w = @w
1700
2437
  @pB.y = @h
1701
2438
  @pB.clear; @pB.update = true
1702
- @pL.w = (@w - 4) * @width / 10
1703
- @pL.h = @h - 4
1704
- @pR.x = @pL.w + 4
1705
- @pR.w = @w - @pL.w - 4
1706
- @pR.h = @h - 4
1707
- @pL.clear; @pL.update = true
1708
- @pR.clear; @pR.update = true
2439
+ if @dual_pane && @pLeft && @pRight && @pPreview
2440
+ # Update dual-pane layout with width control via @width variable
2441
+ total_width = @w - 6 # Account for spacing and borders (2 + 2 + 2 spacing)
2442
+ # Map @width (2-7) to dir_panes_ratio (0.5 to 0.33) - balanced default
2443
+ dir_panes_ratio = [0.5 - (@width - 2) * 0.034, 0.33].max
2444
+ dir_panes_width = (total_width * dir_panes_ratio).to_i
2445
+ left_width = dir_panes_width / 2
2446
+ preview_width = total_width - dir_panes_width
2447
+
2448
+ @pLeft.w = left_width
2449
+ @pLeft.h = @h - 4
2450
+ @pRight.x = left_width + 4 # 2 columns spacing
2451
+ @pRight.w = left_width
2452
+ @pRight.h = @h - 4
2453
+ @pPreview.x = 2*left_width + 6 # 2 columns spacing
2454
+ @pPreview.w = preview_width
2455
+ @pPreview.h = @h - 4
2456
+
2457
+ @pLeft.clear; @pLeft.update = true
2458
+ @pRight.clear; @pRight.update = true
2459
+ @pPreview.clear; @pPreview.update = true
2460
+ else
2461
+ # Update single-pane layout
2462
+ @pL.w = (@w - 4) * @width / 10
2463
+ @pL.h = @h - 4
2464
+ @pR.x = @pL.w + 4
2465
+ @pR.w = @w - @pL.w - 4
2466
+ @pR.h = @h - 4
2467
+ @pL.clear; @pL.update = true
2468
+ @pR.clear; @pR.update = true
2469
+ end
1709
2470
  @pCmd.y = @h
1710
2471
  @pCmd.w = @w
1711
2472
  @pRuby.y = @h
@@ -1784,25 +2545,31 @@ def command(cmd, timeout: 5, return_both: false) # {{{2
1784
2545
  # Drain both pipes in background threads
1785
2546
  out_reader = Thread.new { out_buf << stdout.read until stdout.eof? }
1786
2547
  err_reader = Thread.new { err_buf << stderr.read until stderr.eof? }
1787
- if timeout
1788
- unless wait_thr.join(timeout)
1789
- # Timed out → kill everything
1790
- Process.kill('TERM', pid) rescue nil
1791
- sleep 0.1
1792
- Process.kill('KILL', pid) rescue nil
1793
- Process.wait(pid) rescue nil
1794
- out_reader.kill
1795
- err_reader.kill
1796
- showimage('clear') if @image
1797
- @pR.say('Error: Command timed out.'.fg(196))
1798
- return return_both ? ['', "Error: Command timed out.\n"] : ''
2548
+ begin
2549
+ if timeout
2550
+ unless wait_thr.join(timeout)
2551
+ # Timed out → kill everything
2552
+ Process.kill('TERM', pid) rescue nil
2553
+ sleep 0.1
2554
+ Process.kill('KILL', pid) rescue nil
2555
+ Process.wait(pid) rescue nil
2556
+ out_reader.kill
2557
+ err_reader.kill
2558
+ showimage('clear') if @image
2559
+ @pR.say('Error: Command timed out.'.fg(196))
2560
+ return return_both ? ['', "Error: Command timed out.\n"] : ''
2561
+ end
2562
+ else
2563
+ wait_thr.join
1799
2564
  end
1800
- else
1801
- wait_thr.join
2565
+ # Ensure we've captured all output
2566
+ out_reader.join(1) # Add timeout to prevent hanging
2567
+ err_reader.join(1) # Add timeout to prevent hanging
2568
+ ensure
2569
+ # Clean up threads if they're still running
2570
+ out_reader.kill if out_reader.alive?
2571
+ err_reader.kill if err_reader.alive?
1802
2572
  end
1803
- # Ensure we've captured all output
1804
- out_reader.join
1805
- err_reader.join
1806
2573
  end
1807
2574
  if return_both
1808
2575
  [out_buf, err_buf]
@@ -1832,8 +2599,16 @@ end
1832
2599
 
1833
2600
  def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
1834
2601
  @tagged.uniq!
2602
+
2603
+ # Determine destination directory based on mode
2604
+ dest_dir = if @dual_pane
2605
+ @active_pane == :left ? @pwd_left : @pwd_right
2606
+ else
2607
+ Dir.pwd
2608
+ end
2609
+
1835
2610
  @tagged.each do |item|
1836
- dest = File.join(Dir.pwd, File.basename(item))
2611
+ dest = File.join(dest_dir, File.basename(item))
1837
2612
  dest += '1' if File.exist?(dest)
1838
2613
  while File.exist?(dest)
1839
2614
  # Replace the last character (presumed to be a digit) by incrementing it
@@ -1856,6 +2631,19 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
1856
2631
  end
1857
2632
  end
1858
2633
  @tagged = []
2634
+
2635
+ # Set update flags for proper refresh - let render system handle the actual refresh
2636
+ if @dual_pane
2637
+ # Update the destination pane
2638
+ if @active_pane == :left
2639
+ @pLeft.update = true
2640
+ else
2641
+ @pRight.update = true
2642
+ end
2643
+ # Also update the preview pane
2644
+ @pPreview.update = true if @pPreview
2645
+ end
2646
+
1859
2647
  render
1860
2648
  end
1861
2649
 
@@ -2093,9 +2881,20 @@ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
2093
2881
  elsif pattern # Nil template → image or video
2094
2882
  case @selected
2095
2883
  when /\.(?:png|jpe?g|bmp|gif|webp|tiff?)$/i
2096
- showimage(Shellwords.escape(@selected))
2097
- @image = true
2098
- when /\.(?:mpg|mpeg|avi|mov|mkv|mp4)$/i
2884
+ # Allow larger image files up to 50MB
2885
+ begin
2886
+ file_size = File.size(@selected)
2887
+ if file_size > 50_000_000 # 50MB limit for images
2888
+ @pR.say("Image too large for preview (#{(file_size / 1024.0 / 1024.0).round(1)}MB > 50MB limit)")
2889
+ else
2890
+ showimage(Shellwords.escape(@selected))
2891
+ @image = true
2892
+ end
2893
+ rescue => e
2894
+ @pR.say("Error checking image size: #{e}")
2895
+ end
2896
+ when /\.(?:mpg|mpeg|avi|mov|mkv|mp4|webm|flv|wmv|m4v)$/i
2897
+ # Generate video thumbnail regardless of size
2099
2898
  tn = '/tmp/rtfm_video_tn.jpg'
2100
2899
  showcommand("ffmpegthumbnailer -s 1200 -i #{Shellwords.escape(@selected)} -o #{Shellwords.escape(tn)} 2>/dev/null")
2101
2900
  showimage(tn)
@@ -2108,20 +2907,76 @@ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
2108
2907
  if @selected&.match(@pdffile)
2109
2908
  @pR.say("No preview available for #{@selected}")
2110
2909
  else
2111
- text = File.read(@selected).force_encoding('UTF-8') rescue ''
2112
- if text.valid_encoding?
2113
- if @batuse
2114
- begin
2115
- showcommand("#{@bat} -n --color=always #{Shellwords.escape(@selected)}")
2116
- rescue
2117
- showcommand("cat #{Shellwords.escape(@selected)}")
2910
+ # Enhanced text file preview with partial loading for large files
2911
+ begin
2912
+ file_size = File.size(@selected)
2913
+
2914
+ # For text files, we can preview them partially even if large
2915
+ if file_size > 1_000_000 # Files larger than 1MB
2916
+ # Calculate how many lines fit in the preview pane
2917
+ preview_lines = @pR.h * 3 # Allow for 3 screens worth of preview
2918
+
2919
+ # Read only the first portion of the file
2920
+ preview_content = nil
2921
+ File.open(@selected, 'r:UTF-8') do |file|
2922
+ lines = []
2923
+ line_count = 0
2924
+
2925
+ file.each_line do |line|
2926
+ lines << line
2927
+ line_count += 1
2928
+ break if line_count >= preview_lines
2929
+ end
2930
+
2931
+ preview_content = lines.join
2932
+ end
2933
+
2934
+ if preview_content && preview_content.valid_encoding?
2935
+ # Show preview with truncation notice
2936
+ @pR.say("=== Showing first #{preview_lines} lines of large file (#{(file_size / 1024.0 / 1024.0).round(1)}MB) ===\n\n")
2937
+
2938
+ if @batuse
2939
+ # Write temp file for bat to process
2940
+ require 'tempfile'
2941
+ temp = Tempfile.new(['rtfm_preview', File.extname(@selected)])
2942
+ temp.write(preview_content)
2943
+ temp.close
2944
+
2945
+ begin
2946
+ showcommand("#{@bat} -n --color=always #{Shellwords.escape(temp.path)}")
2947
+ rescue
2948
+ @pR.say(preview_content)
2949
+ ensure
2950
+ temp.unlink
2951
+ end
2952
+ else
2953
+ @pR.say(preview_content)
2954
+ end
2955
+
2956
+ @pR.say("\n\n=== File truncated for preview ===")
2957
+ else
2958
+ @pR.say("File appears to be binary or has invalid encoding")
2118
2959
  end
2119
2960
  else
2120
- showcommand("cat #{Shellwords.escape(@selected)}")
2961
+ # Small files - read entirely as before
2962
+ text = File.read(@selected).force_encoding('UTF-8') rescue ''
2963
+ if text.valid_encoding?
2964
+ if @batuse
2965
+ begin
2966
+ showcommand("#{@bat} -n --color=always #{Shellwords.escape(@selected)}")
2967
+ rescue
2968
+ showcommand("cat #{Shellwords.escape(@selected)}")
2969
+ end
2970
+ else
2971
+ showcommand("cat #{Shellwords.escape(@selected)}")
2972
+ end
2973
+ else
2974
+ @pR.say("No preview available for #{@selected}")
2975
+ end
2121
2976
  end
2122
- else
2123
- @pR.say("No preview available for #{@selected}")
2124
- end
2977
+ rescue => e
2978
+ @pR.say("Error previewing file: #{e}")
2979
+ end
2125
2980
  end
2126
2981
  end
2127
2982
  end
@@ -2151,6 +3006,7 @@ def showimage(image) # SHOW THE SELECTED IMAGE IN THE RIGHT WINDOW {{{2
2151
3006
  img_max_w += char_w + 2
2152
3007
  img_max_h += 2
2153
3008
  `echo "6;#{img_x};#{img_y};#{img_max_w};#{img_max_h};\n4;\n3;" | #{@imgdisplay} 2>/dev/null`
3009
+ @current_image_path = nil # Clear tracking when image is cleared
2154
3010
  else
2155
3011
  # Use the already-escaped image for shell commands
2156
3012
  dimensions = `identify -format "%wx%h" #{image} 2>/dev/null`
@@ -2169,6 +3025,7 @@ def showimage(image) # SHOW THE SELECTED IMAGE IN THE RIGHT WINDOW {{{2
2169
3025
  # Fix: Unescape the filename for w3mimgdisplay protocol, then quote it
2170
3026
  unescaped = image.gsub(/\\(.)/, '\1') # Remove shell escaping
2171
3027
  `echo "0;1;#{img_x};#{img_y};#{img_w};#{img_h};;;;;\"#{unescaped}\"\n4;\n3;" | #{@imgdisplay} 2>/dev/null`
3028
+ @current_image_path = unescaped # Track currently displayed image
2172
3029
  end
2173
3030
  rescue
2174
3031
  @pR.text = 'Error showing image'
@@ -2206,9 +3063,25 @@ end
2206
3063
  # p = Pane.new( x, y, width, height, fg, bg)
2207
3064
  @p0 = Pane.new( 1, 1, @w, @h, 0, 0)
2208
3065
  @pT = Pane.new( 1, 1, @w, 1, 0, @topcolor)
3066
+ @pTab = Pane.new( 1, 1, @w, 1, 255, 238) # Tab overlay pane (same coords as @pT)
2209
3067
  @pB = Pane.new( 1, @h, @w, 1, 252, @bottomcolor)
2210
- @pL = Pane.new( 2, 3, (@w - 4)*@width/10, @h - 4, 15, 0)
2211
- @pR = Pane.new(@pL.w + 4, 3, @w - @pL.w - 4, @h - 4, 255, 0)
3068
+ # Create panes based on mode
3069
+ if @dual_pane
3070
+ # Dual-pane layout: two directory panes + one preview pane
3071
+ left_width = (@w - 6) / 3 # Each directory pane gets 1/3 of width
3072
+ @pLeft = Pane.new( 2, 3, left_width, @h - 4, 226, 0) # Yellow fg for active
3073
+ @pRight = Pane.new(left_width + 4, 3, left_width, @h - 4, 15, 0) # Normal fg for inactive
3074
+ @pPreview = Pane.new(2*left_width + 6, 3, @w - 2*left_width - 6, @h - 4, 255, 0)
3075
+ @pLeft.border = true
3076
+ @pRight.border = true
3077
+ @pPreview.border = false
3078
+ @pL = @pLeft
3079
+ @pR = @pPreview
3080
+ else
3081
+ # Single-pane layout (default)
3082
+ @pL = Pane.new( 2, 3, (@w - 4)*@width/10, @h - 4, 15, 0)
3083
+ @pR = Pane.new(@pL.w + 4, 3, @w - @pL.w - 4, @h - 4, 255, 0)
3084
+ end
2212
3085
  ## Create special panes
2213
3086
  @pCmd = Pane.new( 1, @h, @w, 1, 255, @cmdcolor)
2214
3087
  @pSearch = Pane.new( 1, @h, @w, 1, 255, @searchcolor)
@@ -2218,6 +3091,7 @@ end
2218
3091
  #checkpoint("Panes created")
2219
3092
 
2220
3093
  ## Set pane properties {{{2
3094
+ @pTab.update = true
2221
3095
  @pT.update = true
2222
3096
  @pL.update = true
2223
3097
  @pR.update = true
@@ -2252,17 +3126,18 @@ $stdin.getc while $stdin.wait_readable(0)
2252
3126
  #checkpoint("Program started")
2253
3127
  loop do
2254
3128
  @dir_old = Dir.pwd
3129
+
2255
3130
  # redraw, but ignore TTY‐focus errors
2256
3131
  begin
2257
3132
  render
2258
3133
  rescue Errno::EIO
2259
- # nothing
3134
+ # Note: rcurses 4.9.0+ has enhanced error handling, reducing need for this
2260
3135
  end
2261
3136
  # read key, but ignore TTY-focus errors
2262
3137
  begin
2263
3138
  getkey
2264
3139
  rescue Errno::EIO
2265
- # nothing
3140
+ # Note: rcurses 4.9.0+ has enhanced error handling, reducing need for this
2266
3141
  end
2267
3142
  # If cwd was deleted externally, jump home
2268
3143
  begin
@@ -2271,7 +3146,9 @@ loop do
2271
3146
  Dir.chdir
2272
3147
  end
2273
3148
  # restore index if we cd'd
2274
- @index = @directory[Dir.pwd] || 0 if Dir.pwd != @dir_old
3149
+ if Dir.pwd != @dir_old
3150
+ @index = @directory[Dir.pwd] || 0
3151
+ end
2275
3152
  unless @navi.empty?
2276
3153
  command(@navi)
2277
3154
  @navi = ''