#!/usr/bin/env ruby
# frozen_string_literal: true

# SCRIPT INFO {{{1
# Name:       RTFM - Ruby Terminal File Manager
# Language:   Pure Ruby, best viewed in VIM
# Author:     Geir Isene <g@isene.com>
# Web_site:   http://isene.com/
# Github:     https://github.com/isene/RTFM
# License:    I release all copyright claims. This code is in the public domain.
#             Permission is granted to use, copy modify, distribute, and sell
#             this software for any purpose. I make no guarantee about the
#             suitability of this software for any purpose and I am not liable
#             for any damages resulting from its use. Further, I am under no
#             obligation to maintain or extend this software. It is provided
#             on an 'as is' basis without any expressed or implied warranty.
# Docs:       Apart from the extensive documentation found on Github, you can
#             get a great understanding of the code itself by simply sending
#             or pasting this whole file into you favorite AI for coding with
#             a prompt like this: "Help me understand every part of this code".
@version    = '7.0.4' # Fixed platform-specific issues for macOS/BSD and improved first-run message

# SAVE & STORE TERMINAL {{{1
ORIG_STTY = `stty -g`.chomp

at_exit do
  system("stty #{ORIG_STTY} < /dev/tty") rescue nil
end

# BOOTSNAP {{{1
cache_dir = ENV.fetch('BOOTSNAP_CACHE_DIR', File.join(Dir.home, '.rtfm', 'bootsnap-cache'))
ENV['BOOTSNAP_CACHE_DIR'] = cache_dir
require 'fileutils'
FileUtils.mkdir_p(cache_dir)
require 'bootsnap/setup' # Speed up subsequent requires

# ENCODING {{{1
# encoding: utf-8


def check_image_redraw # {{{2
  # Only check periodically to avoid performance impact
  now = Time.now
  return if now - @last_focus_check < @focus_check_interval
  @last_focus_check = now
  
  # Only check if we have an image currently displayed
  return unless @image && @current_image_path
  
  begin
    # Check if terminal has focus and if image needs redrawing
    active_window = `xdotool getactivewindow 2>/dev/null`.chomp
    return if active_window.empty?
    
    # Simple heuristic: if we can't find any w3mimgdisplay processes,
    # the image overlay was probably cleared and needs redrawing
    img_processes = `pgrep w3mimgdisplay 2>/dev/null`.chomp
    if img_processes.empty? && File.exist?(@current_image_path)
      # Redraw the image
      showimage(Shellwords.escape(@current_image_path))
    end
  rescue
    # Silently fail - we don't want focus checking to break anything
  end
end

# LOAD LIBRARIES {{{1
begin
  require 'rcurses'
  class Object
    include Rcurses
    include Rcurses::Input
  end
rescue StandardError => e
  puts 'RTFM is built using rcurses (https://github.com/isene/rcurses). Install rcurses to run RTFM.'
  exit 1
end
require 'tmpdir'
require 'set'  # For symlink loop detection
# Lazy-load to speed up startup
autoload :Shellwords, 'shellwords'
autoload :Timeout,    'timeout'
autoload :Open3,      'open3'
autoload :PTY,        'pty'
autoload :OpenAI,     'ruby/openai'
autoload :Tempfile,   'tempfile'

# FIX TERMINAL MESSAGE BLEED-THROUGH {{{1
LOG_PATH     = File.join(Dir.tmpdir, "rtfm_#{ENV['USER'] || Process.uid}.log")
MAX_LOG_SIZE = 128 * 1024
if File.exist?(LOG_PATH) && File.size(LOG_PATH) > MAX_LOG_SIZE
  # keep just the last MAX_LOG_SIZE bytes
  File.open(LOG_PATH, 'r+b') do |f|
    f.seek(-MAX_LOG_SIZE, IO::SEEK_END)
    tail = f.read
    f.rewind
    f.truncate(0)
    f.write(tail)
  end
end
logfile = File.open(LOG_PATH, 'a+')
logfile.sync = true
$stderr.reopen(logfile)

# RCURSES CLASS EXTENSION {{{1
module Rcurses
  # Add location attribute for compatibility
  class Pane
    attr_accessor :locate
    
    # Compatibility layer for the new update control system
    def update=(value)
      # TEMPORARILY DISABLED: If rcurses 4.9.0+ is available, use new suspend/resume system
      # if self.respond_to?(:suspend_updates)
      #   if value
      #     resume_updates
      #   else
      #     suspend_updates
      #   end
      # else
        # Fallback for older rcurses versions
        @update = value
      # end
    end
    
    def update
      # For older rcurses versions compatibility
      @update.nil? ? true : @update
    end
    
    # Restore original say method override for compatibility
    alias original_say say
    def say(text)
      original_say(text)
      self.update = false
    end
  end
  
  # Batch update helper for rcurses 4.9.0+
  def self.batch_refresh(&block)
    if Rcurses.respond_to?(:batch_updates)
      Rcurses.batch_updates(&block)
    else
      # Fallback for older versions - just execute the block
      block.call
    end
  end
end

# CREATE DIRS & SET FILE CONSTS {{{1
RTFM_HOME     = File.join(Dir.home, '.rtfm')
PLUGINS_DIR   = File.join(RTFM_HOME, 'plugins')
TRASH_DIR     = File.join(RTFM_HOME, 'trash')
[RTFM_HOME, PLUGINS_DIR, TRASH_DIR].each { |d| FileUtils.mkdir_p(d) }
PREVIEW_FILE  = File.join(RTFM_HOME, 'preview.rb')
KEYS_FILE     = File.join(RTFM_HOME, 'keys.rb') unless defined?(KEYS_FILE)
CONFIG_FILE   = File.join(RTFM_HOME, 'conf')
@plugin_errors = []

# HELP {{{1
@help = <<~HELPTEXT
  RTFM - Ruby Terminal File Manager (https://github.com/isene/RTFM)

  BASIC KEYS
    ?       = Show this help text
    v       = Display RTFM version (and latest Gem version) in bottom window/command bar
    r       = Refresh RTFM (recreates all windows. Use on terminal resize or when there is garbage somewhere)
    R       = Reload configuration (~/.rtfm/conf)
    W       = Write parameters to ~/.rtfm/conf: @marks, @hash, @history, @rubyhistory, @aihistory, @sshhistory
              @lslong, @lsall, @lsorder, @lsinvert, @width, @border, @preview, @trash
    C       = Show the current configuration in ~/.rtfm/conf
    q       = Quit (save basic configuration: @marks, @hash, @history, @rubyhistory, @aihistory, @sshhistory)
    Q       = QUIT (without writing any changes to the config file)

  LAYOUT
    w       = Change the width of the left/right panes (left pane ⇒ 20%, 30%, 40%, 50%, 60%)
    B       = Cycle border
    -       = (Minus sign) Toggle preview in right pane (turn it off for faster traversing of directories)
    _       = (Underscore) Toggle preview of images in right pane
    b       = Toggle syntax highlighting (and line numbering)

  MOTION
    j/DOWN  = Go one item down in left pane (rounds to top)
    k/UP    = Go one item up in left pane (rounds to bottom)
    h/LEFT  = Go up one directory level
    l/RIGHT = Enter directory or open file (using run-mailcap or xdg-open)
              Use the key 'x' to force open using xdg-open (or run-mailcap) - used for opening html files
              in a browser rather than editing the file in your text editor
    PgDown  = Go one page down in left pane
    PgUp    = Go one page up in left pane
    END     = Go to last item in left pane
    HOME    = Go to first item in left pane

  MARKS AND JUMPING
    m       = Mark current dir (persistent). Next letter is the name of the mark [a-zA-Z']
              The special mark "'" jumps to the last directory (makes toggling dirs easy)
              Press '-' and a letter to delete that mark. Mark '0' is the dir where RTFM was started.
              Marks '1' - '5' are the past five directories visited.
    M       = Show marked items in right pane
    '       = Jump to mark (next letter is the name of the mark [a-zA-Z'])
              The 5 latest directories visited are stored in marks 1-5 (1 being the very latest)
    ~       = Jump to Home directory
    >       = Follow symlink to the directory where the target resides
    Ctrl-r  = Show recently accessed files and directories (press number to jump)
    Ctrl-e  = Browse remote directories via SSH/SFTP (toggle remote mode)
                In remote mode: d=download, u=upload, s=shell, →=file info, ←=parent dir
                SSH connections support comments: user@host:/path # Comment

  DIRECTORY VIEWS
    a       = Show all (also hidden) items
    A       = Show long info per item (show item attributes)
    o       = Change the order/sorting of directories (circular toggle)
    i       = Invert/reverse the sorting
    O       = Show the Ordering in the bottom window (the full ls command)

  TAGGING
    t       = Tag item (toggles)
    Ctrl-t  = Add items matching a pattern to list of tagged items (Ctrl-t and then . will tag all items)
    T       = Show currently tagged items in right pane
    u       = Untag all tagged items

  TAB MANAGEMENT
    ]       = Create new tab in current directory
    [       = Close current tab (keeps at least one tab open)
    J       = Previous tab (wraps around)
    K       = Next tab (wraps around)
    }       = Duplicate current tab (creates a copy with same directory)
    {       = Rename current tab
    1-9     = Switch to tab by number (1 = first tab, 2 = second tab, etc.)

  MANIPULATE ITEMS
    p       = Put (copy) tagged items here
    P       = PUT (move) tagged items here
    c       = Change/rename selected (adds command to bottom window)
    E       = Bulk rename tagged files using patterns (regex, templates, case conversion)
    X       = Compare two tagged files (text diff or binary comparison)
    s       = Create symlink to tagged items here
    d       = Delete selected item and tagged items. Confirm with 'y'. 
              Moves items to trash directory (~/.rtfm/trash/) if @trash = true
    D       = Empty trash directory
    Ctrl-d  = Toggle use of trash directory

  UNDO OPERATIONS
    U       = Undo last file operation (delete from trash, move, rename, copy, symlink, bulk rename)
              Only operations that can be safely undone are tracked
              Permanent deletions cannot be undone

  OWNERSHIP AND PERMISSIONS
    Ctrl-o  = Change ownership to user:group of selected and tagged items
    Ctrl-p  = Change permissions of selected and tagged items
              Format = rwxr-xr-x or 755 or rwx (applies the trio to user, group and others)

  FILTER AND SEARCH
    f       = Show only files in the left pane matching extension(s) (e.g. "txt" or "pdf,png,jpg")
    F       = Show only files matching a pattern (Ruby Regex) (e.g. "abc" or "ab.+12(\w3)+")
    Ctrl-f  = Clear all filtering
    /       = Enter search string in bottom window to highlight matching items and jump to the first match
    \\       = Remove search pattern
    n       = Jump to the next item matched by '/'
    N       = Jump to the previous item matched by '/'
    g       = Run 'grep' to show files that contains the MATCH in current directory
    L       = Start 'locate' search for files, then use '#' to jump to desired line/directory
    Ctrl-l  = Locate files via fzf from the current directory down
              (fuzzy file finder must be installed https://github.com/junegunn/fzf)

  ARCHIVES
    z       = Extract tagged zipped archive to current directory
    Z       = Create zipped archive from tagged files/directories

  GIT/HASH/OPENAI
    G       = Show git status for current directory
    H       = Do a cryptographic hash of the current directory with subdirs
              If a previous hash was made, compare and report if there has been any change
    I       = Show OpenAI's description of the selected item and its content (if available)
              You must have installed the ruby-openai gem and added your openai secret key
              in the ~/.rtfm/conf (add `@ai = "your-secret-openai-key") for this to work.
    Ctrl-a  = Start an OpenAI chat (the context window is kept until you exit RTFM)
              The OpenAI agent is specialized in answering questions about cli, files and dirs

  RIGHT PANE CONTROLS
    ENTER   = Refresh the right pane
    S-DOWN  = One line down in the preview
    S-UP    = One line up in the preview
    S-RIGHT = Next page of the preview (if doc long and ∇ in the bottom right) (TAB does the same)
    S-LEFT  = Previous page (if you have moved down the document first - ∆ in the top right) (or S-TAB)

  CLIPBOARD COPY
    y       = Copy path of selected item to primary selection (for pasting with middle mouse button)
    Y       = Copy path of selected item to clipboard
    Ctrl-y  = Copy content of right pane to clipboard
              Turn off batcat syntax highlighting with 'b' for a clean copy of content

  SYSTEM SHORTCUTS
    S       = Show comprehensive System info (system, CPU, filesystem, latest dmesg messages)
    =       = Create a new directory (a shortcut for ":mkdir ")
    e       = Show comprehensive file/directory properties (size, permissions, timestamps, etc.)
    Ctrl-n  = Invoke navi (see https://github.com/denisidoro/navi) with any output in right window

  COMMAND MODE
    :       = Enter "command mode" in bottom window (press ENTER to execute, press ESC to escape)
              Prefix the command with a '§' to force the program to run in interactive mode
              Full screen TUI programs like htop, vim or any shell must run in interactive mode
    ;       = Show command history in right pane
    Ctrl-;  = Show SSH connection history in right pane
    +       = Add program(s) to the list of full-UI interactive terminal programs

  RUBY DEBUG MODE
    @       = Enter Ruby mode to execute any Ruby command (ENTER to execute, ESC to escape)

  COPYRIGHT: Geir Isene, 2025+. No rights reserved. See http://isene.com for more.
HELPTEXT

# FIRST RUN # {{{1
def firstrun # {{{2
  setup_launch_script
  setup_config
  load_config
  setup_templates
  display_welcome_message
  getchr
rescue StandardError => e
  errormsg('⚠ Errors during RTFM first-run.', e)
  exit 1
end

def setup_launch_script # {{{2
  shell_name = File.basename(ENV['SHELL'].to_s)
  if shell_name == 'fish'
    fish_cfg = File.join(Dir.home, '.config', 'fish', 'config.fish')
    FileUtils.mkdir_p(File.dirname(fish_cfg))
    line = 'function r; rtfm; cd (cat ~/.rtfm_last_dir); end # rtfm launcher'
    File.write(fish_cfg, "#{line}\n", mode: 'a')
  else
    rc_file = File.join(Dir.home, ".#{shell_name}rc")
    FileUtils.mkdir_p(File.dirname(rc_file))
    line = 'r(){ rtfm; cd "$(cat ~/.rtfm_last_dir)"; } # rtfm launcher'
    File.write(rc_file, "#{line}\n", mode: 'a')
  end
end

def setup_config # {{{2
  File.write CONFIG_FILE, <<~CONFIG
    # Configuration file for RTFM (https://github.com/isene/RTFM)
    # BASICS
    @width       = 2
    @border      = 1
    @trash       = true

    # LS
    @lslong      = ""
    @lsall       = ""
    @lsorder     = ""
    @lsinvert    = ""

    # RIGHT PANE
    @runmailcap  = true
    @preview     = true
    @showimage   = true

    # TOP PANE
    @topmatch = [["", 249]]

    # INTERACTIVE
    @interactive = "fzf,navi,top,htop,less,vi,vim,ncdu,sh,zsh,bash,fish,git"

    # AI
    @ai      = "Replace_This_With_Your_OpenAI_Key"
    @aimodel = "gpt-4o-mini"

    # LISTS
    @marks       = {}
    @hash        = {}
    @history     = []
    @rubyhistory = []
    @aihistory   = []
    @sshhistory  = []
  CONFIG
end

def setup_templates # {{{2
  unless File.exist?(PREVIEW_FILE) # {{{3
    File.write PREVIEW_FILE, <<~TEMPLATE
      # ~/.rtfm/preview.rb
      #
      # Define one handler per line in the form:
      #
      #   ext1, ext2, ext3 = command with @s placeholder
      #
      # @s will be replaced by the shell-escaped filename.
      #
      # Lines beginning with # or blank are ignored.
      #
      # Examples:
      #   # plain text, Ruby, Python, shell
      #   txt, rb, py, sh = bat -O ansi --force --line-numbers @s
      #
      #   # markdown via pandoc
      #   md = termmark @s
      #
      #   # PDFs
      #   pdf = pdftotext -f 1 -l 4 @s -
      #
    TEMPLATE
  end

  unless File.exist?(KEYS_FILE) # {{{3
    File.write KEYS_FILE, <<~TEMPLATE
      # ~/.rtfm/keys.rb
      #
      # Override or add key bindings simply by assigning into KEYMAP
      # and defining the corresponding handler methods.
      #
      # Syntax:
      #   KEYMAP['X'] = :my_handler
      #
      #   def my_handler(chr)
      #     # anything you like—use @pB, @pR, Dir.pwd, etc.
      #     @pB.say("You pressed X!")
      #   end
      #
      # Examples:
      #
      #   # remap 'C' to show config
      #   KEYMAP['C'] = :show_config
      #
      #   # add a new binding: 'Z'
      #   KEYMAP['Z'] = :zap_all
      #   def zap_all(_chr)
      #     @pB.say("ZAPPED!") 
      #   end
    TEMPLATE
  end
end

def display_welcome_message # {{{2
# rubocop:disable Layout/IndentationWidth
@firstrun = "
  Welcome to RTFM - the Ruby Terminal File Manager. This help text is shown on the first run.
  Next time you run RTFM, you can launch it from your terminal with a one letter command: 'r'
  When launched this way, RTFM will also exit in the directory you are currently in.\n".b.fg(226)
@firstrun += "
  To benefit fully from all the features, you need to install some auxiliary software.\n".fg(230)
@firstrun += "
  Required dependencies:
  • x11-utils (X11 utilities for clipboard support)
  • xdotool (for window management features)

  Recommended viewers (install as needed):
  • bat - Syntax highlighting of text files
  • pandoc - Viewing Markdown documents
  • pdftotext (from poppler-utils) - Viewing PDFs
  • odt2txt - Viewing LibreOffice documents
  • docx2txt - Viewing MS Word docx files
  • unzip - Viewing MS PowerPoint pptx files
  • ssconvert (from gnumeric) - Viewing MS Excel xlsx files
  • catdoc, xls2csv, catppt - Viewing older MS Office formats
  • w3m, ImageMagick - Viewing images
  • ffmpegthumbnailer - Video thumbnails\n".fg(195)
@firstrun += "
  Installation methods vary by platform:\n".fg(159)
@firstrun += "
  • Linux (Debian/Ubuntu): Use apt
  • Linux (Fedora/RHEL): Use dnf/yum
  • macOS: Use Homebrew (brew)
  • BSD: Use pkg or ports

  For Ubuntu/Debian users, install all dependencies with:
  sudo apt install ruby-full git libncurses-dev x11-utils xdotool bat pandoc poppler-utils odt2txt docx2txt unzip gnumeric catdoc w3m imagemagick ffmpegthumbnailer

  Note: Package names may vary between platforms. The 'libncurses-dev' package is Linux-specific.".b.fg(87)
@firstrun += "\n\n
  You need to install the basic requirements and you should install the viewers before you...
  ... hit any key to start RTFM. Hit ? inside RTFM to show the full help text. Enjoy :-)\n".fg(214)
# rubocop:enable Layout/IndentationWidth
puts @firstrun
end

# BASIC SETUP {{{1
## Check for installed basic applications {{{2
def cmd?(cmd) # Helper function
  system("which #{cmd} > /dev/null 2>&1")
end

if cmd?('/usr/lib/w3m/w3mimgdisplay')
  @imgdisplay = '/usr/lib/w3m/w3mimgdisplay'
  @showimage = true
else
  @showimage = false
end
@showimage = false unless cmd?('xwininfo') && cmd?('xdotool')

# Image auto-redraw variables
@current_image_path = nil  # Path of currently displayed image
@last_focus_check = Time.now
@focus_check_interval = 0.5  # Check focus every 500ms

@bat = cmd?('bat') ? 'bat' : 'highlight'

## Set encoding for $stdin to utf-8 {{{2
$stdin.set_encoding(Encoding::UTF_8)

## Initialize rendering optimization variables {{{2
@last_render_state = nil

# Custom caching system - could be replaced with rcurses 4.9.0+ built-in cache
# when Rcurses.cache_set/cache_get becomes available
@dir_cache = {}
@dir_cache_size = 0
@max_cache_entries = 50
@metadata_cache = {}
@metadata_cache_size = 0
@max_metadata_entries = 100
@command_cache = {}  # Cache for expensive shell commands
@command_cache_size = 0
@max_command_cache = 50

# INITIALIZE VARIABLES {{{1
## These can be set by user in ~/.rtfm/conf
### Saved on quit ('q')
@history     = []      # Initialize the command line history array
@rubyhistory = []      # Initialize the command line history array for ruby commands
@aihistory   = []      # Initialize the command line history array for AI chat
@sshhistory  = []      # Initialize the command line history array for SSH connections
### Saved on Write Config ('W')
@lslong      = ''      # Set short form ls (toggled by pressing "A")
@lsall       = ''      # Set "ls -a" to false (toggled by pressing "a" - sets it to "-a")
@lsorder     = ''      # Change the order/sorting by pressing 'o' (circular toggle)
@lsinvert    = ''      # Set to "-r" to reverse/invert sorting order
@width       = 4       # Set width of the left pane to the default ⅓ of the terminal width
@border      = 1       # Set initial border config; 0 = none, 1 = Right pane, 2 = both panes, 3 = Left pane
@preview     = true    # Preview in the Right pane
@trash       = false   # Delete files permanently rather than moving them to ~/.rtfm/trash
@interactive = 'fzf,navi,top,htop,less,vi,vim,nvim,ncdu,sh,zsh,bash,fish,git,mplayer'
### Not saved (but can be set in ~/.rtfm/conf)
@topcolor    = 249     # Default Top pane bg color
@topmatch    = [['', 249]] # Default Top pane bg color for matching directories
@bottomcolor = 236     # Default Bottom pane color
@searchcolor = 23      # Default color for Search pane at bottom
@cmdcolor    = 18      # Default color for Command pane at bottom
@rubycolor   = 52      # Default color for Ruby pane at bottom
@aicolor     = 58      # Default color for AI pane at bottom
@sshcolor    = 54      # Default color for SSH pane at bottom (purple-ish)

# Platform detection for ls command options
@is_macos_bsd = RUBY_PLATFORM =~ /darwin|bsd/i
@lsbase      = @is_macos_bsd ? '' : '--group-directories-first' # Basic ls setup (GNU-specific option)
@lsuser      = ''      # Set this variable in ~/.rtfm/conf to any 'ls' switch you want to customize directory listings
@batuse      = true    # Use batcat for syntax highlighting
@runmailcap  = false   # Set to 'true' in ~/.rtfm/conf if you want to use run-mailcap instead of xdg-open
@aimodel     = 'gpt-4o-mini' # The default OpenAI model, set another in ~/.rtfm/conf if you like

## These are automatically written to ~/.rtfm/conf upon exit
@marks       = {}      # Initialize (book)marks hash
@hash        = {}      # Initialize the sha directory hashing

## Do not set these in ~/.rtfm/conf
@tagged      = []      # Initialize the tagged array - for collecting all tagged items
@directory   = {}      # Initialize the directory hash for remembering directories visited
@searched    = ''      # Initialize the active searched for items
@lsfiles     = ''      # File types to show (initially set to all file types) - not saved on exit
@lsmatch     = ''      # Files to match (initially set to matching all files) - not saved on exit
@index       = 0       # Set chosen item to first on startup
@tagsize     = 0       # Size (in MB) of tagged items
@navi        = ''      # Navi result when navi is invoked
# @dual_pane removed - using tabs for multi-directory navigation

## Dual-pane directory navigation variables
# Dual-pane variables removed - using tabs for multi-directory navigation
# @active_pane, @index_left/right, @files_left/right, @selected_left/right
# @directory_left/right, @pwd_left/right all removed

## Tab system variables
@tabs        = []      # Array of tab objects
@current_tab = 0       # Index of currently active tab
@tab_counter = 0       # Counter for generating unique tab IDs
@tab_bar_visible = false  # Whether tab bar is currently shown
@tab_bar_hide_time = 0    # Time when tab bar should be hidden

## Undo system variables
@undo_history = []      # Array to store undo operations
@max_undo_levels = 20   # Maximum number of undo levels to keep
@undo_enabled = true    # Enable/disable undo system

## Recently accessed files/directories
@recent_files = []      # Last 50 accessed files
@recent_dirs = []       # Last 20 accessed directories  
@max_recent_files = 50  # Maximum recent files to track
@max_recent_dirs = 20   # Maximum recent directories to track

## Remote browsing variables
@remote_connections = {}  # Cache of active remote connections
@current_remote = nil     # Current remote connection info
@remote_cache = {}        # Cache of remote directory listings
@remote_mode = false      # Whether currently browsing remotely
@remote_path = '~'        # Current remote directory path
@remote_files_cache = []  # Cache of current remote directory files with full info

# TAB MANAGEMENT FUNCTIONS {{{1
def create_tab(directory = Dir.pwd, name = nil) # {{{2
  @tab_counter += 1
  tab = {
    id: @tab_counter,
    name: name || File.basename(directory),
    directory: directory,
    index: 0,
    tagged: [],
    searched: '',
    lsfiles: '',
    lsmatch: '',
    directory_memory: {},
    files: []
  }
  @tabs << tab
  tab
end

def current_tab # {{{2
  @tabs[@current_tab] || create_tab
end

def switch_to_tab(tab_index) # {{{2
  return if tab_index < 0 || tab_index >= @tabs.size
  
  # Save current tab state
  save_tab_state
  
  # Switch to new tab
  @current_tab = tab_index
  restore_tab_state
  
  # Update display
  @pL.update = @pR.update = @pT.update = @pTab.update = true
end

def save_tab_state # {{{2
  return unless @tabs[@current_tab]
  
  tab = @tabs[@current_tab]
  tab[:directory] = Dir.pwd
  tab[:name] = File.basename(Dir.pwd)  # Update tab name to current directory
  tab[:index] = @index
  tab[:tagged] = @tagged.dup
  tab[:searched] = @searched
  tab[:lsfiles] = @lsfiles
  tab[:lsmatch] = @lsmatch
  tab[:directory_memory] = @directory.dup
  tab[:files] = @files.dup if defined?(@files)
  
  # Save dual-pane state if in dual-pane mode
  # Dual-pane tab state removed - using single-pane mode only
end

def restore_tab_state # {{{2
  tab = current_tab
  
  begin
    Dir.chdir(tab[:directory]) if Dir.exist?(tab[:directory])
  rescue
    # If directory doesn't exist, go to home
    Dir.chdir
    tab[:directory] = Dir.pwd
  end
  
  @index = tab[:index]
  @tagged = tab[:tagged].dup
  @searched = tab[:searched]
  @lsfiles = tab[:lsfiles]
  @lsmatch = tab[:lsmatch]
  @directory = tab[:directory_memory].dup
  @files = tab[:files].dup if tab[:files]
  
  # Dual-pane state restoration removed - using single-pane mode only
end

def close_tab(tab_index = @current_tab) # {{{2
  return if @tabs.size <= 1  # Always keep at least one tab
  
  @tabs.delete_at(tab_index)
  
  # Adjust current tab index if necessary
  if @current_tab >= @tabs.size
    @current_tab = @tabs.size - 1
  elsif tab_index < @current_tab
    @current_tab -= 1
  end
  
  restore_tab_state
  @pL.update = @pR.update = @pT.update = true
end

def new_tab # {{{2
  save_tab_state
  tab = create_tab(Dir.pwd)
  @current_tab = @tabs.size - 1
  @pL.update = @pR.update = @pT.update = true
end

def duplicate_tab # {{{2
  # Duplicate current tab with same directory and state
  save_tab_state
  current = current_tab
  # Create a smart copy name
  base_name = current[:name].gsub(/ Copy.*$/, '')  # Remove existing " Copy" suffixes
  copy_name = "#{base_name} Copy"
  tab = create_tab(current[:directory], copy_name)
  @current_tab = @tabs.size - 1
  @pL.update = @pR.update = @pT.update = true
end

def rename_tab # {{{2
  @pB.say("Enter new tab name: ".fg(156))
  name = gets_custom("Tab name: ", current_tab[:name])
  return if name.nil? || name.strip.empty?
  
  current_tab[:name] = name.strip
  @pT.update = true  # Update tab bar display
  @pB.clear
end


# Keyboard handler functions for tabs {{{2
def prev_tab # {{{2
  new_index = @current_tab > 0 ? @current_tab - 1 : @tabs.size - 1
  switch_to_tab(new_index)
end

def next_tab # {{{2
  new_index = @current_tab < @tabs.size - 1 ? @current_tab + 1 : 0
  switch_to_tab(new_index)
end

def switch_tab_1; switch_to_tab(0); end
def switch_tab_2; switch_to_tab(1); end
def switch_tab_3; switch_to_tab(2); end
def switch_tab_4; switch_to_tab(3); end
def switch_tab_5; switch_to_tab(4); end
def switch_tab_6; switch_to_tab(5); end
def switch_tab_7; switch_to_tab(6); end
def switch_tab_8; switch_to_tab(7); end
def switch_tab_9; switch_to_tab(8); end

def show_tab_overlay # {{{2
  # Show tab overlay for 2 seconds, then restore top pane
  @tab_bar_visible = true
  @tab_bar_hide_time = Time.now + 2
  
  # Show tab overlay immediately
  @pTab.text = render_tab_bar
  @pTab.refresh
end

def update_tab_overlay # {{{2
  # Check if we should hide the tab overlay and restore top pane
  if @tab_bar_visible && Time.now >= @tab_bar_hide_time
    @tab_bar_visible = false
    @pT.refresh  # Restore the top pane display
  end
end

def render_tab_bar # {{{2
  tab_display = []
  @tabs.each_with_index do |tab, i|
    name = tab[:name]
    # Show current directory in tab if different from name
    dir = File.basename(tab[:directory] || '')
    display_name = (name == "Main" && dir != name) ? "#{name}:#{dir}" : name
    display_name = display_name[0..12] + '…' if display_name.length > 14  # Truncate long names
    
    if i == @current_tab
      # Active tab with bright background and bold text
      tab_display << " #{i+1}:#{display_name} ".bg(226).fg(0).b  # Yellow background, black text, bold
    else
      # Inactive tab with subtle styling
      tab_display << " #{i+1}:#{display_name} ".fg(244)          # Gray text
    end
  end
  
  # Enhanced tab bar with better shortcuts
  if @tabs.size == 1
    # Show enhanced help for single tab
    "#{tab_display.join('')} | ]:new }:dup {:rename | Auto-hiding in #{(@tab_bar_hide_time - Time.now).ceil}s"
  else
    # Show all available tab shortcuts
    "#{tab_display.join('')} | ]:new }:dup {:rename [:close J/K:nav 1-9:switch"
  end
end

# DUAL-PANE HELPER FUNCTIONS REMOVED - using tabs for multi-directory navigation

# LOAD CONFIG {{{1
## Get variables from config file (written back to ~/.rtfm/conf when exit via 'q')
def load_config
  if File.exist?(CONFIG_FILE)
    load(CONFIG_FILE)
    @conf = File.read(CONFIG_FILE).dup
  else
    firstrun
  end
rescue StandardError => e
  errormsg("⚠ Errors while loading #{CONFIG_FILE}\nCheck your config file or delete it to remake in a fresh RTFM start.", e)
end
load_config
@marks['0']  = Dir.pwd # Original dir
@marks["'"]  = Dir.pwd # Initial mark

# Handle start dir {{{2
Dir.chdir(ARGV.shift) if ARGV[0] && File.directory?(ARGV[0])

# Initialize first tab {{{2
create_tab(Dir.pwd, "Main")

# OPENAI SETUP {{{1
def chat_history # {{{2
  @chat_history ||= [
    { role: 'system',
      content: 'You are a helpful assistant embedded in a terminal file manager. ' \
               'Answer questions about files, directories, or shell commands.' }
  ]
end

def openai_client # {{{2
  require 'ruby/openai' unless defined?(OpenAI)
  @openai_client ||= OpenAI::Client.new(access_token: @ai)
end

# SET UP VIEWER SYSTEM {{{1
# rubocop:disable Style/StringLiterals
preview_specs = {
  'txt, rb, py, sh'    => "#{@bat} -O ansi --force --line-numbers @s",
  'md'                 => "termmark @s",
  'pdf'                => "pdftotext -f 1 -l 4 @s - | tr -d '\\r'",  # Filter carriage returns
  'odt, odp, odg, odc' => "odt2txt @s",
  'docx'               => "docx2txt @s -",
  'xlsx'               => "ssconvert -O 'separator=   ' -T Gnumeric_stf:stf_assistant @s fd://1",
  'pptx'               => "unzip -qc @s | ruby -e '$stdin.each_line{ |i| i.force_encoding(\"ISO-8859-1\").scan(/<a:t>(.+?)<\\/a:t>/).each{ |j| puts j } }'",
  'doc'                => "catdoc @s 2>/dev/null",
  'xls'                => "xls2csv @s 2>/dev/null",
  'ppt'                => "catppt @s 2>/dev/null",
  # images ⇒ nil => call showimage
  'png, jpg, jpeg, bmp, gif, webp, tif, tiff, svg' => nil,
  # video ⇒ nil, but detected via pattern below
  'mpg, mpeg, avi, mov, mkv, mp4'             => nil
}
@imagefile ||= /\.(?:png|jpe?g|bmp|gif|webp|tiff?|svg)$/i
@pdffile   ||= /\.pdf$/i
# rubocop:enable Style/StringLiterals

# USER PLUGINS {{{1
# Merge in any user overrides from ~/.rtfm/preview.rb
if File.exist?(PREVIEW_FILE)
  begin
    File.readlines(PREVIEW_FILE).each_with_index do |line, idx|
      line = line.strip
      next if line.empty? || line.start_with?('#')

      if line =~ /\A([0-9A-Za-z_,\s]+)\s*=\s*(.+)\z/
        exts_str = $1.strip
        cmd_str  = $2.strip
        preview_specs[exts_str] = cmd_str
      else
        @plugin_errors << "Invalid preview.rb line #{idx + 1}: #{line}"
      end
    end
  rescue => e
    @plugin_errors << "Error loading preview.rb: #{e.class}: #{e.message}"
  end
end
# Compile into an array of [Regexp, template] for fast lookup
PREVIEW_HANDLERS = preview_specs.map do |exts_str, tmpl|
  exts = exts_str.split(',').map(&:strip).map { |ext| Regexp.escape(ext) }.join('|')
  [/\.(#{exts})$/i, tmpl]
end

# KEY DISPATCH TABLE & HANDLERS {{{1
KEYMAP = { # {{{2
  # BASIC KEYS {{{3
  '?'       => :show_help,
  'v'       => :show_version,
  'r'       => :refresh_all,
  'R'       => :load_config,
  'C'       => :show_config,
  'W'       => :write_config,
  'q'       => :quit_and_save,
  'Q'       => :quit_no_save,

  # LAYOUT {{{3
  'w'       => :change_width,
  'B'       => :toggle_border,
  '-'       => :toggle_preview,
  '_'       => :toggle_image,
  'b'       => :toggle_syntax,

  # MOTION {{{3
  'DOWN'    => :move_down,
  'j'       => :move_down,
  'C-DOWN'  => :move_down,
  'UP'      => :move_up,
  'k'       => :move_up,
  'C-UP'    => :move_up,
  'LEFT'    => :move_left,
  'h'       => :move_left,
  'C-LEFT'  => :move_left,
  'RIGHT'   => :move_right,
  'l'       => :move_right,
  'C-RIGHT' => :move_right,
  'x'       => :open_force,
  'PgDOWN'  => :page_down,
  'PgUP'    => :page_up,
  'END'     => :go_last,
  'HOME'    => :go_first,

  # MARKS & JUMPING {{{3
  'm'       => :set_mark,
  'M'       => :show_marks,
  "'"       => :jump_to_mark,
  '~'       => :go_home,
  '>'       => :follow_symlink,

  # DIRECTORY VIEWS {{{3
  'a'       => :toggle_all,
  'A'       => :toggle_long,
  'o'       => :toggle_order,
  'i'       => :toggle_invert,
  'O'       => :show_ls_command,

  # TAGGING {{{3
  't'       => :tag_current,
  'C-T'     => :tag_pattern,
  'T'       => :show_tagged,
  'u'       => :clear_tagged,

  # UNDO {{{3
  'U'       => :undo_last_operation,

  # RECENT FILES {{{3
  'C-R'     => :show_recent_files,

  # REMOTE BROWSING {{{3
  'C-E'     => :browse_remote,

  # TAB MANAGEMENT {{{3
  ']'       => :new_tab,
  '['       => :close_tab,
  'J'       => :prev_tab,
  'K'       => :next_tab,
  '}'       => :duplicate_tab,     # Duplicate current tab  
  '{'       => :rename_tab,        # Rename current tab
  '1'       => :switch_tab_1,
  '2'       => :switch_tab_2,
  '3'       => :switch_tab_3,
  '4'       => :switch_tab_4,
  '5'       => :switch_tab_5,
  '6'       => :switch_tab_6,
  '7'       => :switch_tab_7,
  '8'       => :switch_tab_8,
  '9'       => :switch_tab_9,

  # MANIPULATE ITEMS {{{3
  'p'       => :copy_items,
  'P'       => :move_items,
  'c'       => :rename_item,
  'E'       => :bulk_rename,
  'X'       => :compare_files,
  's'       => :link_items,
  'd'       => :delete_items,
  'D'       => :empty_trash,
  'C-D'     => :toggle_trash,
  'C-O'     => :change_ownership,
  'C-P'     => :change_permissions,

  # FILTER AND SEARCH {{{3
  'f'       => :filter_types,
  'F'       => :filter_regex,
  'C-F'     => :filter_clear,
  '/'       => :search_text,
  '\\'      => :clear_search,
  'n'       => :search_next,
  'N'       => :search_prev,
  'g'       => :grep_current,
  'L'       => :locate,
  '#'       => :jump_locate,
  'C-L'     => :fzf_jump,

  # ARCHIVES {{{3
  'z'       => :unzip_items,
  'Z'       => :zip_items,

  # GIT/HASH/OPENAI {{{3
  'G'       => :git_status,
  'H'       => :hash_directory,
  'I'       => :openai_description,
  'C-A'     => :chat_mode,

  # RIGHT PANE CONTROLS {{{3
  'ENTER'   => :refresh_right,
  'S-DOWN'  => :line_down_right,
  'S-UP'    => :line_up_right,
  'S-RIGHT' => :page_down_right,
  'TAB'     => :page_down_right,
  'S-LEFT'  => :page_up_right,
  'S-TAB'   => :page_up_right,

  # CLIPBOARD COPY {{{3
  'y'       => :copy_path_primary,
  'Y'       => :copy_path_clipboard,
  'C-Y'     => :copy_right,

  # SYSTEM SHORTCUTS {{{3
  'S'       => :system_info,
  '='       => :make_directory,
  'C-N'     => :navi_invoke,

  # COMMAND MODE {{{3
  ':'       => :command_mode,
  ';'       => :show_history,
  'C-;'     => :show_ssh_history,
  '+'       => :add_interactive,
  'e'       => :show_file_properties,

  # RUBY MODE {{{3
  '@'       => :ruby_debug
}

# USER KEYS (override or extend KEYMAP) {{{2
if File.exist?(KEYS_FILE)
  begin
    load KEYS_FILE
  rescue => e
    @plugin_errors << "Error loading keys.rb: #{e.class}: #{e.message}"
  end
end

# MAIN GETKEY FOR USER INPUT {{{2
def getkey # {{{3
  chr = getchr(1)
  
  # Check for image redraw on focus regain (even if no key was pressed)
  check_image_redraw if @showimage
  
  return unless chr

  showimage('clear') if @image
  @image = false
  if handler = KEYMAP[chr]  # rubocop:disable Lint/AssignmentInCondition
    m = method(handler)
    m.arity == 1 ? m.call(chr) : m.call
  end
rescue Errno::EIO
  # ignore transient TTY/read errors when upon focus switch
  # Note: rcurses 4.9.0+ has enhanced error handling, but keeping this for compatibility
  return
rescue StandardError => e
  errormsg('⚷ Error in getkey', e)
end

# BASIC KEYS {{{2
def show_help # {{{3
  help_info
end

def help_info # {{{3
  help_text = "RTFM Help\n".b.fg(156)
  help_text << "=" * 50 + "\n\n"
  
  # First pass: find the longest key to determine column alignment
  max_key_length = 0
  @help.lines.each do |line|
    if line =~ /^  ([\S\/\-]+)\s+=\s+/
      key_length = $1.length
      max_key_length = key_length if key_length > max_key_length
    end
  end
  
  # Add some padding for visual clarity
  equal_column = max_key_length + 2
  desc_column = equal_column + 2  # Description starts 2 spaces after '='
  
  # Process help text with formatting but create completely new strings
  @help.lines.each do |line|
    content = line.chomp  # Remove newline and create new string
    
    case content
    when /^[A-Z][A-Z \/]+$/  # Section headers like "BASIC KEYS", "GIT/HASH/OPENAI" (no leading spaces)
      help_text << content.b.fg(156) + "\n"  # Removed extra newline before
    when /^  RTFM - Ruby Terminal File Manager/  # Title
      help_text << content.b.fg(154) + "\n"
    when /^COPYRIGHT:/  # Copyright line (no leading spaces)
      help_text << "\n" + content.fg(242) + "\n"
    when /^  (\S+)\s+=\s+/  # Key lines: "  key = description"
      # Extract the key part and the description part
      if content =~ /^  ([\S\/\-]+)\s+=\s+(.*)$/
        key_part = $1
        desc_part = $2
        padding = " " * (equal_column - key_part.length)
        help_text << "  " + key_part.fg(51) + padding + "= ".fg(252) + desc_part.fg(252) + "\n"  # Changed to lighter gray (252)
      else
        help_text << content.fg(252) + "\n"
      end
    when /^            /  # Continuation lines (12 spaces)
      # Align continuation lines with the description column
      indent = " " * (2 + desc_column)  # 2 for initial indent + column position
      help_text << indent + content.strip.fg(252) + "\n"
    else
      help_text << content.fg(252) + "\n"
    end
  end
  
  @pR.say(help_text)
end

def show_version # {{{3
  @pB.say(" RTFM version = #{@version} (latest RubyGems version is #{Gem.latest_version_for('rtfm-filemanager').version} - https://github.com/isene/RTFM)".b)
end

def refresh_all # {{{3
  # Clear remote directory cache if in remote mode
  if @remote_mode
    @remote_files_cache = []
    @pL.update = true
  end
  refresh
end

def show_config
  @pR.say('Configuration'.u.fg(254) + ":\n\n" + @conf.fg(249))
end

def write_config # {{{3
  conf_write(all: true)
  show_config
end

def quit_and_save # {{{3
  shell("printf \"\e]0;#{Dir.pwd}\007\"")
  conf_write
  exit_rtfm
end

def quit_no_save # {{{3
  shell("printf \"\e]0;#{Dir.pwd}\007\"")
  exit_rtfm
end

# LAYOUT {{{2
def change_width # {{{3
  @width += 1
  @width = 2 if @width == 8
  
  if @dual_pane
    # Show width setting info for dual-pane mode using the new ratio calculation
    dir_panes_ratio = [0.5 - (@width - 2) * 0.034, 0.33].max
    preview_ratio = 1.0 - dir_panes_ratio
    @pB.say("Width: #{@width} (Dir panes: #{(dir_panes_ratio * 100).to_i}%, Preview: #{(preview_ratio * 100).to_i}%)")
  else
    @pB.say("Width: #{@width}")
  end
  
  refresh
  @pL.update = @pR.update = @pT.update = @pB.update = true
  # Also update dual-pane objects if they exist
  if @dual_pane && @pLeft && @pRight && @pPreview
    @pLeft.update = @pRight.update = @pPreview.update = true
  end
end

def toggle_border # {{{3
  @border = (@border + 1) % 4
  setborder
  @pL.update = @pR.update = @pT.update = @pB.update = true
end

def toggle_preview # {{{3
  @preview = !@preview
  @pB.say("Preview = #{@preview ? 'On' : 'Off'}")
  @pR.clear unless @preview
end

def toggle_image # {{{3
  @showimage = !@showimage
  @pB.say("Image preview = #{@showimage ? 'On' : 'Off'}")
  getch
end

def toggle_syntax # {{{3
  @batuse = !@batuse
  @pB.say("Syntax highlighting = #{@batuse ? 'On' : 'Off'}")
  @pR.update = true
end

# toggle_dual_pane function removed - using tabs for multi-directory navigation

# MOTION {{{2
def move_down # {{{3
  @index = @index >= @max_index ? @min_index : @index + 1
  @pL.update = true
  # In remote mode, only update bottom pane (for file attributes)
  if @remote_mode
    @pB.update = true
  else
    @pR.update = @pB.update = true
  end
end

def move_up # {{{3
  @index = @index <= @min_index ? @max_index : @index - 1
  @pL.update = true
  # In remote mode, only update bottom pane (for file attributes)
  if @remote_mode
    @pB.update = true
  else
    @pR.update = @pB.update = true
  end
end

def move_left # {{{3
  if @remote_mode
    # Remote mode - go to parent directory
    return if @remote_path == '/' || @remote_path == '~'
    
    old_path = @remote_path
    @remote_path = File.dirname(@remote_path)
    @remote_files_cache = []  # Clear cache when changing directories
    @index = 0  # Reset selection
    @pL.update = true
    @pR.update = @pB.update = true
  else
    # Local mode
    old_dir = Dir.pwd
    parent  = File.dirname(old_dir)
    child   = File.basename(old_dir)
    purels = command(
      "ls #{Shellwords.escape(parent)} #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}"
    ).pure.split("\n")
    child_idx = purels.index(child) || 0
    @directory[parent] = child_idx
    mark_latest
    Dir.chdir(parent)
    track_directory_access(parent)
    @pL.update = true
    @pR.update = @pB.update = true
  end
end

# dirlist_simple function removed - was only for dual-pane navigation

def move_right # {{{3
  if @remote_mode
    # Remote mode - enter directory or perform action on file
    return unless @files && @files[@index] && @remote_files_cache[@index]
    
    selected_file = @remote_files_cache[@index]
    
    if selected_file[:type] == 'directory'
      # Enter directory
      @remote_path = File.join(@remote_path, selected_file[:name])
      @remote_files_cache = []  # Clear cache when changing directories
      @index = 0  # Reset selection
      @pL.update = true
      @pR.update = @pB.update = true
    else
      # File selected - show file info in right pane
      show_remote_file_info(selected_file)
    end
  else
    # Local mode
    @directory[Dir.pwd] = @index
    mark_latest
    open_selected
    @index = @directory[Dir.pwd] || 0
    @pB.update = true
  end
end

def open_force # {{{3
  @directory[Dir.pwd] = @index
  mark_latest
  open_selected(true)
  @pB.update = true
end

def page_down # {{{3
  @index += @pL.h - 2
  @index = @max_index if @index > @max_index
  @pL.update = true
  # In remote mode, only update bottom pane (for file attributes)
  if @remote_mode
    @pB.update = true
  else
    @pR.update = @pB.update = true
  end
end

def page_up # {{{3
  @index -= @pL.h - 2
  @index = @min_index if @index < @min_index
  @pL.update = true
  # In remote mode, only update bottom pane (for file attributes)
  if @remote_mode
    @pB.update = true
  else
    @pR.update = @pB.update = true
  end
end

def go_last # {{{3
  @index = @max_index
  @pL.update = true
  # In remote mode, only update bottom pane (for file attributes)
  if @remote_mode
    @pB.update = true
  else
    @pR.update = @pB.update = true
  end
end

def go_first # {{{3
  @index = @min_index
  @pL.update = true
  # In remote mode, only update bottom pane (for file attributes)
  if @remote_mode
    @pB.update = true
  else
    @pR.update = @pB.update = true
  end
end

# switch_pane function removed - using tabs for multi-directory navigation

# MARKS & JUMPING {{{2
def set_mark # {{{3
  marks_info
  @pB.say("Set mark by pressing any letter. Delete mark by pressing '-' and the letter of the mark to remove.".fg(156))
  m = getchr
  return if m == 'ESC'

  if m =~ /[\w']/
    @marks[m] = Dir.pwd
  elsif m == '-'
    @pB.say('Press the letter of the mark to remove.'.fg(156))
    r = getchr; @marks.delete(r)
  end
  marks_info
  @pB.update = true
end

def show_marks # {{{3
  marks_info
  @pB.update = true
end

def jump_to_mark # {{{3
  marks_info
  m = getchr
  if m =~ /[\w']/ && @marks[m]
    @directory[Dir.pwd] = @index
    dir_before = Dir.pwd
    begin; Dir.chdir(@marks[m]); track_directory_access(@marks[m]); rescue; @pB.say(' No such directory'); end
    mark_latest
    @marks["'"] = dir_before
  end
  @pR.update = @pB.update = true
end

def go_home # {{{3
  if @dual_pane
    # In dual-pane mode, navigate the active pane to home directory
    home_dir = Dir.home
    
    # Get home directory listing
    newfiles = command(
      "ls #{Shellwords.escape(home_dir)} #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}"
    ).pure.split("\n")
    
    # Update the active pane's state
    if @active_pane == :left
      @directory_left[@pwd_left] = @index_left
      @pwd_left = home_dir
      @index_left = 0
      @files_left = newfiles
      @selected_left = newfiles[0] if newfiles.length > 0
      @directory_left[home_dir] = 0
      @pLeft.update = true
    else
      @directory_right[@pwd_right] = @index_right
      @pwd_right = home_dir
      @index_right = 0
      @files_right = newfiles
      @selected_right = newfiles[0] if newfiles.length > 0
      @directory_right[home_dir] = 0
      @pRight.update = true
    end
    
    # Update compatibility variables
    @index = @active_pane == :left ? @index_left : @index_right
    @files = @active_pane == :left ? @files_left : @files_right
    @selected = @active_pane == :left ? @selected_left : @selected_right
    
    @pPreview.update = true if @pPreview
  else
    # Original single-pane logic
    @directory[Dir.pwd] = @index
    mark_latest
    Dir.chdir
  end
  @pR.update = @pB.update = true
end

def follow_symlink # {{{3
  @directory[Dir.pwd] = @index; mark_latest
  if File.symlink?(@selected)
    begin
      # Track visited symlinks to prevent loops
      @symlink_history ||= Set.new
      resolved_path = File.realpath(@selected)
      
      if @symlink_history.include?(resolved_path)
        @pB.say("Error: Symlink loop detected".fg(196))
        return
      end
      
      @symlink_history.add(resolved_path)
      target = File.readlink(@selected)
      target = File.expand_path(target, File.dirname(@selected)) unless target.start_with?('/')
      
      # Check if target is a directory or file
      if File.directory?(target)
        Dir.chdir(target)
      elsif File.file?(target)
        # If it's a file, change to the directory containing the file
        target_dir = File.dirname(target)
        target_basename = File.basename(target)
        Dir.chdir(target_dir)
        # Clear the index first to force a full refresh
        @index = 0
        @selected = nil
        # Refresh the directory listing
        dirlist
        # Find and select the target file in the new directory
        file_index = @files.index(target_basename)
        if file_index
          @index = file_index
          @selected = @files[@index]
          # Update the directory memory with the new index so it's not reset by the main loop
          @directory[Dir.pwd] = @index
        else
          # If file not found in listing (possibly filtered), still try to select it
          @selected = target_basename
          @directory[Dir.pwd] = 0
        end
      else
        @pB.say("Error: Symlink target does not exist or is not accessible".fg(196))
        @symlink_history&.clear
        return
      end
      
      # Clear history after successful navigation
      @symlink_history.clear
    rescue => e
      @pB.say("Error following symlink: #{e}")
      @symlink_history&.clear
    end
  end
  @pB.update = true
end

# DIRECTORY VIEWS {{{2
def toggle_all # {{{3
  @lsall = @lsall.empty? ? '-a' : ''
  @pR.update = @pB.update = true
end

def toggle_long # {{{3
  @lslong = @lslong.empty? ? (@is_macos_bsd ? '-lhT' : '-lh --time-style=long-iso') : ''
  @pR.update = @pB.update = true
end

def toggle_order # {{{3
  case @lsorder
  when ''
    @lsorder = '-S'; @pB.say(' Sorting by size')
  when '-S'
    @lsorder = '-t'; @pB.say(' Sorting by time')
  when '-t'
    @lsorder = '-X'; @pB.say(' Sorting by extension')
  else
    @lsorder = '';  @pB.say(' Normal sorting')
  end
  @pR.update = true; @orderchange = true
end

def toggle_invert # {{{3
  @lsinvert = @lsinvert.empty? ? '-r' : ''
  @pB.say(' Sorting inverted')
  @pR.update = true; @orderchange = true
end

def show_ls_command # {{{3
  @pB.say(" Full 'ls' command: ls #{@lsbase} #{@lslong} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}".gsub(/ +/, ' '))
  @pB.update = false
end

# TAGGING {{{2
def tag_current # {{{3
  if @dual_pane
    # In dual-pane mode, get the correct selected item and construct full path
    current_dir = @active_pane == :left ? @pwd_left : @pwd_right
    current_index = @active_pane == :left ? @index_left : @index_right
    current_files = @active_pane == :left ? @files_left : @files_right
    
    if current_files && current_index < current_files.length
      selected_item = current_files[current_index]
      item = File.join(current_dir, selected_item)
      
      # Tag/untag the item
      if @tagged.include?(item)
        @tagged.delete(item); @tagsize -= File.size(item) rescue 0
      else
        @tagged.push(item); @tagsize += File.size(item) rescue 0
      end
      
      # Advance to next item in the active pane
      max_index = current_files.size - 1
      if @active_pane == :left
        @index_left = [@index_left + 1, max_index].min
        @selected_left = current_files[@index_left] if current_files[@index_left]
        @pLeft.update = true
      else
        @index_right = [@index_right + 1, max_index].min
        @selected_right = current_files[@index_right] if current_files[@index_right]
        @pRight.update = true
      end
      
      # Update compatibility variables
      @index = @active_pane == :left ? @index_left : @index_right
      @selected = @active_pane == :left ? @selected_left : @selected_right
      
      @pPreview.update = true if @pPreview
    end
  else
    # Original single-pane logic
    item = @selected
    if @tagged.include?(item)
      @tagged.delete(item); @tagsize -= File.size(item) rescue 0
    else
      @tagged.push(item); @tagsize += File.size(item) rescue 0
    end
    @index = [@index + 1, (@files.size - 1)].min
    @pL.update = true
  end
  
  @pB.say(" Tagged #{@tagged.size} files (#{(@tagsize.to_f / 1_000_000).round(2)}MB)".fg(204))
  @pB.update = false; @pR.update = true
end

def tag_pattern # {{{3
  pat = @pB.ask('Tag pattern (ruby regex): ', '')
  re  = Regexp.new(pat)
  matches = @files.grep(re).map { |t| File.join(Dir.pwd, t) }
  matches.each do |f|
    @tagsize += File.size(f) rescue nil
  end
  @tagged.concat(matches)
  @tagged.uniq!
  @pB.say(" Tagged #{@tagged.size} files (#{(@tagsize.to_f / 1_000_000).round(2)}MB)".fg(204))
  @pB.update = false
  @pR.update = true
end

def show_tagged # {{{3
  tagged_info
  @pB.update = true
end

def clear_tagged # {{{3
  if @remote_mode
    # In remote mode, 'u' key uploads a file
    remote_upload_file
    return
  end
  
  @tagged = []
  tagged_info
  @pB.update = true
end

# UNDO SYSTEM {{{2
def add_undo_operation(operation) # {{{3
  return unless @undo_enabled
  
  @undo_history << operation
  # Keep only the most recent operations
  @undo_history.shift if @undo_history.length > @max_undo_levels
end

def undo_last_operation # {{{3
  return unless @undo_enabled
  
  if @undo_history.empty?
    @pB.say("No operations to undo".fg(196))
    return
  end
  
  operation = @undo_history.pop
  
  begin
    case operation[:type]
    when 'delete'
      undo_delete(operation)
    when 'move'
      undo_move(operation)
    when 'rename'
      undo_rename(operation)
    when 'copy'
      undo_copy(operation)
    when 'link'
      undo_link(operation)
    when 'bulk_rename'
      undo_bulk_rename(operation)
    else
      @pB.say("Unknown operation type: #{operation[:type]}".fg(196))
      return
    end
    
    dirlist
    render
    @pB.say("Undid #{operation[:type]} operation".fg(156))
  rescue StandardError => e
    @pB.say("Undo failed: #{e.message}".fg(196))
    # Don't re-add the operation to history if undo failed
  end
end

def undo_delete(operation) # {{{3
  if operation[:trash]
    # Restore from trash
    operation[:paths].each do |path_info|
      source = File.join(TRASH_DIR, File.basename(path_info[:path]))
      dest = path_info[:path]
      
      if File.exist?(source)
        FileUtils.mv(source, dest)
      else
        raise "Cannot restore #{path_info[:path]}: not found in trash"
      end
    end
  else
    raise "Cannot undo permanent deletion"
  end
end

def undo_move(operation) # {{{3
  # Move items back to their original locations
  operation[:moves].each do |move_info|
    source = move_info[:dest_path]
    dest = move_info[:source_path]
    
    if File.exist?(source)
      FileUtils.mv(source, dest)
    else
      raise "Cannot undo move: #{source} not found"
    end
  end
end

def undo_rename(operation) # {{{3
  old_path = operation[:old_path]
  new_path = operation[:new_path]
  
  if File.exist?(new_path)
    FileUtils.mv(new_path, old_path)
  else
    raise "Cannot undo rename: #{new_path} not found"
  end
end

def undo_copy(operation) # {{{3
  # Remove copied files
  operation[:copies].each do |copy_info|
    dest = copy_info[:dest_path]
    
    if File.exist?(dest)
      if File.directory?(dest)
        FileUtils.rm_rf(dest)
      else
        FileUtils.rm(dest)
      end
    end
  end
end

def undo_link(operation) # {{{3
  # Remove symlinks
  operation[:links].each do |link_info|
    dest = link_info[:dest_path]
    
    if File.symlink?(dest)
      FileUtils.rm(dest)
    elsif File.exist?(dest)
      raise "Cannot undo link: #{dest} is not a symlink"
    end
  end
end

def undo_bulk_rename(operation) # {{{3
  # Undo bulk rename operations by reversing each rename
  operation[:renames].reverse.each do |rename_info|
    old_path = rename_info[:old_path]
    new_path = rename_info[:new_path]
    
    if File.exist?(new_path)
      FileUtils.mv(new_path, old_path)
    else
      raise "Cannot undo bulk rename: #{new_path} not found"
    end
  end
end

# RECENTLY ACCESSED FILES {{{2
def track_file_access(file_path) # {{{3
  return unless File.exist?(file_path)
  
  abs_path = File.expand_path(file_path)
  @recent_files.delete(abs_path)  # Remove if already exists
  @recent_files.unshift(abs_path) # Add to front
  @recent_files = @recent_files.first(@max_recent_files) # Limit size
end

def track_directory_access(dir_path) # {{{3
  return unless File.directory?(dir_path)
  
  abs_path = File.expand_path(dir_path)
  @recent_dirs.delete(abs_path)   # Remove if already exists
  @recent_dirs.unshift(abs_path)  # Add to front  
  @recent_dirs = @recent_dirs.first(@max_recent_dirs) # Limit size
end

def show_recent_files # {{{3
  text = "Recently Accessed Files and Directories\n".b.fg(156)
  text << "=" * 40 + "\n\n"
  
  unless @recent_files.empty?
    text << "Files:\n".fg(226)
    @recent_files.first(15).each_with_index do |file, i|
      basename = File.basename(file)
      dirname = File.dirname(file)
      mtime = File.exist?(file) ? File.mtime(file).strftime("%Y-%m-%d %H:%M") : "MISSING"
      text << sprintf("%2d. %-30s %s\n", i + 1, basename.fg(156), "#{dirname} (#{mtime})".fg(240))
    end
    text << "\n"
  end
  
  unless @recent_dirs.empty?
    text << "Directories:\n".fg(226)  
    @recent_dirs.first(10).each_with_index do |dir, i|
      basename = File.basename(dir) 
      parent = File.dirname(dir)
      exists = File.exist?(dir) ? "✓".fg(156) : "✗".fg(196)
      text << sprintf("%2d. %s %-25s %s\n", i + 1, exists, basename.fg(156), parent.fg(240))
    end
  end
  
  if @recent_files.empty? && @recent_dirs.empty?
    text << "No recently accessed files or directories.\n".fg(240)
    text << "Files and directories will appear here as you use RTFM.\n".fg(240)
  else
    text << "\nPress number to jump to item, or any other key to close.".fg(240)
  end
  
  @pR.say(text)
  @pR.update = false
  
  # Handle selection
  chr = getchr
  if chr =~ /\d/
    num = chr.to_i
    if num > 0 && num <= @recent_files.length
      # Jump to recent file
      target = @recent_files[num - 1]
      if File.exist?(target)
        Dir.chdir(File.dirname(target))
        dirlist
        # Select the file in the list
        basename = File.basename(target)
        @index = @files.index(basename) || 0
        @selected = target
        render
      else
        @pB.say("File no longer exists: #{target}".fg(196))
      end
    elsif num > 0 && num <= @recent_dirs.length + @recent_files.length 
      # Jump to recent directory
      dir_index = num - @recent_files.length - 1
      if dir_index >= 0 && dir_index < @recent_dirs.length
        target = @recent_dirs[dir_index]
        if File.directory?(target)
          Dir.chdir(target)
          dirlist 
          render
        else
          @pB.say("Directory no longer exists: #{target}".fg(196))
        end
      end
    end
  end
  
  @pR.update = true
end

# FILE PROPERTIES {{{2
def show_file_properties # {{{3
  return unless @selected && File.exist?(@selected)
  
  begin
    stat = File.stat(@selected)
    text = "File Properties: #{File.basename(@selected)}\n".b.fg(156)
    text << "=" * 50 + "\n\n"
    
    # Basic information
    text << "Basic Information:\n".fg(226)
    text << sprintf("  %-20s %s\n", "Full Path:", @selected.fg(156))
    text << sprintf("  %-20s %s\n", "Directory:", File.dirname(@selected).fg(240))
    text << sprintf("  %-20s %s\n", "Size:", format_file_size(stat.size))
    text << sprintf("  %-20s %s\n", "Type:", File.ftype(@selected).capitalize.fg(156))
    
    # MIME type
    begin
      mime_output = `file --mime-type #{Shellwords.escape(@selected)} 2>/dev/null`.strip
      mime_type = mime_output.split(':')[1]&.strip || "Unknown"
      text << sprintf("  %-20s %s\n", "MIME Type:", mime_type.fg(156))
    rescue
      text << sprintf("  %-20s %s\n", "MIME Type:", "Unknown".fg(240))
    end
    
    text << "\n"
    
    # Permissions and ownership
    text << "Permissions & Ownership:\n".fg(226)
    mode_oct = sprintf("%04o", stat.mode & 0777)
    mode_str = File.world_readable?(@selected) ? 
               sprintf("%s (readable)", mode_oct) : 
               sprintf("%s (protected)", mode_oct)
    text << sprintf("  %-20s %s\n", "Permissions:", mode_str.fg(156))
    
    begin
      require 'etc'
      owner = Etc.getpwuid(stat.uid).name rescue stat.uid.to_s
      group = Etc.getgrgid(stat.gid).name rescue stat.gid.to_s
      text << sprintf("  %-20s %s\n", "Owner:Group:", "#{owner}:#{group}".fg(156))
    rescue
      text << sprintf("  %-20s %s\n", "Owner:Group:", "#{stat.uid}:#{stat.gid}".fg(156))
    end
    
    text << "\n"
    
    # Timestamps
    text << "Timestamps:\n".fg(226)
    text << sprintf("  %-20s %s\n", "Created:", stat.ctime.strftime("%Y-%m-%d %H:%M:%S").fg(156))
    text << sprintf("  %-20s %s\n", "Modified:", stat.mtime.strftime("%Y-%m-%d %H:%M:%S").fg(156))
    text << sprintf("  %-20s %s\n", "Accessed:", stat.atime.strftime("%Y-%m-%d %H:%M:%S").fg(156))
    
    # Symlink information
    if File.symlink?(@selected)
      text << "\n"
      text << "Symlink Information:\n".fg(226)
      begin
        target = File.readlink(@selected)
        text << sprintf("  %-20s %s\n", "Points to:", target.fg(156))
        text << sprintf("  %-20s %s\n", "Target exists:", File.exist?(target) ? "Yes".fg(156) : "No".fg(196))
      rescue
        text << sprintf("  %-20s %s\n", "Target:", "Cannot read link".fg(196))
      end
    end
    
    # Directory-specific information
    if File.directory?(@selected)
      text << "\n"
      text << "Directory Information:\n".fg(226)
      begin
        entries = Dir.entries(@selected).reject { |e| e == '.' || e == '..' }
        text << sprintf("  %-20s %d\n", "Total entries:", entries.length)
        dirs = entries.select { |e| File.directory?(File.join(@selected, e)) }
        files = entries.select { |e| File.file?(File.join(@selected, e)) }
        text << sprintf("  %-20s %s\n", "Breakdown:", "#{dirs.length} directories, #{files.length} files")
      rescue
        text << sprintf("  %-20s %s\n", "Contents:", "Cannot read directory".fg(196))
      end
    end
    
    # File-specific information
    if File.file?(@selected)
      text << "\n"
      text << "File Information:\n".fg(226)
      
      # Try to get checksum for regular files
      if stat.size < 100 * 1024 * 1024 # Only for files under 100MB
        begin
          require 'digest'
          checksum = Digest::SHA256.file(@selected).hexdigest[0, 16]
          text << sprintf("  %-20s %s...\n", "SHA256 (partial):", checksum.fg(156))
        rescue
          text << sprintf("  %-20s %s\n", "Checksum:", "Cannot calculate".fg(240))
        end
      else
        text << sprintf("  %-20s %s\n", "Checksum:", "File too large".fg(240))
      end
      
      # Check if binary or text
      begin
        File.open(@selected, 'rb') do |f|
          chunk = f.read(1024)
          is_binary = chunk && chunk.encoding == Encoding::ASCII_8BIT && 
                     (chunk.bytes.any? { |b| b < 32 && ![9, 10, 13].include?(b) })
          text << sprintf("  %-20s %s\n", "Content type:", is_binary ? "Binary".fg(196) : "Text".fg(156))
        end
      rescue
        text << sprintf("  %-20s %s\n", "Content type:", "Unknown".fg(240))
      end
    end
    
    text << "\n"
    text << "Press any key to close...".fg(240)
    
    @pR.say(text)
    @pR.update = false
    getchr
    @pR.update = true
    
  rescue StandardError => e
    @pB.say("Error getting file properties: #{e.message}".fg(196))
  end
end

def format_file_size(bytes) # {{{3
  units = ['B', 'KB', 'MB', 'GB', 'TB']
  size = bytes.to_f
  unit_index = 0
  
  while size >= 1024 && unit_index < units.length - 1
    size /= 1024.0
    unit_index += 1
  end
  
  if unit_index == 0
    "#{size.to_i} #{units[unit_index]}".fg(156)
  else
    "#{sprintf('%.1f', size)} #{units[unit_index]}".fg(156)
  end
end

# BULK RENAME {{{2
def bulk_rename # {{{3
  if @tagged.empty?
    @pB.say("No files tagged for bulk rename. Tag files first with 't'".fg(196))
    return
  end
  
  @pB.say("Bulk rename pattern: ".fg(156))
  @pR.say(build_pattern_help)
  @pR.update = false
  
  pattern = @pCmd.ask('Pattern: ', '')
  return if pattern.nil? || pattern.strip.empty?
  
  # Parse and preview renames
  rename_operations = []
  errors = []
  
  @tagged.each do |file|
    next unless File.exist?(file)
    
    old_name = File.basename(file)
    new_name = apply_rename_pattern(old_name, pattern)
    
    if new_name && new_name != old_name
      old_path = file
      new_path = File.join(File.dirname(file), new_name)
      
      if File.exist?(new_path) && new_path != old_path
        errors << "#{old_name} -> #{new_name} (target exists)"
      else
        rename_operations << {
          old_path: old_path,
          new_path: new_path,
          old_name: old_name,
          new_name: new_name
        }
      end
    end
  end
  
  # Show preview
  show_rename_preview(rename_operations, errors, pattern)
  
  if rename_operations.empty?
    @pB.say("No valid renames to perform".fg(196))
    return
  end
  
  # Confirm and execute
  @pB.say("Apply #{rename_operations.length} renames? (y/N): ".fg(226))
  
  if getchr.downcase == 'y'
    successful_renames = []
    failed_renames = []
    
    rename_operations.each do |op|
      begin
        FileUtils.mv(op[:old_path], op[:new_path])
        successful_renames << op
      rescue StandardError => e
        failed_renames << { operation: op, error: e.message }
      end
    end
    
    # Record undo information for successful renames
    unless successful_renames.empty?
      add_undo_operation({
        type: 'bulk_rename',
        renames: successful_renames,
        timestamp: Time.now
      })
    end
    
    # Show results
    result_msg = "Renamed #{successful_renames.length} files"
    result_msg += ", #{failed_renames.length} failed" unless failed_renames.empty?
    @pB.say(result_msg.fg(successful_renames.empty? ? 196 : 156))
    
    # Update file listing and clear tags
    @tagged.clear
    dirlist
    render
  else
    @pB.say("Bulk rename cancelled".fg(240))
  end
  
  @pR.update = true
end

def apply_rename_pattern(old_name, pattern) # {{{3
  case pattern
  when /^s\/(.+?)\/(.+?)\/([gimx]*)$/
    # Regex substitution: s/old/new/flags
    regex_pattern, replacement, flags = $1, $2, $3
    options = 0
    options |= Regexp::IGNORECASE if flags.include?('i')
    options |= Regexp::MULTILINE if flags.include?('m')
    options |= Regexp::EXTENDED if flags.include?('x')
    
    begin
      regex = Regexp.new(regex_pattern, options)
      if flags.include?('g')
        old_name.gsub(regex, replacement)
      else
        old_name.sub(regex, replacement)
      end
    rescue StandardError
      nil # Invalid regex
    end
    
  when /^\*(.+)$/
    # Append suffix: *_backup
    suffix = $1
    base = File.basename(old_name, '.*')
    ext = File.extname(old_name)
    "#{base}#{suffix}#{ext}"
    
  when /^(.+)\*$/
    # Prepend prefix: backup_*
    prefix = $1
    "#{prefix}#{old_name}"
    
  when /^(.+)\*(.+)$/
    # Prefix and suffix: backup_*_old
    prefix, suffix = $1, $2
    base = File.basename(old_name, '.*')
    ext = File.extname(old_name)
    "#{prefix}#{base}#{suffix}#{ext}"
    
  when /^upper$/i
    # Convert to uppercase
    old_name.upcase
    
  when /^lower$/i
    # Convert to lowercase
    old_name.downcase
    
  when /^title$/i
    # Convert to title case
    old_name.split(/[-_\s]/).map(&:capitalize).join('_')
    
  when /^\*\.(.+)$/
    # Change extension: *.txt
    new_ext = $1
    base = File.basename(old_name, '.*')
    "#{base}.#{new_ext}"
    
  when /^(\d+)-(\d+)$/
    # Sequential numbering: 1-100 (start-end)
    # This will be handled by the caller with an index
    nil
    
  else
    # Direct replacement
    pattern
  end
end

def build_pattern_help # {{{3
  <<~HELP
    Bulk Rename Patterns:
    
    Regex substitution:
      s/old/new/       - Replace first occurrence
      s/old/new/g      - Replace all occurrences  
      s/old/new/i      - Case insensitive
    
    Template patterns:
      prefix_*         - Add prefix: "backup_filename.txt"
      *_suffix         - Add suffix: "filename_backup.txt"
      prefix_*_suffix  - Both: "backup_filename_old.txt"
    
    Extension change:
      *.txt            - Change all extensions to .txt
    
    Case conversion:
      upper            - Convert to UPPERCASE
      lower            - convert to lowercase  
      title            - Convert To Title_Case
    
    Examples:
      s/IMG/Photo/g    - Replace "IMG" with "Photo"
      backup_*         - Add "backup_" prefix
      *_old            - Add "_old" suffix
      *.backup         - Change extension to .backup
      lower            - Convert to lowercase
  HELP
end

def show_rename_preview(operations, errors, pattern) # {{{3
  text = "Bulk Rename Preview: #{pattern}\n".b.fg(156)
  text << "=" * 50 + "\n\n"
  
  unless operations.empty?
    text << "Successful renames (#{operations.length}):\n".fg(156)
    operations.first(10).each do |op|
      text << "  #{op[:old_name].fg(240)} -> #{op[:new_name].fg(156)}\n"
    end
    
    if operations.length > 10
      text << "  ... and #{operations.length - 10} more\n".fg(240)
    end
    text << "\n"
  end
  
  unless errors.empty?
    text << "Errors (#{errors.length}):\n".fg(196)
    errors.first(5).each do |error|
      text << "  #{error}\n".fg(196)
    end
    
    if errors.length > 5
      text << "  ... and #{errors.length - 5} more errors\n".fg(240)
    end
    text << "\n"
  end
  
  if operations.empty? && errors.empty?
    text << "No changes would be made with this pattern.\n".fg(240)
  end
  
  @pR.say(text)
end

# FILE COMPARISON {{{2
def compare_files # {{{3
  if @tagged.length == 0
    @pB.say("No files tagged for comparison. Tag 2 files with 't'".fg(196))
    return
  elsif @tagged.length == 1
    @pB.say("Only 1 file tagged. Tag a second file to compare with #{File.basename(@tagged[0])}".fg(196))
    return
  elsif @tagged.length > 2
    @pB.say("Too many files tagged (#{@tagged.length}). Tag exactly 2 files for comparison.".fg(196))
    return
  end
  
  file1, file2 = @tagged
  
  # Validate files exist
  unless File.exist?(file1) && File.exist?(file2)
    @pB.say("One or both tagged files no longer exist".fg(196))
    return
  end
  
  # Show comparison
  show_file_comparison(file1, file2)
end

def show_file_comparison(file1, file2) # {{{3
  basename1 = File.basename(file1)
  basename2 = File.basename(file2)
  
  text = "File Comparison\n".b.fg(156)
  text << "=" * 50 + "\n\n"
  text << sprintf("%-25s vs %s\n", basename1.fg(156), basename2.fg(156))
  text << "=" * 50 + "\n\n"
  
  # Basic file info comparison
  stat1 = File.stat(file1)
  stat2 = File.stat(file2)
  
  text << "File Information:\n".fg(226)
  text << sprintf("  %-20s %-25s %s\n", "Size:", format_size_simple(stat1.size), format_size_simple(stat2.size))
  text << sprintf("  %-20s %-25s %s\n", "Modified:", stat1.mtime.strftime("%Y-%m-%d %H:%M"), stat2.mtime.strftime("%Y-%m-%d %H:%M"))
  text << sprintf("  %-20s %-25s %s\n", "Type:", File.ftype(file1), File.ftype(file2))
  
  # Check if files are identical
  if stat1.size == stat2.size && files_identical?(file1, file2)
    text << "\n"
    text << "Files are identical! ✓".fg(156).b
    @pR.say(text)
    @pR.update = false
    getchr
    @pR.update = true
    return
  end
  
  text << "\n"
  
  # Determine comparison type
  if binary_file?(file1) || binary_file?(file2)
    text << show_binary_comparison(file1, file2, stat1, stat2)
  else
    text << show_text_comparison(file1, file2)
  end
  
  text << "\n"
  text << "Press any key to close...".fg(240)
  
  @pR.say(text)
  @pR.update = false
  getchr
  @pR.update = true
end

def show_binary_comparison(file1, file2, stat1, stat2) # {{{3
  text = "Binary File Comparison:\n".fg(226)
  
  # Size comparison
  size_diff = stat2.size - stat1.size
  if size_diff == 0
    text << "  Same size: #{format_size_simple(stat1.size)}\n".fg(156)
  else
    sign = size_diff > 0 ? "+" : ""
    text << "  Size difference: #{sign}#{format_size_simple(size_diff.abs)}\n".fg(size_diff > 0 ? 196 : 156)
  end
  
  # Checksum comparison
  text << "\n  Computing checksums...\n".fg(240)
  
  begin
    require 'digest'
    hash1 = Digest::SHA256.file(file1).hexdigest
    hash2 = Digest::SHA256.file(file2).hexdigest
    
    if hash1 == hash2
      text << "  SHA256: Identical ✓\n".fg(156)
    else
      text << "  SHA256: Different ✗\n".fg(196)
      text << "    File 1: #{hash1[0, 16]}...\n".fg(240)
      text << "    File 2: #{hash2[0, 16]}...\n".fg(240)
    end
  rescue StandardError => e
    text << "  Checksum: Error - #{e.message}\n".fg(196)
  end
  
  # File type analysis
  begin
    type1 = `file #{Shellwords.escape(file1)} 2>/dev/null`.strip
    type2 = `file #{Shellwords.escape(file2)} 2>/dev/null`.strip
    
    text << "\n  File types:\n".fg(226)
    text << "    #{File.basename(file1)}: #{type1.split(':')[1]&.strip || 'Unknown'}\n".fg(240)
    text << "    #{File.basename(file2)}: #{type2.split(':')[1]&.strip || 'Unknown'}\n".fg(240)
  rescue
    # Ignore file type detection errors
  end
  
  text
end

def show_text_comparison(file1, file2) # {{{3
  text = "Text File Comparison:\n".fg(226)
  
  begin
    lines1 = File.readlines(file1, chomp: true)
    lines2 = File.readlines(file2, chomp: true)
  rescue StandardError => e
    return "Error reading files: #{e.message}\n".fg(196)
  end
  
  # Line count comparison
  if lines1.length == lines2.length
    text << "  Same line count: #{lines1.length}\n".fg(156)
  else
    diff = lines2.length - lines1.length
    sign = diff > 0 ? "+" : ""
    text << "  Line count: #{lines1.length} vs #{lines2.length} (#{sign}#{diff})\n".fg(diff > 0 ? 196 : 156)
  end
  
  # Generate and show diff
  diff_lines = generate_unified_diff(lines1, lines2, File.basename(file1), File.basename(file2))
  
  if diff_lines.empty?
    text << "  Content: Identical ✓\n".fg(156)
  else
    text << "\n  Differences (unified diff):\n".fg(226)
    
    # Show first 15 lines of diff
    diff_lines.first(15).each do |line|
      color = case line[0]
              when '+' then 156  # Green for additions
              when '-' then 196  # Red for deletions  
              when '@' then 226  # Yellow for headers
              else 240           # Gray for context
              end
      text << "    #{line}\n".fg(color)
    end
    
    if diff_lines.length > 15
      text << "    ... and #{diff_lines.length - 15} more lines\n".fg(240)
    end
  end
  
  text
end

def generate_unified_diff(lines1, lines2, name1, name2) # {{{3
  # Simple unified diff implementation
  diff_lines = []
  
  # Find differences using basic LCS-like approach
  i1 = i2 = 0
  context_size = 3
  
  while i1 < lines1.length || i2 < lines2.length
    if i1 < lines1.length && i2 < lines2.length && lines1[i1] == lines2[i2]
      # Lines match, move forward
      i1 += 1
      i2 += 1
    else
      # Found a difference, create a hunk
      hunk_start1, hunk_start2 = i1, i2
      
      # Find the end of differences
      temp_i1, temp_i2 = i1, i2
      while temp_i1 < lines1.length || temp_i2 < lines2.length
        if temp_i1 < lines1.length && temp_i2 < lines2.length && lines1[temp_i1] == lines2[temp_i2]
          break
        end
        temp_i1 += 1 if temp_i1 < lines1.length
        temp_i2 += 1 if temp_i2 < lines2.length
      end
      
      # Add hunk header
      diff_lines << "@@ -#{hunk_start1 + 1},#{temp_i1 - hunk_start1} +#{hunk_start2 + 1},#{temp_i2 - hunk_start2} @@"
      
      # Add removed lines
      (hunk_start1...temp_i1).each do |idx|
        diff_lines << "-#{lines1[idx]}" if idx < lines1.length
      end
      
      # Add added lines  
      (hunk_start2...temp_i2).each do |idx|
        diff_lines << "+#{lines2[idx]}" if idx < lines2.length
      end
      
      i1, i2 = temp_i1, temp_i2
      
      break if diff_lines.length > 50 # Prevent huge diffs
    end
  end
  
  diff_lines
end

def files_identical?(file1, file2) # {{{3
  # Quick check for identical files
  return false unless File.size(file1) == File.size(file2)
  
  File.open(file1, 'rb') do |f1|
    File.open(file2, 'rb') do |f2|
      while (chunk1 = f1.read(8192))
        chunk2 = f2.read(8192)
        return false if chunk1 != chunk2
      end
    end
  end
  
  true
rescue
  false
end

def binary_file?(file) # {{{3
  # Check if file appears to be binary
  File.open(file, 'rb') do |f|
    chunk = f.read(1024)
    return false if chunk.nil? || chunk.empty?
    
    # Consider file binary if it contains null bytes or too many non-printable chars
    null_count = chunk.count("\x00")
    non_printable = chunk.bytes.count { |b| b < 32 && ![9, 10, 13].include?(b) }
    
    null_count > 0 || (non_printable.to_f / chunk.length) > 0.3
  end
rescue
  true # Assume binary if we can't read it
end

def format_size_simple(bytes) # {{{3
  units = ['B', 'KB', 'MB', 'GB']
  size = bytes.to_f
  unit_index = 0
  
  while size >= 1024 && unit_index < units.length - 1
    size /= 1024.0
    unit_index += 1
  end
  
  if unit_index == 0
    "#{size.to_i} #{units[unit_index]}"
  else
    "#{sprintf('%.1f', size)} #{units[unit_index]}"
  end
end

# REMOTE BROWSING {{{2
def browse_remote # {{{3
  if @remote_mode
    # Exit remote mode
    exit_remote_mode
  else
    # Enter remote mode
    @pB.say("Remote connection: ".fg(156))
    @pR.say(build_remote_help)
    @pR.update = false
    
    connection_string = @pSsh.ask('SSH connect to: ', '')
    
    # Check if user cancelled (ESC or empty input)
    if connection_string.nil? || connection_string.strip.empty?
      @pB.clear; @pB.update = true
      @pR.update = true
      return
    end
    
    # Parse connection string
    remote_info = parse_remote_connection(connection_string)
    unless remote_info
      @pB.clear; @pB.update = true
      @pR.update = true
      return
    end
    
    # Test connection and enter remote mode
    begin
      connect_remote(remote_info)
      enter_remote_mode(remote_info[:path])
    rescue StandardError => e
      @pB.say("Connection failed: #{e.message}".fg(196))
      @pR.update = true
    end
  end
end

def enter_remote_mode(path = '~') # {{{3
  @remote_mode = true
  @remote_path = path
  @index = 0  # Reset selection
  @remote_files_cache = []  # Clear cache when entering remote mode
  @pB.say("Remote mode: #{@current_remote[:user]}@#{@current_remote[:host]}:#{@remote_path} (Ctrl+E to exit)".fg(156))
  @pR.update = true
  dirlist
  render
end

def exit_remote_mode # {{{3
  @remote_mode = false
  @current_remote = nil
  @remote_path = '~'
  @remote_files_cache = []  # Clear cache when exiting remote mode
  @index = 0  # Reset selection
  @pB.say("Returned to local browsing".fg(156))
  dirlist
  render
end

def show_remote_file_info(file) # {{{3
  info_text = "Remote File Information\n".b.fg(156)
  info_text << "=" * 40 + "\n\n"
  
  info_text << "Name:          #{file[:name]}\n".fg(255)
  info_text << "Type:          #{file[:type].capitalize}\n".fg(255)
  info_text << "Size:          #{format_size_simple(file[:size])}\n".fg(255)
  info_text << "Permissions:   #{file[:permissions]}\n".fg(255)
  info_text << "Owner:         #{file[:owner]}\n".fg(255)
  info_text << "Group:         #{file[:group]}\n".fg(255)
  info_text << "Modified:      #{file[:modified]}\n".fg(255)
  info_text << "Remote Path:   #{@remote_path}/#{file[:name]}\n".fg(240)
  
  info_text << "\n"
  info_text << "Actions:\n".fg(226)
  if file[:type] == 'directory'
    info_text << "  Enter  = Navigate into directory\n".fg(240)
    info_text << "  ←      = Go to parent directory\n".fg(240)
  else
    info_text << "  d      = Download file\n".fg(240)
    info_text << "  Enter  = Show this information\n".fg(240)
  end
  info_text << "  s      = Open SSH shell in current directory\n".fg(240)
  info_text << "  u      = Upload file to current directory\n".fg(240)
  info_text << "  Ctrl+E = Exit remote mode\n".fg(240)
  
  @pR.say(info_text)
  @pR.update = true
end

def remote_download_selected # {{{3
  return unless @remote_mode && @files && @files[@index] && @remote_files_cache[@index]
  
  selected_file = @remote_files_cache[@index]
  
  if selected_file[:type] == 'directory'
    @pB.say("Cannot download directories directly".fg(196))
    return
  end
  
  # Show download prompt in right pane to avoid interfering with top pane
  default_dest = File.join(Dir.pwd, selected_file[:name])
  prompt_text = "Download: #{selected_file[:name]}\n\n"
  prompt_text << "Default destination:\n#{default_dest}\n\n"
  prompt_text << "Press Enter to use default,\ntype new path, or clear field to cancel:"
  
  @pR.say(prompt_text.fg(156))
  @pR.update = true
  
  # Ask for local destination with clearer instructions
  destination = @pSsh.ask('Download to (Enter=default, clear field to cancel): ', default_dest)
  
  # Check if user cancelled by clearing the field
  if destination.nil? || destination.strip.empty?
    @pB.say("Download cancelled".fg(240))
    # Restore file info display
    show_remote_file_info(selected_file)
    return
  end
  
  # Download the file
  remote_file = File.join(@remote_path, selected_file[:name])
  scp_cmd = build_scp_command(@current_remote, destination, remote_file, :download)
  
  @pB.say("Downloading #{selected_file[:name]}...".fg(156))
  
  begin
    result = system(scp_cmd)
    if result
      @pB.say("Downloaded: #{selected_file[:name]} -> #{destination}".fg(156))
      # Refresh local directory if we downloaded to current directory
      if File.dirname(destination) == Dir.pwd
        dirlist(left: false)  # Refresh right pane if needed
      end
      # Show success and restore file info
      show_remote_file_info(selected_file)
    else
      @pB.say("Download failed".fg(196))
      show_remote_file_info(selected_file)
    end
  rescue StandardError => e
    @pB.say("Download error: #{e.message}".fg(196))
    show_remote_file_info(selected_file)
  end
end

def open_remote_shell # {{{3
  return unless @remote_mode && @current_remote
  
  @pB.say("Launching SSH shell...".fg(156))
  
  begin
    # Build SSH command with proper directory navigation
    ssh_opts = "-t"
    ssh_opts += " -i #{Shellwords.escape(@current_remote[:ssh_key])}" if @current_remote[:ssh_key]
    
    ssh_target = "#{@current_remote[:user]}@#{@current_remote[:host]}"
    
    # Create SSH command that changes to the remote directory
    ssh_cmd = "ssh #{ssh_opts} #{ssh_target} -t 'cd #{Shellwords.escape(@remote_path)} 2>/dev/null || cd ~; exec bash -l'"
    
    # Use RTFM's interactive program pattern
    system("stty #{ORIG_STTY} < /dev/tty")
    system('clear < /dev/tty > /dev/tty')
    Cursor.show
    
    # Show connection info
    puts "Connecting to #{ssh_target}..."
    puts "Starting in directory: #{@remote_path}"
    puts "Type 'exit' to return to RTFM"
    puts "=" * 50
    puts
    
    # Launch SSH on real TTY using Process.spawn like RTFM does
    pid = Process.spawn(ssh_cmd,
                       in:  '/dev/tty',
                       out: '/dev/tty',
                       err: '/dev/tty')
    begin
      Process.wait(pid)
    rescue Interrupt
      Process.kill('TERM', pid) rescue nil
      retry
    end
    
    # Restore RTFM's terminal state
    system('stty raw -echo isig < /dev/tty')
    $stdin.raw!
    $stdin.echo = false
    Rcurses.init! # Reinitialize rcurses to fix input handling
    Cursor.hide
    Rcurses.clear_screen
    
    # Refresh RTFM interface
    @pL.update = true
    @pR.update = true
    @pB.say("Returned from SSH shell session".fg(118))
    dirlist
    refresh
    render
    
  rescue StandardError => e
    # Error handling with proper terminal restoration
    system('stty raw -echo isig < /dev/tty') rescue nil
    $stdin.raw! rescue nil
    $stdin.echo = false rescue nil
    Cursor.hide rescue nil
    Rcurses.clear_screen rescue nil
    
    @pL.update = true
    @pR.update = true
    @pB.say("SSH shell failed: #{e.message}".fg(196))
    dirlist
    refresh
    render
  end
end

def remote_upload_file # {{{3
  return unless @remote_mode
  
  # Check if there are tagged files first
  unless @tagged.empty?
    # Show tagged files for upload confirmation
    upload_text = "Upload Tagged Files to Remote Directory\n\n".fg(156)
    upload_text << "Current remote path: #{@remote_path}\n\n".fg(240)
    upload_text << "Tagged files to upload:\n".fg(226)
    
    @tagged.each_with_index do |file, i|
      if File.exist?(file)
        upload_text << sprintf("  %d. %s\n", i + 1, file.fg(255))
      else
        upload_text << sprintf("  %d. %s [MISSING]\n", i + 1, file.fg(196))
      end
    end
    
    total_size = @tagged.sum { |f| File.exist?(f) ? File.size(f) : 0 }
    upload_text << "\nTotal size: #{(total_size.to_f / 1_000_000).round(2)}MB\n".fg(240)
    upload_text << "\nPress 'y' to upload all tagged files\n".fg(226)
    upload_text << "Press 'i' to upload individual files\n".fg(226)
    upload_text << "Press any other key to cancel\n".fg(240)
    
    @pR.say(upload_text)
    @pR.update = true
    
    choice = getchr
    case choice
    when 'y', 'Y'
      upload_tagged_files
    when 'i', 'I'
      upload_individual_file
    else
      @pB.say("Upload cancelled".fg(240))
      @pR.update = true
    end
  else
    # No tagged files, show instructions
    upload_text = "Upload Files to Remote Directory\n\n".fg(156)
    upload_text << "Current remote path: #{@remote_path}\n\n".fg(240)
    upload_text << "No files are currently tagged.\n\n".fg(226)
    upload_text << "Workflow:\n".fg(226)
    upload_text << "  1. Press Ctrl+E to exit remote mode\n".fg(240)
    upload_text << "  2. Navigate and tag files with 't'\n".fg(240)
    upload_text << "  3. Press Ctrl+E to return to remote mode\n".fg(240)
    upload_text << "  4. Press 'u' to upload tagged files\n\n".fg(240)
    upload_text << "Options:\n".fg(226)
    upload_text << "  'i' = Upload individual file\n".fg(240)
    upload_text << "  'l' = Show local directory for tagging\n".fg(240)
    upload_text << "  Any other key = Cancel\n".fg(240)
    
    @pR.say(upload_text)
    @pR.update = true
    
    choice = getchr
    case choice
    when 'i', 'I'
      upload_individual_file
    when 'l', 'L'
      show_local_for_tagging
    else
      @pB.say("Upload cancelled".fg(240))
      @pR.update = true
    end
  end
end

def upload_tagged_files # {{{3
  return unless @remote_mode
  
  existing_files = @tagged.select { |f| File.exist?(f) }
  
  if existing_files.empty?
    @pB.say("No valid tagged files to upload".fg(196))
    @pR.update = true
    return
  end
  
  uploaded_count = 0
  failed_count = 0
  
  existing_files.each do |local_file|
    begin
      remote_name = File.basename(local_file)
      remote_destination = File.join(@remote_path, remote_name)
      scp_cmd = build_scp_command(@current_remote, local_file, remote_destination, :upload)
      
      @pB.say("Uploading #{File.basename(local_file)}...".fg(156))
      
      if system(scp_cmd)
        uploaded_count += 1
        @pB.say("✓ #{File.basename(local_file)} uploaded".fg(156))
      else
        failed_count += 1
        @pB.say("✗ Failed to upload #{File.basename(local_file)}".fg(196))
      end
    rescue => e
      failed_count += 1
      @pB.say("✗ Error uploading #{File.basename(local_file)}: #{e.message}".fg(196))
    end
  end
  
  @pB.say("Upload complete: #{uploaded_count} successful, #{failed_count} failed. Press 'r' to refresh if files don't appear.".fg(226))
  
  # Refresh remote directory listing
  @remote_files_cache = []
  @pL.update = true
  @pR.update = true
end

def upload_individual_file # {{{3
  return unless @remote_mode
  
  # Show upload prompt in right pane
  upload_text = "Upload Individual File to Remote Directory\n\n".fg(156)
  upload_text << "Current remote path: #{@remote_path}\n\n".fg(240)
  upload_text << "Enter local file path to upload\n(or ESC to cancel):"
  
  @pR.say(upload_text)
  @pR.update = true
  
  # Ask for local file to upload
  local_file = @pSsh.ask('Local file (clear to cancel): ', '')
  
  # Check if user cancelled
  if local_file.nil? || local_file.strip.empty?
    @pB.say("Upload cancelled".fg(240))
    @pR.update = true  # Restore normal right pane
    return
  end
  
  unless File.exist?(local_file)
    @pB.say("File not found: #{local_file}".fg(196))
    @pR.update = true
    return
  end
  
  if File.directory?(local_file)
    @pB.say("Cannot upload directories directly".fg(196))
    @pR.update = true
    return
  end
  
  # Ask for remote destination name (default to same name)
  default_name = File.basename(local_file)
  remote_name = @pSsh.ask('Remote filename (Enter=default, clear to cancel): ', default_name)
  
  # Check if user cancelled
  if remote_name.nil? || remote_name.strip.empty?
    @pB.say("Upload cancelled".fg(240))
    @pR.update = true
    return
  end
  
  # Upload the file
  remote_destination = File.join(@remote_path, remote_name)
  scp_cmd = build_scp_command(@current_remote, local_file, remote_destination, :upload)
  
  @pB.say("Uploading #{File.basename(local_file)}...".fg(156))
  
  begin
    result = system(scp_cmd)
    if result
      @pB.say("Uploaded: #{local_file} -> #{remote_name}. Press 'r' to refresh if not visible.".fg(156))
      # Clear cache and refresh remote directory
      connection_id = "#{@current_remote[:user]}@#{@current_remote[:host]}"
      cache_key = "#{connection_id}:#{@remote_path}"
      @remote_cache.delete(cache_key)
      @remote_files_cache = []  # Clear file cache too
      @pL.update = true  # Force refresh of left pane
    else
      @pB.say("Upload failed".fg(196))
    end
  rescue StandardError => e
    @pB.say("Upload error: #{e.message}".fg(196))
  end
  
  @pR.update = true  # Restore normal right pane
end

def show_local_for_tagging # {{{3
  return unless @remote_mode
  
  # Temporarily exit remote mode for tagging
  @pB.say("Temporarily exiting remote mode for file tagging...".fg(156))
  
  # Store current remote state
  saved_remote_mode = @remote_mode
  saved_remote_path = @remote_path
  saved_current_remote = @current_remote
  saved_remote_files_cache = @remote_files_cache
  
  # Exit remote mode
  @remote_mode = false
  @current_remote = nil
  @remote_path = '~'
  @remote_files_cache = []
  
  # Refresh local directory
  dirlist
  render
  
  # Show tagging instructions
  tag_text = "Local File Tagging Mode\n\n".fg(156)
  tag_text << "You are now in local mode for tagging files.\n\n".fg(240)
  tag_text << "Instructions:\n".fg(226)
  tag_text << "  't' = Tag/untag files\n".fg(240)
  tag_text << "  'T' = Show tagged files\n".fg(240)
  tag_text << "  'u' = Clear all tags\n".fg(240)
  tag_text << "  Navigate with arrow keys\n".fg(240)
  tag_text << "  Tab/Shift+Tab = Switch tabs\n\n".fg(240)
  tag_text << "Press 'r' when done tagging to return to remote mode\n".fg(226)
  tag_text << "Press any other key to continue in local mode\n".fg(240)
  
  @pR.say(tag_text)
  @pR.update = true
  
  choice = getchr
  if choice == 'r' || choice == 'R'
    # Restore remote mode
    @remote_mode = saved_remote_mode
    @remote_path = saved_remote_path
    @current_remote = saved_current_remote
    @remote_files_cache = saved_remote_files_cache
    
    # Refresh remote directory
    @pL.update = true
    @pR.update = true
    render
    
    @pB.say("Returned to remote mode. Press 'u' to upload tagged files.".fg(156))
  else
    @pB.say("Staying in local mode. Press Ctrl+E to return to remote mode when ready.".fg(240))
  end
end

def parse_remote_connection(connection_string) # {{{3
  # Support various formats:
  # ssh://user@host/path
  # user@host:/path  
  # host:/path
  # user@host (defaults to home directory)
  # -i ~/.ssh/keyfile user@host:/path
  # user@host:/path -i ~/.ssh/keyfile
  # Comments are supported: user@host:/path # My server
  
  # Strip comments (everything after #)
  connection_string = connection_string.split('#').first.strip
  
  parts = connection_string.split(/\s+/)
  ssh_key = nil
  main_connection = nil
  
  # Look for -i flag and extract key file
  if parts.include?('-i')
    key_index = parts.index('-i')
    if key_index && key_index + 1 < parts.length
      ssh_key = parts[key_index + 1]
      # Remove -i and keyfile from parts
      parts.delete_at(key_index + 1)  # Remove keyfile
      parts.delete_at(key_index)      # Remove -i flag
    end
  end
  
  # Join remaining parts back (in case there were spaces in paths)
  main_connection = parts.join(' ')
  
  # Parse the main connection string
  result = case main_connection
  when %r{^ssh://([^@]+@)?([^/]+)(/.*)?$}
    user_part, host, path = $1, $2, $3
    user = user_part ? user_part.chomp('@') : ENV['USER']
    path ||= '~'
    { protocol: 'ssh', user: user, host: host, path: path }
    
  when %r{^([^@]+@)?([^:]+):(.*)$}
    user_part, host, path = $1, $2, $3
    user = user_part ? user_part.chomp('@') : ENV['USER']
    { protocol: 'ssh', user: user, host: host, path: path }
    
  when %r{^([^@]+@)?([^:]+)$}
    user_part, host = $1, $2
    user = user_part ? user_part.chomp('@') : ENV['USER']
    { protocol: 'ssh', user: user, host: host, path: '~' }
    
  else
    @pB.say("Invalid connection format. Use: user@host:/path or -i ~/.ssh/key user@host:/path".fg(196))
    nil
  end
  
  # Add SSH key to result if specified
  result[:ssh_key] = ssh_key if result && ssh_key
  result
end

def build_ssh_command(remote_info, command = nil) # {{{3
  # Build SSH command with optional key file
  ssh_opts = "-o ConnectTimeout=10 -o BatchMode=yes"
  ssh_opts += " -i #{Shellwords.escape(remote_info[:ssh_key])}" if remote_info[:ssh_key]
  
  ssh_cmd = "ssh #{ssh_opts} #{remote_info[:user]}@#{remote_info[:host]}"
  ssh_cmd += " '#{command}'" if command
  ssh_cmd
end

def build_scp_command(remote_info, local_path, remote_path, direction = :download) # {{{3
  # Build SCP command with optional key file
  scp_opts = ""
  scp_opts += " -i #{Shellwords.escape(remote_info[:ssh_key])}" if remote_info[:ssh_key]
  
  ssh_target = "#{remote_info[:user]}@#{remote_info[:host]}"
  
  # Handle tilde expansion for SCP
  scp_remote_path = if remote_path.start_with?('~')
                      "#{ssh_target}:#{remote_path}"
                    else
                      "#{ssh_target}:#{Shellwords.escape(remote_path)}"
                    end
  
  if direction == :download
    "scp#{scp_opts} #{scp_remote_path} #{Shellwords.escape(local_path)}"
  else  # upload
    "scp#{scp_opts} #{Shellwords.escape(local_path)} #{scp_remote_path}"
  end
end

def connect_remote(remote_info) # {{{3
  @pB.say("Connecting to #{remote_info[:user]}@#{remote_info[:host]}...".fg(156))
  
  # Test SSH connection
  ssh_cmd = build_ssh_command(remote_info, 'echo connected')
  result = `#{ssh_cmd} 2>&1`
  
  unless $?.success?
    key_hint = remote_info[:ssh_key] ? " with key #{remote_info[:ssh_key]}" : ""
    raise "SSH connection failed#{key_hint}. Check your SSH keys or try: ssh #{remote_info[:user]}@#{remote_info[:host]}"
  end
  
  # Store connection info
  @current_remote = remote_info
  connection_id = "#{remote_info[:user]}@#{remote_info[:host]}"
  @remote_connections[connection_id] = remote_info
  
  @pB.say("Connected successfully!".fg(156))
end


def list_remote_directory(remote_path) # {{{3
  # Handle tilde expansion - don't escape ~ as it needs shell expansion
  escaped_path = if remote_path.start_with?('~')
                   remote_path  # Don't escape paths starting with ~
                 else
                   Shellwords.escape(remote_path)
                 end
  
  # Use ls -la to get detailed file listing
  ls_cmd = build_ssh_command(@current_remote, "cd #{escaped_path} && ls -la 2>/dev/null")
  output = `#{ls_cmd} 2>&1`
  
  unless $?.success?
    raise "Failed to list remote directory: #{remote_path}"
  end
  
  # Parse ls output
  files = []
  output.lines.each do |line|
    next if line.match?(/^total \d+/) # Skip total line
    
    parts = line.strip.split(/\s+/, 9)
    next if parts.length < 9
    
    permissions, links, owner, group, size, month, day, time_or_year, name = parts
    next if name == '.' || name == '..'
    
    # Determine file type
    type = case permissions[0]
           when 'd' then 'directory'
           when 'l' then 'symlink'
           when '-' then 'file'
           else 'other'
           end
    
    files << {
      name: name,
      type: type,
      size: size.to_i,
      permissions: permissions,
      owner: owner,
      group: group,
      modified: "#{month} #{day} #{time_or_year}"
    }
  end
  
  # Sort: directories first, then by name
  files.sort_by { |f| [f[:type] == 'directory' ? 0 : 1, f[:name].downcase] }
end


def build_remote_help # {{{3
  help_text = "Remote Connection Setup\n".b.fg(156)
  help_text << "=" * 50 + "\n\n"
  
  # Show recent SSH connections if any exist
  unless @pSsh.history.empty?
    help_text << "Recent Connections:\n".fg(226)
    @pSsh.history.reverse.first(5).each_with_index do |connection, i|
      if connection =~ /([^@]+@[^:\s]+)/
        host_part = $1
        help_text << sprintf("  %d. %s\n", i + 1, host_part.fg(156))
      else
        help_text << sprintf("  %d. %s\n", i + 1, connection.fg(156))
      end
    end
    help_text << "\nUse " + "↑/↓".fg(156) + " in prompt to recall connections\n\n".fg(249)
  end
  
  help_text << "Connection Examples:\n".fg(226)
  help_text << "  Basic SSH/SFTP connections:\n".fg(249)
  help_text << "    user@hostname:/path      - Connect to specific path\n"
  help_text << "    user@hostname            - Connect to home directory\n"
  help_text << "    ssh://user@host/path     - Full SSH URI format\n"
  help_text << "    myserver.com:/var/www    - Connect to web directory\n\n"
  
  help_text << "  With SSH keys:\n".fg(249)
  help_text << "    -i ~/.ssh/keyfile user@host:/path    - Use specific SSH key\n"
  help_text << "    user@host:/path -i ~/.ssh/keyfile    - SSH key at end\n"
  help_text << "    -i ~/.ssh/pf-do pfadmin@server.com   - Custom key example\n\n"
  
  help_text << "  Example connections:\n".fg(249)
  help_text << "    john@server.com:/home/john/documents\n".fg(240)
  help_text << "    admin@192.168.1.100:/var/log\n".fg(240)
  help_text << "    ssh://deploy@prod.server.com/app\n".fg(240)
  help_text << "    -i ~/.ssh/aws-key ec2-user@1.2.3.4\n".fg(240)
  help_text << "\n"
  
  help_text << "  Comments for organization:\n".fg(249)
  help_text << "    user@server.com:/path # Production server\n".fg(240)
  help_text << "    admin@192.168.1.10 # Local development\n".fg(240)
  help_text << "    root@backup.server.com # Backup storage\n".fg(240)
  help_text << "    (Comments after # are ignored)\n".fg(240)
  help_text << "\n"
  
  help_text << "Requirements:\n".fg(226)
  help_text << "  • SSH access to remote host\n"
  help_text << "  • SSH key authentication (recommended)\n"
  help_text << "  • Or password authentication enabled\n\n"
  
  help_text << "Remote Navigation:\n".fg(226)
  help_text << "  • Use arrow keys to navigate directories\n"
  help_text << "  • Press " + "d".fg(156) + " to download files\n"
  help_text << "  • Press " + "u".fg(156) + " to upload files\n"
  help_text << "  • Press " + "s".fg(156) + " to open SSH shell\n"
  help_text << "  • Press " + "→".fg(156) + " to view file info\n"
  help_text << "  • Press " + "Ctrl+E".fg(156) + " to return to local browsing\n"
  
  help_text
end

# MANIPULATE ITEMS {{{2
def copy_items # {{{3
  copy_move_link('copy')
  # Dual-pane refresh is handled in copy_move_link function
  @pR.update = true
end

def move_items # {{{3
  copy_move_link('move')
  # Dual-pane refresh is handled in copy_move_link function
  @pR.update = true
end

def rename_item # {{{3
  basename = File.basename(@selected)
  dir      = File.dirname(@selected)
  display  = basename.length > 12 ? basename[0, 12] + '…' : basename
  tpl      = "mv \"#{display}\" \"#{basename}\""
  cmd      = @pCmd.ask(': ', tpl).pure
  match        = cmd.match(/mv\s+"[^"]+"\s+"([^"]+)"/)
  new_basename = match ? match[1] : basename
  
  # Only proceed if name actually changed
  if new_basename != basename
    old_path = @selected
    old_esc  = Shellwords.escape(old_path)
    new_path = File.join(dir, new_basename)
    new_esc  = Shellwords.escape(new_path)
    
    # Record undo information before rename
    undo_info = {
      type: 'rename',
      old_path: old_path,
      new_path: new_path,
      timestamp: Time.now
    }
    
    shellexec("mv #{old_esc} #{new_esc}")
    
    # Only add to undo history if rename was successful
    if File.exist?(new_path)
      add_undo_operation(undo_info)
    end
    
    dirlist
    # point @selected and @index at the new name
    @selected = new_path
    new_idx   = @files.index(new_basename)
    @index    = new_idx if new_idx
    render
  end
end

def link_items # {{{3
  if @remote_mode
    # In remote mode, 's' key opens SSH shell
    open_remote_shell
    return
  end
  
  copy_move_link('link')
  # Dual-pane refresh is handled in copy_move_link function
  @pR.update = true
end

def delete_items # {{{3
  if @remote_mode
    # In remote mode, 'd' key downloads the selected file
    remote_download_selected
    return
  end
  
  tagged_info
  
  # Add deletion warning to the right pane
  warning_text = "\n" + "=" * 50 + "\n"
  action = @trash ? 'Move to Trash' : 'PERMANENT DELETE'
  action_color = @trash ? 220 : 196
  warning_text << action.fg(action_color).b + "\n\n"
  
  if @trash
    warning_text << "Items will be moved to:\n".fg(249)
    warning_text << "  ~/.rtfm/trash/\n".fg(240)
    warning_text << "\nYou can restore them with " + "C-z".fg(156) + "\n".fg(249)
  else
    warning_text << "⚠️  WARNING: PERMANENT DELETION!\n".fg(196).b
    warning_text << "Files will be permanently removed\n".fg(196)
    warning_text << "This action CANNOT be undone!\n".fg(196).b
  end
  
  warning_text << "\n" + "Press " + "y".fg(156).b + " to confirm, any other key to cancel".fg(249)
  
  @pR.text << warning_text
  @pR.refresh
  
  # Bottom pane prompt
  prompt_text = @trash ? "Move to trash? (y/n)" : "⚠️  PERMANENTLY DELETE? (y/n)"
  @pB.say(" #{prompt_text}".fg(action_color))
  if getchr == 'y'
    # collect & escape every path with existence verification
    paths = (@tagged + [@selected]).uniq.select { |p| File.exist?(p) }
    if paths.empty?
      @pB.say("No valid items to #{action.downcase}".fg(196))
    else
      # Record undo information before deletion
      if @trash
        undo_info = {
          type: 'delete',
          trash: true,
          paths: paths.map { |p| { path: p } },
          timestamp: Time.now
        }
      end
      
      esc = paths.map { |p| Shellwords.escape(p) }.join(' ')
      if @trash
        esc_trash = Shellwords.escape(TRASH_DIR)
        command("mv -f #{esc} #{esc_trash}")
        # Only add to undo history if operation succeeded and we can undo it
        add_undo_operation(undo_info)
      else
        command("rm -rf #{esc}")
        # Cannot undo permanent deletion, so don't add to undo history
      end
      @tagged.clear
      
      # Store the old preview text to detect if it needs updating
      old_preview_text = @pR.text
      
      # Refresh the file list to reflect deletions
      @pL.update = true
      render  # This will update @files and @selected
      
      # After render, check if we still have files
      if @files.empty?
        # No files left in directory - clear the preview
        @pR.text = ""
        @pR.refresh
      else
        # Files exist - force update preview for new selection
        # Clear the preview text to ensure it gets regenerated
        @pR.text = ""
        @pR.update = true
        render  # Update the preview pane with the new selection
      end
      
      @pB.say("#{action} #{paths.size} items#{@trash ? " to #{TRASH_DIR}" : ''}".fg(204))
    end
  else
    @pB.update = true
  end
end

def empty_trash # {{{3
  @pB.say(" Really empty Trash (~/.rtfm/trash)? (press 'y')")
  return unless  getchr == 'y'

  command("rm -rf #{TRASH_DIR}/*")
  @pB.say('Trash is now empty')
  render
end

def toggle_trash # {{{3
  @trash = !@trash
  @pB.say("Trash (~/.rtfm/trash) = #{@trash ? 'On' : 'Off'}")
end

def change_ownership # {{{3
  require 'etc'
  gnm = Etc.getgrgid(File.stat(@selected).gid).name
  unm = Etc.getpwuid(File.stat(@selected).uid).name
  ans = @pB.ask('Change ownership (user:group): ', "#{unm}:#{gnm}")
  user, group = ans.split(':')
  uid = Etc.getpwnam(user).uid
  gid = Etc.getgrnam(group).gid
  File.chown(uid, gid, @selected)
  @tagged.each { |t| File.chown(uid, gid, t) rescue nil }
  if user == unm && group == gnm
    @pB.say('No change in ownership')
  else
    @pB.say("Ownership changed to #{user}:#{group}")
  end
end

def change_permissions # {{{3
  # strip leading “-” off e.g. "-rwxr-xr-x" → "rwxr-xr-x"
  default = @fileattr.split[1][1..]
  ans = @pB.ask('Permissions: ', default)
  mode = if ans =~ /^\d{3}$/          # "755"
           ans.to_i(8)
         elsif ans.length == 3           # "rwx" → "rwxrwxrwx"
           # compute the single octal digit
           digit = ans.chars.map do |c|
             (c == 'r' ? 4 : 0) +
             (c == 'w' ? 2 : 0) +
             (c == 'x' ? 1 : 0)
           end.sum
           # replicate for user, group, other
           (digit * 64) + (digit * 8) + digit
         elsif ans.length == 9           # "rwxr-xr-x"
           # split into three triads and compute each
           triads = ans.scan(/.{3}/)
           perms  = triads.map do |tri|
             (tri[0] == 'r' ? 4 : 0) +
             (tri[1] == 'w' ? 2 : 0) +
             (tri[2] == 'x' ? 1 : 0)
           end
           (perms[0] * 64) + (perms[1] * 8) + perms[2]
         end
  if mode.nil?
    @pB.say('Invalid mode')
  else
    current = File.stat(@selected).mode & 0o777
    if mode == current
      @pB.say("No change needed (already #{mode.to_s(8)})")
    else
      File.chmod(mode, @selected)
      @tagged.each { |t| File.chmod(mode, t) rescue nil }
      @pB.say("Permissions changed to: #{mode.to_s(8)}")
    end
  end
end

# FILTER AND SEARCH {{{2
def filter_types # {{{3
  @lsfiles = @pB.ask('Filetype(s) to show: ', @lsfiles)
  @pR.update = @pB.update = true
end

def filter_regex # {{{3
  @lsmatch = @pB.ask('Files match RegEx: ', @lsmatch)
  @pB.say(nil)
  @pR.update = @pB.update = true
end

def filter_clear # {{{3
  @lsfiles = @lsmatch = ''
  @pB.say('All filtering cleared.')
end

def search_text # {{{3
  @searched = @pSearch.ask('/ ', '')
  l = command("ls #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").split
  m = l.each_index.select { |n| l[n] =~ /#{@searched}/ }
  @index = m.first unless m.empty?
  @index = 0 if @searched.empty?
  @pR.update = true; @pB.full_refresh
end

def clear_search # {{{3
  @searched = ''
end

def search_next # {{{3
  l = command("ls #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").split
  m = l.each_index.select { |n| l[n] =~ /#{@searched}/ }
  i = m.find { |n| n > @index }
  @index = i || m.first
  @pR.update = true
end

def search_prev # {{{3
  l = command("ls #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").split
  m = l.each_index.select { |n| l[n] =~ /#{@searched}/ }.reverse
  i = m.find { |n| n < @index }
  @index = i || m.first
  @pR.update = true
end

def grep_current # {{{3
  cmd = @pCmd.ask(': ', 'grep -s MATCH *')
  shellexec(cmd)
end

def locate # {{{3
  cmd = @pCmd.ask(': ', 'locate ')
  cmd += " | #{@bat} -O ansi --force --line-numbers"
  shellexec(cmd)
  @locate = true
end

def jump_locate # {{{3
  @pB.update = true; return unless @locate

  nr = @pB.ask('# ', '').to_i
  line = @pR.text.lines[nr - 1]
  unless line
    @pB.say('Error: No such file or directory.'); @pB.update = false; return
  end
  jump = line.pure[%r{/\S+}]
  dir = File.dirname(jump); tgt = File.basename(jump)
  @directory[Dir.pwd] = @index; mark_latest; Dir.chdir(dir); @dir_old = Dir.pwd
  l = command("ls #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").split("\n")
  @index = l.index { |t| t == tgt }
end

def fzf_jump # {{{ 3
  unless system('which fzf > /dev/null 2>&1')
    @pB.say(' fzf not installed – see https://github.com/junegunn/fzf')
    return
  end
  # 2) Prepare a temp file for the single-line selection
  tmp = File.join(Dir.tmpdir, 'rtfm_fzf_selection')
  # Launch fzf:
  #  - stdin  <- /dev/tty so fzf reads from terminal
  #  - stdout -> tmp to capture only the chosen entry
  #  - stderr -> /dev/tty so fzf can draw its full-screen UI
  system("fzf < /dev/tty > #{Shellwords.escape(tmp)} 2> /dev/tty")
  jump = File.exist?(tmp) ? File.read(tmp).chomp : ''
  File.delete(tmp)
  return if jump.empty?

  dir = File.dirname(jump)
  tgt = File.basename(jump)
  @directory[Dir.pwd] = @index
  mark_latest
  Dir.chdir(dir)
  @dir_old = Dir.pwd
  l = command("ls #{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}").split("\n")
  @index = l.index { |t| t == tgt } || 0
  @pL.update = @pR.update = @pB.update = true
end

# ARCHIVES {{{2
def unzip_items # {{{3
  first = @tagged.first
  tar   = Shellwords.escape(first)
  cmd   = @pCmd.ask('Command: ', "tar xfz #{tar}")
  shellexec(cmd)
  render
end

def zip_items # {{{3
  arc = @pCmd.ask('Archive name: ', '')
  # escape archive base and all paths
  arc_esc    = Shellwords.escape(arc)
  tagged_esc = @tagged.map { |p| Shellwords.escape(p) }.join(' ')
  cmd        = @pCmd.ask('Command: ', "tar cfz #{arc_esc}.gz #{tagged_esc}")
  shellexec(cmd + ' 2>/dev/null')
  render
end

# GIT/HASH/OPENAI {{{2
def git_status # {{{3
  @pR.clear
  
  # Check if we're in a git repository
  unless Dir.exist?('.git') || system('git rev-parse --git-dir > /dev/null 2>&1')
    @pR.say("Not a Git Repository".b.fg(196) + "\n\n" + "This directory is not under Git version control.".fg(240))
    @pR.update = false
    @pB.update = true
    return
  end
  
  # Get git status with porcelain format for parsing
  status_out, status_err = command('git status --porcelain', return_both: true)
  
  # Get branch info
  branch_out, branch_err = command('git branch --show-current', return_both: true)
  current_branch = branch_out.chomp
  
  # Get commit info
  commit_out, commit_err = command('git log --oneline -n 5 2>/dev/null || echo "No commits"', return_both: true)
  
  # Build formatted output
  info = "Git Status".b.fg(156) + "\n"
  info << "=" * 50 + "\n\n"
  
  # Show errors if any
  unless status_err.empty? && branch_err.empty?
    info << "Errors:".fg(196) + "\n"
    info << status_err.fg(196) unless status_err.empty?
    info << branch_err.fg(196) unless branch_err.empty?
    info << "\n"
  end
  
  # Branch information
  if current_branch.empty?
    info << "Branch: ".fg(226) + "Not on any branch".fg(240) + "\n\n"
  else
    info << "Branch: ".fg(226) + current_branch.fg(156).b + "\n\n"
  end
  
  # Parse and categorize changes
  if status_out.empty?
    info << "Working directory clean".fg(156) + "\n"
  else
    staged = []
    modified = []
    untracked = []
    
    status_out.lines.each do |line|
      status_code = line[0..1]
      filename = line[3..-1].chomp
      
      case status_code
      when /^[MADRC]./
        staged << [status_code[0], filename]
      when /^.[MD]/
        modified << [status_code[1], filename]
      when '??'
        untracked << filename
      end
    end
    
    # Show staged changes
    unless staged.empty?
      info << "Staged for commit:".fg(156) + "\n"
      staged.each do |status, file|
        status_text = case status
                      when 'M' then 'modified'
                      when 'A' then 'new file'
                      when 'D' then 'deleted'
                      when 'R' then 'renamed'
                      when 'C' then 'copied'
                      else 'changed'
                      end
        info << sprintf("  %-10s %s\n", status_text.fg(156), file.fg(249))
      end
      info << "\n"
    end
    
    # Show modified files
    unless modified.empty?
      info << "Changes not staged:".fg(220) + "\n"
      modified.each do |status, file|
        status_text = status == 'M' ? 'modified' : 'deleted'
        info << sprintf("  %-10s %s\n", status_text.fg(220), file.fg(249))
      end
      info << "\n"
    end
    
    # Show untracked files
    unless untracked.empty?
      info << "Untracked files:".fg(240) + "\n"
      untracked.first(10).each do |file|
        info << sprintf("  %-10s %s\n", "new".fg(240), file.fg(249))
      end
      info << "  (showing first 10)\n".fg(240) if untracked.length > 10
      info << "\n"
    end
  end
  
  # Recent commits
  unless commit_out.chomp == "No commits"
    info << "Recent commits:".fg(226) + "\n"
    commit_out.lines.first(5).each_with_index do |commit, i|
      hash, *message = commit.chomp.split(' ', 2)
      info << sprintf("  %s %s\n", hash.fg(214), message.join(' ').fg(249))
    end
  end
  
  @pR.say(info)
  @pR.update = false
  @pB.update = true
end

def hash_directory # {{{3
  dir = Shellwords.escape(Dir.pwd)
  @pB.say("Creating hash for #{dir}. May take a while…")
  hashcmd = <<~CMD.chomp
    (find #{dir} -type f -print0 |
      sort -z |
      xargs -0 sha1sum;
    find #{dir} \\( -type f -o -type d \\) -print0 |
      sort -z |
      xargs -0 stat -c '%n %a') |
    sha1sum |
    cut -c -40
  CMD
  hashdir = command(hashcmd, timeout: 300).chomp
  hashtime = DateTime.now.strftime('%Y-%m-%d %H:%M')
  if @hash.key?(Dir.pwd)
    old_time, old_hash = @hash[Dir.pwd]
    if old_hash == hashdir
      @pB.say(" Hash for #{Dir.pwd} has NOT changed since #{old_time} (#{hashdir})".fg(213))
    else
      @pB.say(" Hash for #{Dir.pwd} has CHANGED: #{old_time}→#{hashtime} (#{old_hash}→#{hashdir})".fg(213))
      @hash[Dir.pwd] = [hashtime, hashdir]
    end
  else
    @hash[Dir.pwd] = [hashtime, hashdir]
    @pB.say(" New hash for #{Dir.pwd}: #{hashtime}: #{hashdir}".fg(213))
  end
  @pR.update = true
end

def openai_description # {{{3
  begin
    require 'ruby/openai'
  rescue LoadError
    @pB.say('To enable AI-descriptions: `gem install ruby-openai` and set @ai in ~/.rtfm/conf')
    return
  end
  unless @ai && !@ai.empty?
    @pB.say("Set your API key in ~/.rtfm/conf: @ai = 'your-secret-openai-key'")
    return
  end
  # Context
  path    = File.join(Dir.pwd, @selected.to_s)
  is_dir  = File.directory?(path)
  preview = @pR.text.pure.strip
  # Enhanced prompt
  parts = []
  parts << 'You are an expert terminal file manager assistant.'
  parts << (is_dir ?  "Summarize the overall purpose and contents of this directory: #{path}." : "Summarize the purpose of this file: #{path}.")
  parts << 'Since this is source code, do a brief code review: highlight bugs, style issues, and improvements.' if !is_dir && path.match?(/\.(sh|rb|py|js|go|java|41)$/i)
  parts << 'Embedded Documentation Lookup: for any libraries, commands, or APIs used, include a one-sentence summary from their official docs.'
  parts << "Git-Aware Diff Explanation: summarize the most recent `git diff` touching #{path}, explaining what changed." if Dir.exist?(File.join(Dir.pwd, '.git'))
  parts << "Existing preview text: #{preview}" unless preview.empty?
  prompt = parts.join(' ')
  # Send to OpenAI
  client = OpenAI::Client.new(access_token: @ai)
  @pR.say('Thinking...'.fg(244))
  response = client.chat(
    parameters: {
      model:      @aimodel,
      messages:   [{ role: 'user', content: prompt }],
      max_tokens: 600
    }
  ) rescue nil
  answer = response&.dig('choices', 0, 'message', 'content') ||
           '⚠️ Error or empty response from OpenAI.'
  @pR.say(answer.fg(230))
end

def chat_mode # {{{3
  unless defined?(OpenAI) && @ai && !@ai.empty?
    @pB.say("To make OpenAI work in RTFM, run `gem install ruby-openai` and add to ~/.rtfm/conf:\n  @ai = 'your-secret-openai-key'")
    return
  end

  @pB.clear; @pB.update = true
  question = @pAI.ask('Chat> ', '').strip
  return if question.empty?

  chat_history << { role: 'user', content: question }
  @pR.say('Thinking...'.fg(230))
  reply = openai_client.chat(
    parameters: {
      model:    @aimodel,
      messages: chat_history,
      max_tokens: 400
    }
  ) rescue nil
  answer = reply&.dig('choices', 0, 'message', 'content') ||
           '⚠️ API error or empty response'
  chat_history << { role: 'assistant', content: answer }
  @pR.say(answer.fg(230))
  @pB.clear; @pB.update = true
end

# RIGHT PANE CONTROLS {{{2
def refresh_right # {{{3
  @pR.full_refresh
  @pR.update = @pB.update = true
end

def line_down_right # {{{3
  @pR.linedown; @pB.update = true
end

def line_up_right # {{{3
  @pR.lineup;   @pB.update = true
end

def page_down_right # {{{3
  @pR.pagedown; @pB.update = true
end

def page_up_right # {{{3
  @pR.pageup; @pB.update = true
end

# CLIPBOARD COPY {{{2
def copy_path_primary # {{{3
  if @selected
    # Get the correct path in dual-pane mode
    path = get_selected_full_path
    if path
      copy_to_clipboard(path, 'primary')
      @pB.say(' Path copied to primary selection (middle-click to paste)')
    else
      @pB.say(' No selected item path to copy')
    end
  else
    @pB.say(' No selected item path to copy')
  end
end

def copy_path_clipboard # {{{3
  if @selected
    # Get the correct path in dual-pane mode
    path = get_selected_full_path
    if path
      copy_to_clipboard(path, 'clipboard')
      @pB.say(' Path copied to clipboard (Ctrl+V to paste)')
    else
      @pB.say(' No selected item path to copy')
    end
  else
    @pB.say(' No selected item path to copy')
  end
end

def copy_to_clipboard(text, selection = 'clipboard') # {{{3
  # Robust clipboard copying with fallback mechanisms
  escaped_text = text.gsub("'", "'\"'\"'")  # Escape single quotes for shell
  
  # Try xsel first (often more reliable), then fall back to xclip
  success = false
  
  if cmd?('xsel')
    cmd = case selection
          when 'primary' then "echo -n '#{escaped_text}' | xsel --primary --input"
          else "echo -n '#{escaped_text}' | xsel --clipboard --input"
          end
    success = system(cmd + ' 2>/dev/null')
  end
  
  unless success
    # Fallback to xclip with timeout to prevent hanging
    cmd = case selection
          when 'primary' then "echo -n '#{escaped_text}' | timeout 2 xclip -selection primary -in"
          else "echo -n '#{escaped_text}' | timeout 2 xclip -selection clipboard -in"
          end
    success = system(cmd + ' 2>/dev/null')
  end
  
  unless success
    @pB.say(' Warning: Clipboard copy may have failed')
  end
end

def get_selected_full_path # {{{3
  # Helper function to get the correct full path in both single and dual-pane modes
  if @dual_pane
    active_dir = @active_pane == :left ? @pwd_left : @pwd_right
    active_selected = @active_pane == :left ? @selected_left : @selected_right
    return active_selected ? File.join(active_dir, active_selected) : nil
  else
    return @selected
  end
end

def copy_right # {{{3
  clip = 'xclip -selection clipboard'
  @pB.say(' Right pane copied to clipboard')
  # We drop the trailing & in the string itself…
  shell("echo -n #{Shellwords.escape(@pR.text.pure)} | #{clip} > /dev/null 2>&1", background: true)
end

# SYSTEM SHORTCUTS {{{2
def system_info # {{{3
  text = "SYSTEM INFORMATION".b.fg(156) + " - " + Time.now.strftime("%Y-%m-%d %H:%M:%S").fg(249) + "\n"
  text << "=" * 50 + "\n\n"
  
  begin
    # System Overview
    text << "System Overview".fg(226).b + "\n"
    text << "─" * 20 + "\n"
    
    # Hostname and uptime
    hostname = `hostname 2>/dev/null`.chomp
    uptime_raw = `uptime -p 2>/dev/null`.chomp.sub('up ', '')
    boot_time = `uptime -s 2>/dev/null`.chomp
    
    text << sprintf("  %-15s %s\n", "Hostname:".fg(249), hostname.fg(156))
    text << sprintf("  %-15s %s\n", "Uptime:".fg(249), uptime_raw.fg(156))
    text << sprintf("  %-15s %s\n", "Boot time:".fg(249), boot_time.fg(156))
    
    # OS Info
    os_name = `awk -F '"' '/PRETTY/ {print $2}' /etc/os-release 2>/dev/null`.chomp
    kernel_version = `uname -r 2>/dev/null`.chomp
    architecture = `uname -m 2>/dev/null`.chomp
    
    text << sprintf("  %-15s %s\n", "OS:".fg(249), os_name.fg(156))
    text << sprintf("  %-15s %s\n", "Kernel:".fg(249), kernel_version.fg(156))
    text << sprintf("  %-15s %s\n", "Architecture:".fg(249), architecture.fg(156))
    text << "\n"
  rescue # rubocop:disable Lint/SuppressedException
  end
  
  begin
    # Hardware Information
    text << "Hardware".fg(226).b + "\n"
    text << "─" * 20 + "\n"
    
    # CPU Info
    cpu_count = `nproc 2>/dev/null`.chomp
    cpuinfo = `lscpu 2>/dev/null`
    cpu_model = cpuinfo[/^.*Model name:\s*(.*)/, 1]&.strip || "Unknown"
    cpu_model = cpu_model[0..45] + "..." if cpu_model.length > 48
    
    # Current CPU frequency
    cpu_freq = `grep "cpu MHz" /proc/cpuinfo 2>/dev/null | head -1 | awk '{print $4}'`.chomp.to_f.round
    
    # Load average
    loadavg = `cat /proc/loadavg 2>/dev/null`.chomp.split[0..2].join(' ')
    
    text << sprintf("  %-15s %s\n", "CPU Model:".fg(249), cpu_model.fg(156))
    text << sprintf("  %-15s %s\n", "CPU Cores:".fg(249), "#{cpu_count} cores".fg(156))
    text << sprintf("  %-15s %s\n", "Current Freq:".fg(249), "#{cpu_freq} MHz".fg(156)) if cpu_freq > 0
    text << sprintf("  %-15s %s\n", "Load Average:".fg(249), loadavg.fg(156))
    
    # Temperature if available
    if File.exist?("/sys/class/thermal/thermal_zone0/temp")
      temp = (File.read("/sys/class/thermal/thermal_zone0/temp").to_i / 1000.0).round(1)
      temp_color = temp > 80 ? 196 : temp > 60 ? 220 : 156
      text << sprintf("  %-15s %s\n", "CPU Temp:".fg(249), "#{temp}°C".fg(temp_color))
    end
    
    text << "\n"
  rescue # rubocop:disable Lint/SuppressedException
  end
  
  begin
    # Memory Information with visual bar
    text << "Memory".fg(226).b + "\n"
    text << "─" * 20 + "\n"
    
    mem_info = `free -b 2>/dev/null | grep Mem:`
    if mem_info && !mem_info.empty?
      parts = mem_info.split
      total = parts[1].to_i
      used = parts[2].to_i
      available = parts[6].to_i
      
      # Convert to human readable
      total_h = (total / 1024.0 / 1024.0 / 1024.0).round(1)
      used_h = (used / 1024.0 / 1024.0 / 1024.0).round(1)
      available_h = (available / 1024.0 / 1024.0 / 1024.0).round(1)
      percent = ((used.to_f / total) * 100).round
      
      # Create visual bar (40 chars wide)
      bar_width = 40
      filled = (percent * bar_width / 100.0).round
      bar = "█" * filled + "░" * (bar_width - filled)
      
      # Color based on usage
      bar_color = percent > 90 ? 196 : percent > 70 ? 220 : 156
      
      # Bar first
      text << "  " + bar.fg(bar_color) + " " + "#{percent}%".fg(bar_color) + "\n"
      text << sprintf("  %-15s %s\n", "Total:".fg(249), "#{total_h} GB".fg(156))
      text << sprintf("  %-15s %s\n", "Used:".fg(249), "#{used_h} GB".fg(bar_color))
      text << sprintf("  %-15s %s\n", "Available:".fg(249), "#{available_h} GB".fg(156))
    end
    
    # Swap info
    swap_info = `free -b 2>/dev/null | grep Swap:`
    if swap_info && !swap_info.empty? && swap_info.split[1].to_i > 0
      parts = swap_info.split
      swap_total = (parts[1].to_i / 1024.0 / 1024.0 / 1024.0).round(1)
      swap_used = (parts[2].to_i / 1024.0 / 1024.0 / 1024.0).round(1)
      text << sprintf("  %-15s %s\n", "Swap:".fg(249), "#{swap_used}/#{swap_total} GB".fg(156))
    end
    
    text << "\n"
  rescue # rubocop:disable Lint/SuppressedException
  end
  
  begin
    # Storage Information with visual bars
    text << "Storage".fg(226).b + "\n"
    text << "─" * 20 + "\n"
    
    # Get disk usage for main filesystems only
    disk_output = `df -BG 2>/dev/null | grep -E '^/dev/' | grep -vE '/snap/|/tmp\\.| /run|/dev/loop'`
    if disk_output && !disk_output.empty?
      disk_output.lines.each do |line|
        parts = line.split
        next if parts.length < 6
        
        filesystem = parts[0].sub('/dev/', '')
        size = parts[1].sub('G', '').to_i
        used = parts[2].sub('G', '').to_i
        avail = parts[3].sub('G', '').to_i
        percent = parts[4].sub('%', '').to_i
        mount = parts[5]
        
        # Skip if too small
        next if size < 1
        
        # Shorten mount point if needed
        mount_display = mount.length > 15 ? "...#{mount[-12..]}" : mount
        
        # Mini bar (10 chars)
        bar_width = 10
        filled = (percent * bar_width / 100.0).round
        bar = "█" * filled + "░" * (bar_width - filled)
        
        # Color based on usage
        color = percent > 90 ? 196 : percent > 80 ? 220 : 156
        
        info = sprintf("  %-15s %3dG/%3dG %s %3d%%", 
                      mount_display, used, size, bar, percent)
        text << info.fg(color) + "\n"
      end
    end
    text << "\n"
  rescue # rubocop:disable Lint/SuppressedException
  end
  
  begin
    # Network Information
    text << "Network".fg(226).b + "\n"
    text << "─" * 20 + "\n"
    
    # Get active network interfaces
    interfaces = `ip -o link show 2>/dev/null | grep -E 'state UP|UNKNOWN' | awk '{print $2}' | sed 's/://'`.chomp.split("\n")
    
    interfaces.each do |iface|
      next if iface.empty? || iface == "lo"
      
      # Get IP address
      ip_addr = `ip -4 addr show #{iface} 2>/dev/null | grep -oP '(?<=inet\\s)\\d+(\\.\\d+){3}'`.chomp
      next if ip_addr.empty?
      
      # Get RX/TX stats
      rx_bytes = File.read("/sys/class/net/#{iface}/statistics/rx_bytes").to_i rescue 0
      tx_bytes = File.read("/sys/class/net/#{iface}/statistics/tx_bytes").to_i rescue 0
      
      rx_mb = (rx_bytes / 1024.0 / 1024.0).round(1)
      tx_mb = (tx_bytes / 1024.0 / 1024.0).round(1)
      
      text << sprintf("  %-15s %s\n", "#{iface}:".fg(249), ip_addr.fg(156))
      text << sprintf("  %-15s %s\n", "    Traffic:".fg(249), "↓#{rx_mb}MB ↑#{tx_mb}MB".fg(156))
    end
    
    # Public IP if available (with timeout)
    begin
      require 'timeout'
      public_ip = Timeout.timeout(2) do
        `curl -s ifconfig.me 2>/dev/null`.chomp
      end
      text << sprintf("  %-15s %s\n", "Public IP:".fg(249), public_ip.fg(156)) unless public_ip.empty?
    rescue
      # Skip public IP if timeout or error
    end
    
    text << "\n"
  rescue # rubocop:disable Lint/SuppressedException
  end
  
  begin
    # Environment Information
    text << "Environment".fg(226).b + "\n"
    text << "─" * 20 + "\n"
    
    # Shell and terminal
    shell = ENV['SHELL']&.sub(%r{.*/}, '') || 'unknown'
    terminal = ENV['TERM'] || 'unknown'
    text << sprintf("  %-15s %s\n", "Shell:".fg(249), shell.fg(156))
    text << sprintf("  %-15s %s\n", "Terminal:".fg(249), terminal.fg(156))
    
    # Package count
    if system("which dpkg >/dev/null 2>&1")
      packages = `dpkg-query -l 2>/dev/null | grep -c '^ii'`.chomp
    elsif system("which rpm >/dev/null 2>&1") 
      packages = `rpm -qa 2>/dev/null | wc -l`.chomp
    elsif system("which pacman >/dev/null 2>&1")
      packages = `pacman -Q 2>/dev/null | wc -l`.chomp
    else
      packages = "unknown"
    end
    text << sprintf("  %-15s %s\n", "Packages:".fg(249), packages.fg(156))
    
    # Desktop environment
    desktop = ENV['XDG_CURRENT_DESKTOP'] || ENV['DESKTOP_SESSION'] || 'none'
    text << sprintf("  %-15s %s\n", "Desktop:".fg(249), desktop.fg(156))
    
    # GTK Theme if available
    if File.exist?("#{ENV['HOME']}/.config/gtk-3.0/settings.ini")
      gtk_theme = `grep gtk-theme-name ~/.config/gtk-3.0/settings.ini 2>/dev/null | cut -d= -f2`.chomp
      text << sprintf("  %-15s %s\n", "GTK Theme:".fg(249), gtk_theme.fg(156)) unless gtk_theme.empty?
    end
    
    text << "\n"
  rescue # rubocop:disable Lint/SuppressedException
  end
  
  begin
    # Services & Processes
    text << "Services & Processes".fg(226).b + "\n"
    text << "─" * 20 + "\n"
    
    # Running services count
    services_count = `systemctl list-units --type=service --state=running 2>/dev/null | grep -c '\.service'`.chomp
    text << sprintf("  %-15s %s\n", "Services:".fg(249), "#{services_count} running".fg(156)) unless services_count == "0"
    
    # Failed services
    failed_count = `systemctl list-units --type=service --state=failed 2>/dev/null | grep -c '\.service'`.chomp
    if failed_count.to_i > 0
      text << sprintf("  %-15s %s\n", "Failed:".fg(249), "#{failed_count} service#{failed_count.to_i > 1 ? 's' : ''}".fg(196))
      # Get the actual failed services (need to parse the full output)
      failed_output = `systemctl list-units --type=service --state=failed --no-legend 2>/dev/null`.chomp
      unless failed_output.empty?
        failed_output.lines.each do |line|
          # Skip the bullet point and extract service name (second field)
          parts = line.strip.split(/\s+/)
          if parts.length >= 2
            service_name = parts[1]
            if service_name && service_name.include?('.service')
              # Shorten long service names if needed
              display_name = service_name.length > 40 ? service_name[0..37] + "..." : service_name
              text << "    → #{display_name}".fg(196) + "\n"
            end
          end
        end
      end
    end
    
    # Process count
    proc_count = `ps aux | wc -l`.chomp.to_i - 1
    text << sprintf("  %-15s %s\n", "Processes:".fg(249), proc_count.to_s.fg(156))
    
    # Users logged in
    users = `who | awk '{print $1}' | sort -u | wc -l`.chomp
    text << sprintf("  %-15s %s\n", "Users:".fg(249), "#{users} logged in".fg(156))
    
    text << "\n"
  rescue # rubocop:disable Lint/SuppressedException
  end
  
  @pR.say(text)
rescue => e
  @pR.say("Unable to show system info\n#{e.message}".fg(196))
end

def make_directory # {{{3
  cmd = @pCmd.ask(': ', 'mkdir ')
  shellexec(cmd + ' -p')
end

def navi_invoke # {{{3
  @navi = `navi`
rescue
  @pB.say(' navi not installed - see https://github.com/junegunn/fzf')
end

# COMMAND MODE {{{2
def command_mode # {{{3
  raw = @pCmd.ask('Shell command: ', '').strip
  @pB.clear; @pB.update = true
  return if raw.empty?

  # Prefix override: §cmd = force interactive
  force = raw.start_with?('§')
  raw = raw[1..].strip if force
  # Expand @s / @t
  # Check if this is a GUI application that needs unescaped filenames
  gui_apps = %w[evince okular xdg-open firefox chromium nautilus thunar dolphin gwenview eog]
  first_word = raw.split.first
  
  if gui_apps.include?(first_word)
    # For GUI apps, quote the filename instead of escaping
    sel = '"' + @selected.to_s.gsub('"', '\\"') + '"'
    tg  = @tagged.map { |p| '"' + p.gsub('"', '\\"') + '"' }.join(' ')
  else
    # For command-line tools, use shell escaping
    sel = Shellwords.escape(@selected.to_s)
    tg  = @tagged.map { |p| Shellwords.escape(p) }.join(' ')
  end
  cmd = raw.gsub('@s', sel).gsub('@t', tg)
  
  # Handle cd commands specially
  if cmd.match(/^\s*cd\s*(.*)$/)
    target = $1.strip
    target = ENV['HOME'] if target.empty?  # Default to home directory
    target = File.expand_path(target)
    
    if File.directory?(target)
      @directory[Dir.pwd] = @index; mark_latest
      Dir.chdir(target)
      @pB.update = @pR.update = true
      refresh
      return
    else
      @pB.say("cd: #{target}: No such directory".fg(196))
      return
    end
  end
  # Determine program name
  prog = Shellwords.split(cmd).first
  prog = File.basename(prog) if prog
  # Whitelist check against @interactive
  inter_list = (@interactive || '').split(',').map(&:strip)
  whitelist = prog && inter_list.include?(prog)
  # Magic PTY-peek (only if not forced or whitelisted)
  magic = false
  unless force || whitelist
    begin
      PTY.spawn(cmd) do |r, _w, pid|
        begin
          Timeout.timeout(0.1) do
            magic = r.readpartial(1024).include?("\e[?1049h")
          end
        rescue Timeout::Error
          # no quick data = assume non-TUI
        ensure
          Process.kill('TERM', pid) rescue nil
          Process.wait(pid)        rescue nil
        end
      end
    rescue Errno::ENOENT, Errno::EIO
      # command not found or PTY closed immediately = non-TUI
    end
  end
  if force || whitelist || magic # Decide interactive vs non-interactive
    # Restore shell tty so Ctrl-C/D work
    system("stty #{ORIG_STTY} < /dev/tty")
    # Clear to top-left
    system('clear < /dev/tty > /dev/tty')
    Cursor.show
    # Spawn on real tty
    pid2 = Process.spawn(cmd,
                         in:  '/dev/tty',
                         out: '/dev/tty',
                         err: '/dev/tty')
    begin
      Process.wait(pid2)
    rescue Interrupt
      Process.kill('TERM', pid2) rescue nil
      retry
    end
    # Restore raw/no-echo for RTFM
    system('stty raw -echo isig < /dev/tty')
    $stdin.raw!
    $stdin.echo = false
    Cursor.hide
    Rcurses.clear_screen
    refresh
    render
  else
    # Check if it's a GUI application that should run without timeout
    gui_apps = ['evince', 'xdg-open', 'firefox', 'chrome', 'chromium', 'eog', 'nautilus', 'thunar', 'gedit', 'code', 'subl']
    prog_base = File.basename(prog.to_s)
    is_gui = gui_apps.include?(prog_base)
    
    # Use nil timeout (wait forever) for GUI apps, 10 seconds for others
    shellexec(cmd, timeout: is_gui ? nil : 10)
    @pR.refresh
    @pR.update = false
  end
end

def show_history # {{{3
  @pR.say("Command history:\n\n" + @pCmd.history.reverse.join("\n"))
  @pB.update = true
end

def show_ssh_history # {{{3
  history_text = "SSH Connection History\n".b.fg(156)
  history_text << "=" * 50 + "\n\n"
  
  if @pSsh.history.empty?
    history_text << "No SSH connections in history\n".fg(240)
    history_text << "\nPress " + "Ctrl+E".fg(156) + " to start browsing remote directories\n".fg(249)
  else
    history_text << "Recent connections:\n".fg(226)
    @pSsh.history.reverse.each_with_index do |connection, i|
      # Parse and format connection for display
      if connection =~ /([^@]+@[^:\s]+)/
        host_part = $1
        history_text << sprintf("  %2d. %s\n", i + 1, host_part.fg(156))
        if connection.length > host_part.length + 10
          history_text << sprintf("      %s\n", connection.fg(240))
        end
      else
        history_text << sprintf("  %2d. %s\n", i + 1, connection.fg(156))
      end
    end
    history_text << "\n" + "Use " + "↑/↓".fg(156) + " in SSH prompt to recall connections".fg(249)
  end
  
  @pR.say(history_text)
  @pB.update = true
end

def add_interactive # {{{
  @interactive = @pB.ask('Add program to @interactive: '.fg(213), @interactive.fg(213))
  @pB.clear; @pB.update = true
end

# RUBY MODE {{{2
def ruby_debug # {{{3
  require 'stringio'
  cmd = @pRuby.ask('Ruby command: ', '')
  
  # If user cancelled (ESC) or entered empty command, don't execute or output anything
  if cmd.nil? || cmd.strip.empty?
    @pB.clear; @pB.update = true
    return
  end
  
  @pR.text = "Command: #{cmd}\n\n".fg(205)
  original_stdout = $stdout
  original_stderr = $stderr
  stdout_captured = StringIO.new
  stderr_captured = StringIO.new
  $stdout = stdout_captured
  $stderr = stderr_captured
  begin
    eval(cmd) # rubocop:disable Security/Eval
  rescue Exception => e # rubocop:disable Lint/RescueException
    puts "Error: #{e.message}\n#{e.backtrace.join("\n")}"
  ensure
    $stdout = original_stdout
    $stderr = original_stderr
  end
  @pR.text += stdout_captured.string + "\n\n" + stderr_captured.string
  @pR.refresh
  @pR.update = false
  @pB.full_refresh
end

# GENERIC FUNCTIONS {{{1
def get_cached_dirlist(dir, ls_options) # {{{2
  # Generate cache key with directory mtime for automatic invalidation
  begin
    dir_mtime = File.mtime(dir).to_i
    cache_key = "#{dir}:#{ls_options}:#{dir_mtime}"
  rescue
    return nil  # Can't cache if we can't get mtime
  end
  
  # Return cached result if available and fresh
  cached = @dir_cache[cache_key]
  return cached if cached
  
  # Clean old cache entries if cache is getting too large
  if @dir_cache_size >= @max_cache_entries
    @dir_cache.clear
    @dir_cache_size = 0
  end
  
  # Generate new directory listing
  begin
    purels  = command("ls #{Shellwords.escape(dir)} #{ls_options}").pure.split("\n")
    color_opt = @is_macos_bsd ? '-G' : '--color'
    colorls = command("ls #{color_opt} #{Shellwords.escape(dir)} #{ls_options} #{@lslong}").split("\n")
    
    result = { purels: purels, colorls: colorls }
    @dir_cache[cache_key] = result
    @dir_cache_size += 1
    
    # Clean up old entries for this directory
    @dir_cache.delete_if { |key, _| key.start_with?("#{dir}:") && key != cache_key }
    
    result
  rescue => e
    # Return empty result on error
    { purels: [], colorls: [] }
  end
end

def get_cached_file_metadata(file_path) # {{{2
  return nil unless file_path && File.exist?(file_path)
  
  begin
    file_stat = File.stat(file_path)
    cache_key = "#{file_path}:#{file_stat.mtime.to_i}:#{file_stat.size}"
  rescue
    return nil
  end
  
  # Check if we have cached metadata
  cached_metadata = @metadata_cache[cache_key]
  return cached_metadata if cached_metadata
  
  # Clean old cache entries if cache is getting too large
  if @metadata_cache_size >= @max_metadata_entries
    @metadata_cache.clear
    @metadata_cache_size = 0
  end
  
  # Generate new metadata
  metadata = nil
  begin
    if file_path.match(@imagefile) && cmd?('identify')
      metadata = `identify -format " [%wx%h %m %[colorspace] %[bit-depth]-bit]" #{Shellwords.escape(file_path)} 2>/dev/null`.strip
    elsif file_path.match(@pdffile)
      info = `pdfinfo #{Shellwords.escape(file_path)} 2>/dev/null`
      pages = info[/^Pages:\s+(\d+)/, 1]
      metadata = pages ? " [#{pages} pages]" : nil
    end
    
    # Cache the result (even if nil)
    @metadata_cache[cache_key] = metadata
    @metadata_cache_size += 1
    
    # Clean up old entries for this file
    @metadata_cache.delete_if { |key, _| key.start_with?("#{file_path}:") && key != cache_key }
    
    metadata
  rescue
    nil
  end
end

def dirlist_remote # {{{2
  return '' unless @current_remote && @remote_mode
  
  current_index = @index || 0
  current_index = current_index.to_i
  width = @pL.w
  
  # Check if we need to refresh the directory listing
  connection_id = "#{@current_remote[:user]}@#{@current_remote[:host]}"
  cache_key = "#{connection_id}:#{@remote_path}"
  
  # Use cache if available and recent (60 seconds for navigation)
  if @remote_cache[cache_key] && (Time.now - @remote_cache[cache_key][:timestamp]) < 60
    files = @remote_cache[cache_key][:files]
  else
    # Only fetch remote listing when cache is stale or missing
    begin
      files = list_remote_directory(@remote_path)
      # Cache the result
      @remote_cache[cache_key] = {
        files: files,
        timestamp: Time.now
      }
    rescue StandardError => e
      @pB.say("Remote listing failed: #{e.message}".fg(196))
      return ''
    end
  end
  
  # Update global @files for navigation (simple names array)
  @files = files.map { |f| f[:name] }
  
  # Store the full file info for later use (avoid repeated SSH calls)
  @remote_files_cache = files
  
  # Update @selected for current selection
  if @files[@index] && files[@index]
    @selected = "#{@remote_path}/#{@files[@index]}"
    selected_file = files[@index]
    @fileattr = "#{selected_file[:owner]}:#{selected_file[:group]} #{selected_file[:permissions]}  #{format_size_simple(selected_file[:size])}  #{selected_file[:modified]}"
  end
  
  # Format the listing with remote-specific styling
  search_regex = @searched.empty? ? nil : /#{@searched}/
  
  result = files.map.with_index do |file, i|
    name = file[:name]
    
    # Color coding for different file types
    color = case file[:type]
           when 'directory' then 156  # Blue-ish
           when 'symlink' then 226    # Yellow  
           when 'file' then 255       # White
           else 240                   # Gray
           end
    
    # Apply color and decorations
    n = name.fg(color)
    n = n.inject('@', -1) if file[:type] == 'symlink'
    n = n.inject('/', -1) if file[:type] == 'directory'
    n = n.bg(238) if search_regex && name.match(search_regex)
    
    # Truncate if too long
    n = n.shorten(width - 5).inject('…', -1) if name.length > width - 6
    
    # Add selection indicator and remote mode indicator (red background)
    if i == current_index
      n = '→ ' + n.u.bg(52)  # Red background for remote mode selection
    else
      n = '  ' + n.bg(52)    # Red background for remote mode
    end
    
    n
  end
  
  result.join("\n")
end

def dirlist(left: true, directory: nil) # LIST DIRECTORIES {{{2
  current_index = @index || 0
  current_index = current_index.to_i
  
  # Handle remote mode for left pane
  if left && @remote_mode
    return dirlist_remote
  end
  
  if left
    dir = directory || Dir.pwd
    width = @pL.w
  else
    dir = if directory
            directory
          elsif @selected && File.directory?(@selected)
            File.symlink?(@selected) ? File.realpath(@selected) : @selected.to_s
          else
            File.dirname(@selected)
          end
    width = @pR.w
  end
  # Use cached directory listing
  ls_options = "#{@lsbase} #{@lsall} #{@lsorder} #{@lsinvert} #{@lsuser}"
  cached = get_cached_dirlist(dir, ls_options)
  
  if cached
    purels = cached[:purels]
    colorls = cached[:colorls]
  else
    # No cache available, generate fresh directory listing
    begin
      color_opt = @is_macos_bsd ? '-G' : '--color=always'
      color_output = command("ls #{Shellwords.escape(dir)} #{color_opt} #{ls_options}")
      pure_output = command("ls #{Shellwords.escape(dir)} #{ls_options}")
      purels = pure_output.pure.split("\n")
      colorls = color_output.split("\n")
    rescue => e
      purels = []
      colorls = []
    end
  end
  colorls.shift if colorls[0]&.strip == "#{dir}:"
  colorls.shift if colorls[0]&.match?(/^total/)
  if left && @orderchange && !directory # Keep the same @selected even when we re-sort (only for single-pane)
    basename = File.basename(@selected.to_s)
    new_idx  = purels.index(basename)
    @index   = new_idx if new_idx
    @orderchange = false
  end
  entries = purels.zip(colorls) # Zip into pairs and filter
  unless @lsfiles.empty?  # filter by extension(s)
    exts = @lsfiles.split(',').map { |e| /\.#{Regexp.escape(e)}$/i }
    entries.select! { |name, _| exts.any? { |re| name =~ re } }
  end
  unless @lsmatch.empty?  # filter by regex
    re = Regexp.new(@lsmatch)
    entries.select! { |name, _| name =~ re }
  end
  # Unzip, with safe defaults if nothing matched
  t = entries.transpose
  purels = t[0] || []
  ls     = t[1] || []
  # Only update global variables in single-pane mode
  if left && !directory
    @files = purels
    # Update @selected & @fileattr for left pane
    if purels[@index]
      @selected = Dir.pwd + '/' + purels[@index]
      sfile = @selected.dup
      sfile += '/' if File.directory?(@selected)
      time_opt = @is_macos_bsd ? '-T' : '--time-style=long-iso'
      slsl_cmd = "ls -ldHlh #{time_opt} #{Shellwords.escape(sfile)}"
      slsl = command(slsl_cmd)
      a = slsl.split
      @fileattr = a.size >= 7 ? "#{a[2]}:#{a[3]} #{a[0]}  #{a[4]}  #{a[5]} #{a[6]}" : ''
    end
  end
  # Map & decorate each colored line - optimized version
  base_dir = left ? (directory || Dir.pwd) : dir
  search_regex = @searched.empty? ? nil : /#{@searched}/
  
  ls.map!.with_index do |el, i|
    n = el.to_s.clean_ansi
    n = n.shorten(width - 5).inject('…', -1) if n.pure.length > width - 6
    raw_name = (purels[i] || '').strip
    next n if raw_name.empty?
    
    fullpath = "#{base_dir}/#{raw_name}"
    
    # Batch file system checks to reduce system calls
    is_symlink = File.symlink?(fullpath)
    is_directory = File.directory?(fullpath)
    
    n = n.inject('@', -1) if is_symlink
    n = n.inject('/', -1) if is_directory
    n = n.bg(238) if search_regex && raw_name.match(search_regex)
    n = n.r if @tagged.include?(fullpath)
    
    if left
      if i == current_index
        n = '→ ' + n.u  # Default terminal color
      else
        n = '  ' + n
      end
    end
    n
  end
  ls.join("\n")
end

def current_render_state # {{{2
  # Create a hash representing current state for conditional rendering
  # Note: pane_updates removed to avoid oscillation - panes update themselves when needed
  if @dual_pane
    # Dual-pane specific state tracking
    {
      dual_pane: true,
      active_pane: @active_pane,
      pwd_left: @pwd_left,
      pwd_right: @pwd_right,
      index_left: @index_left,
      index_right: @index_right,
      selected_left: @selected_left,
      selected_right: @selected_right,
      tagged_count: @tagged.size,
      preview: @preview,
      showimage: @showimage,
      searched: @searched,
      filters: [@filter, @filtered].join,
      tabs_state: [@tabs.size, @current_tab, @tabs.map { |t| t[:name] }.join('|')]
    }
  else
    # Single-pane state tracking
    {
      dual_pane: false,
      dir: Dir.pwd,
      index: @index,
      files_mtime: File.mtime(Dir.pwd).to_i,
      selected: @selected,
      tagged_count: @tagged.size,
      preview: @preview,
      showimage: @showimage,
      searched: @searched,
      filters: [@filter, @filtered].join,
      tabs_state: [@tabs.size, @current_tab, @tabs.map { |t| t[:name] }.join('|')]
    }
  end
end

def needs_render? # {{{2
  current_state = current_render_state
  state_changed = @last_render_state != current_state
  
  # Also check if any panes need updating (for refresh operations)
  panes_need_update = @pL&.update || @pR&.update || @pT&.update || @pB&.update
  if @dual_pane
    panes_need_update ||= @pLeft&.update || @pRight&.update || @pPreview&.update
  end
  
  needs_update = state_changed || panes_need_update
  @last_render_state = current_state if state_changed
  needs_update
end

def render # RENDER ALL PANES {{{2
  return unless needs_render?
  
  # LEFT pane {{{3
  if @pL.update
    lefttext = @pL.text
    @pL.text = dirlist
    scrolloff = 3
    total     = @files.size
    page      = @pL.h
    if total <= page
      # If everything fits, always start from the very top
      @pL.ix = 0
    elsif @index - @pL.ix < scrolloff
      # If we're too close to the top of the pane, scroll up
      @pL.ix = [@index - scrolloff, 0].max
    elsif (@pL.ix + page - 1 - @index) < scrolloff
      # If we're too close to the bottom of the pane, scroll down
      max_off = total - page
      @pL.ix = [@index + scrolloff - page + 1, max_off].min
    end
    @min_index = 0
    @max_index = total - 1
    @index     = 0          if @index.negative?
    @index     = @max_index if @index > @max_index
    @pL.refresh unless @pL.text == lefttext
  end
  
  # DUAL-PANE render section removed - using tabs for multi-directory navigation

  # RIGHT pane (or Preview pane in dual-pane mode) {{{3
  if @pR.update && @preview
    showimage('clear') if @image; @image = false
    righttext = @pR.text
    
    # In dual-pane mode, construct full path from active pane
    if @dual_pane
      active_dir = @active_pane == :left ? @pwd_left : @pwd_right
      active_selected = @active_pane == :left ? @selected_left : @selected_right
      
      if active_selected && !active_selected.empty?
        # Ensure we're only using the basename for the selected file
        selected_basename = File.basename(active_selected)
        full_selected_path = File.join(active_dir, selected_basename)
        
        # Temporarily set @selected for existing preview functions to work
        old_selected = @selected
        @selected = full_selected_path
        
        if File.exist?(full_selected_path) && File.directory?(full_selected_path)
          @pR.text = dirlist(left: false)
        else
          # Use existing showcontent function for files
          showcontent
        end
        
        # Restore original @selected
        @selected = old_selected
      else
        @pR.text = "No file selected"
      end
    else
      # Original single-pane logic
      if @selected && File.directory?(@selected)
        @pR.text = dirlist(left: false)
      else
        showcontent
      end
    end
    
    @pR.full_refresh unless @pR.text == righttext || @image
  end

  # TOP PANE {{{3
  if @pT.update
    toptext = @pT.text
    text = ' ' + ENV.fetch('USER') + '@' + `hostname 2>/dev/null`.chomp + ': '
    unless @selected.nil?
      text += @selected
      text += " → #{File.readlink(@selected)}" if File.symlink?(@selected)
    end
    # File attributes
    text += " (#{@fileattr})" if defined?(@fileattr)
    # Image or PDF metadata using cache
    begin
      cached_meta = get_cached_file_metadata(@selected)
      if cached_meta
        text += cached_meta
      elsif @selected&.match(@imagefile) && cmd?('identify')
        # Fallback for non-cached image metadata
        meta = `identify -format " [%wx%h %m %[colorspace] %[bit-depth]-bit]" #{Shellwords.escape(@selected)} 2>/dev/null`
        text += meta
      elsif @selected&.match(@pdffile)
        # Fallback for non-cached PDF metadata
        info  = `pdfinfo #{Shellwords.escape(@selected)} 2>/dev/null`
        pages = info[/^Pages:\s+(\d+)/, 1]
        size  = info[/^Page size:.*\((.*)\)/, 1]
        text += " [#{pages} pages]" if pages
        text += " [#{size}]"         if size
      end
    rescue Errno::ENOENT, Errno::EACCES
      # ignore missing or permission errors
    end
    # Directory children count
    if @selected && Dir.exist?(@selected)
      begin
        count = Dir.children(@selected).count
        text += " [#{count} items]"
      rescue Errno::EACCES
        text += ' [Denied]'
      end
    end
    
    # Add tab indicator to the right side if there are multiple tabs
    if @tabs.size > 1
      tab_indicator = "[#{@current_tab + 1}/#{@tabs.size}]"
      # Calculate available space for the main text (including space for right-justification)
      available_width = @pT.w - tab_indicator.length - 1  # -1 for space between text and indicator
      if text.length > available_width
        text = text[0, available_width - 3] + "..."
      end
      # Right-justify the tab indicator by padding with spaces
      padding = @pT.w - text.length - tab_indicator.length
      text += " " * padding + tab_indicator
    end
    
    @pT.text = text.b
    
    @pT.bg   = @topmatch.find { |name, _| name.empty? || Dir.pwd.include?(name) }&.last
    @pT.refresh unless @pT.text == toptext
  end

  # BOTTOM pane {{{3
  if @pB.update
    bottomtext = @pB.text
    info = ': for command (use @s for selected item, @t for tagged items) - press ? for help'
    info = " Showing only files matching '#{@lsmatch}'".fg(130).u if @lsmatch != ''
    info = " Showing only file type '#{@lsfiles}'".fg(129).u if @lsfiles != ''
    info = " Showing only file types '#{@lsfiles}'".fg(129).u if @lsfiles =~ /,/
    info += " and only files matching '#{@lsmatch}'".fg(129).u if @lsfiles != '' && @lsmatch != ''
    @pB.text = info
    @pB.refresh unless @pB.text == bottomtext
  end
end

def refresh # {{{2
  @p0.w = @w
  @p0.h = @h
  @p0.clear
  @pT.w = @w
  @pT.clear; @pT.update = true
  @pTab.w = @w  # Keep tab overlay same width as top pane
  @pB.w = @w
  @pB.y = @h
  @pB.clear; @pB.update = true
  if @dual_pane && @pLeft && @pRight && @pPreview
    # Update dual-pane layout with width control via @width variable
    total_width = @w - 6  # Account for spacing and borders (2 + 2 + 2 spacing)
    # Map @width (2-7) to dir_panes_ratio (0.5 to 0.33) - balanced default
    dir_panes_ratio = [0.5 - (@width - 2) * 0.034, 0.33].max
    dir_panes_width = (total_width * dir_panes_ratio).to_i
    left_width = dir_panes_width / 2
    preview_width = total_width - dir_panes_width
    
    @pLeft.w = left_width
    @pLeft.h = @h - 4
    @pRight.x = left_width + 4  # 2 columns spacing
    @pRight.w = left_width
    @pRight.h = @h - 4
    @pPreview.x = 2*left_width + 6  # 2 columns spacing
    @pPreview.w = preview_width
    @pPreview.h = @h - 4
    
    @pLeft.clear; @pLeft.update = true
    @pRight.clear; @pRight.update = true
    @pPreview.clear; @pPreview.update = true
  else
    # Update single-pane layout
    @pL.w = (@w - 4) * @width / 10
    @pL.h = @h - 4
    @pR.x = @pL.w + 4
    @pR.w = @w - @pL.w - 4
    @pR.h = @h - 4
    @pL.clear; @pL.update = true
    @pR.clear; @pR.update = true
  end
  @pCmd.y = @h
  @pCmd.w = @w
  @pRuby.y = @h
  @pRuby.w = @w
  @pSsh.y = @h
  @pSsh.w = @w
end

def setborder # {{{2
  case @border
  when 0
    @pL.border = false
    @pR.border = false
  when 1
    @pL.border = false
    @pR.border = true
  when 2
    @pL.border = true
    @pR.border = true
  when 3
    @pL.border = true
    @pR.border = false
  end
  refresh
end

def errormsg(msg, err) # {{{2 
  text  = "#{msg}: #{err.class} – #{err.message}\n\n".fg(196)
  err.backtrace.each { |ln| text += "  #{ln}\n" } # Full backtrace
  text += "\nPlease report error as an RTFM issue: https://github.com/isene/RTFM/issues"
  @pR.say(text)
end

def shell(cmd, background: false, err: nil) # {{{2
  # if caller passed err: filename, use that, otherwise fall back to the default log in /tmp
  tmp = err || File.join(Dir.tmpdir, 'rtfm_err.log')
  # build the full command, redirecting stderr to tmp
  full = if background
           "#{cmd} 2>#{tmp} &"
         else
           "#{cmd} 2>#{tmp}"
         end
  system(full)
  if File.exist?(tmp) # if anything was written, show it in @pR
    sleep 0.1
    err_text = File.read(tmp)
    unless err_text.strip.empty?
      @pR.say(err_text.fg(196))
      @pR.update = false
    end
    File.delete(tmp)
  end
  nil
end

def shellexec(cmd, timeout: 10) # {{{2
  out, err = command(cmd, timeout: timeout, return_both: true)
  @pR.say(err.fg(196)) unless err.empty?
  @pR.say(out) unless out.empty?
  @pB.full_refresh
end

def command(cmd, timeout: 5, return_both: false) # {{{2
  # Run a shell command with timeout, drain pipes concurrently,
  # and optionally return both stdout+stderr instead of auto-saying stderr
  #
  # @param cmd [String] the command to run via `bash -c`
  # @param timeout [Numeric, nil] seconds to wait (nil = wait forever)
  # @param return_both [Boolean] if true, return [stdout, stderr] instead of auto-saying
  # @return [String] stdout (when return_both: false)
  # @return [Array<String,String>] [stdout, stderr] (when return_both: true)
  cmd_array = ['bash', '-c', cmd]
  out_buf = String.new
  err_buf = String.new
  begin
    Open3.popen3(*cmd_array) do |_stdin, stdout, stderr, wait_thr|
      pid = wait_thr.pid
      # Drain both pipes in background threads
      out_reader = Thread.new { out_buf << stdout.read until stdout.eof? }
      err_reader = Thread.new { err_buf << stderr.read until stderr.eof? }
      begin
        if timeout
          unless wait_thr.join(timeout)
            # Timed out → kill everything
            Process.kill('TERM', pid) rescue nil
            sleep 0.1
            Process.kill('KILL', pid) rescue nil
            Process.wait(pid) rescue nil
            out_reader.kill
            err_reader.kill
            showimage('clear') if @image
            @pR.say('Error: Command timed out.'.fg(196))
            return return_both ? ['', "Error: Command timed out.\n"] : ''
          end
        else
          wait_thr.join
        end
        # Ensure we've captured all output
        out_reader.join(1)  # Add timeout to prevent hanging
        err_reader.join(1)  # Add timeout to prevent hanging
      ensure
        # Clean up threads if they're still running
        out_reader.kill if out_reader.alive?
        err_reader.kill if err_reader.alive?
      end
    end
    if return_both
      [out_buf, err_buf]
    else
      @pR.say(err_buf.fg(196)) unless err_buf.empty?
      out_buf
    end
  rescue IOError => e
    msg = "Error: Stream closed – #{e.message}\n"
    if return_both
      ['', msg]
    else
      showimage('clear') if @image
      @pR.say(msg.fg(196))
      ''
    end
  rescue => e
    msg = "Error: #{e.message}\n#{e.backtrace.join("\n")}\n"
    if return_both
      ['', msg]
    else
      @pR.say(msg.fg(196))
      ''
    end
  end
end

def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
  @tagged.uniq!
  
  # Determine destination directory based on mode
  dest_dir = if @dual_pane
               @active_pane == :left ? @pwd_left : @pwd_right
             else
               Dir.pwd
             end
  
  # Track operations for undo
  operations = []
  
  @tagged.each do |item|
    dest = File.join(dest_dir, File.basename(item))
    dest += '1' if File.exist?(dest)
    while File.exist?(dest)
      # Replace the last character (presumed to be a digit) by incrementing it
      dest = dest.chop + (dest[-1].to_i + 1).to_s
    end
    begin
      case type
      when 'copy'
        FileUtils.cp_r(item, dest)
        operations << { source_path: item, dest_path: dest }
        @pB.say(' Item(s) copied here.')
      when 'move'
        FileUtils.mv(item, dest)
        operations << { source_path: item, dest_path: dest }
        @pB.say(' Item(s) moved here.')
      when 'link'
        FileUtils.ln_s(item, dest)
        operations << { source_path: item, dest_path: dest }
        @pB.say(' Item(s) symlinked here.')
      end
    rescue => e
      @pB.say(e.to_s)
    end
  end
  
  # Record undo information if operations were successful
  unless operations.empty?
    case type
    when 'copy'
      add_undo_operation({
        type: 'copy',
        copies: operations,
        timestamp: Time.now
      })
    when 'move'
      add_undo_operation({
        type: 'move',
        moves: operations,
        timestamp: Time.now
      })
    when 'link'
      add_undo_operation({
        type: 'link',
        links: operations,
        timestamp: Time.now
      })
    end
  end
  
  @tagged = []
  
  # Set update flags for proper refresh - let render system handle the actual refresh
  if @dual_pane
    # Update the destination pane
    if @active_pane == :left
      @pLeft.update = true
    else
      @pRight.update = true
    end
    # Also update the preview pane
    @pPreview.update = true if @pPreview
  end
  
  render
end

def mark_latest # UPDATE MARKS LIST {{{2
  @marks['5'] = @marks['4']
  @marks['4'] = @marks['3']
  @marks['3'] = @marks['2']
  @marks['2'] = @marks['1']
  @marks['1'] = @marks["'"]
  @marks["'"] = Dir.pwd
end

def get_interactive_program(file_path) # HELPER FOR OPEN_SELECTED TO USE @interactive {{{2
  begin
    inter_list = (@interactive || '').split(',').map(&:strip)
    if @runmailcap
      # Check what run-mailcap would execute. Use --norun to see the command without executing it
      mailcap_output = `run-mailcap --norun #{Shellwords.escape(file_path)} 2>/dev/null`.chomp
      if !mailcap_output.empty?
        # Extract the program name from the command
        # Handle cases like "/usr/bin/mplayer file.mp4" or "mplayer %s"
        cmd_parts = mailcap_output.split
        prog = cmd_parts.first
        prog = File.basename(prog) if prog
        return prog if prog && inter_list.include?(prog)
      end
    else
      # Check what xdg-open would use
      mimetype = `file --mime-type -b #{Shellwords.escape(file_path)}`.chomp
      desktop_file = `xdg-mime query default #{Shellwords.escape(mimetype)}`.chomp
      
      if desktop_file && !desktop_file.empty?
        # Read the desktop file to get the actual command
        desktop_path = nil
        ['/usr/share/applications/', '/usr/local/share/applications/', 
         File.join(Dir.home, '.local/share/applications/')].each do |dir|
          path = File.join(dir, desktop_file)
          if File.exist?(path)
            desktop_path = path
            break
          end
        end
        if desktop_path
          content = File.read(desktop_path)
          exec_line = content[/^Exec=(.*)$/m, 1]
          if exec_line
            # Extract the program name (first word, remove path)
            prog = exec_line.split.first
            prog = File.basename(prog) if prog
            return prog if prog && inter_list.include?(prog)
          end
        end
      end
    end
  rescue
    # If detection fails, fall back to normal behavior
  end
  nil
end

def open_selected(html = nil) # OPEN SELECTED FILE {{{2
  require 'tmpdir' unless Dir.respond_to?(:tmpdir)
  if File.directory?(@selected) # Dir? just cd into it
    mark_latest
    Dir.chdir(@selected) rescue nil
    track_directory_access(@selected)
    return
  end
  
  # Track file access when opening files
  track_file_access(@selected)
  
  # Check if this file should use an interactive program (same as § prefix logic)
  if !html && (prog = get_interactive_program(@selected))
    cmd = "#{prog} #{Shellwords.escape(@selected)}"
    # Use exactly the same logic as command_mode with § prefix (force interactive)
    # Restore shell tty so Ctrl-C/D work
    system("stty #{ORIG_STTY} < /dev/tty")
    # Clear to top-left
    system('clear < /dev/tty > /dev/tty')
    Cursor.show
    # Spawn on real tty
    pid = Process.spawn(cmd,
                       in:  '/dev/tty',
                       out: '/dev/tty',
                       err: '/dev/tty')
    begin
      Process.wait(pid)
    rescue Interrupt
      Process.kill('TERM', pid) rescue nil
      retry
    end
    # Restore raw/no-echo for RTFM
    system('stty raw -echo isig < /dev/tty')
    $stdin.raw!
    $stdin.echo = false
    Cursor.hide
    Rcurses.clear_screen
    refresh
    render
    return
  end
  tmpfile = File.join(Dir.tmpdir, 'rtfm_err.log')
  paths   = (@tagged + [@selected]).uniq
  if html # html mode - open in HTML-browser
    esc = paths.map { |p| Shellwords.escape(p) }.join(' ')
    shell("xdg-open #{esc} &", err: tmpfile)
    Rcurses.clear_screen; refresh; render
    if File.exist?(tmpfile)
      sleep 0.5
      err = File.read(tmpfile)
      showimage('clear') if @image
      @pR.say(err.fg(196))
      File.delete(tmpfile)
    end
    return
  end
  # Don't try to read large files or PDFs as text for validation
  if !@selected&.match(@pdffile) && File.size(@selected) < 1_000_000 && File.read(@selected).force_encoding('UTF-8').valid_encoding? # Pure text
    # Save terminal state before launching editor
    system("stty -g < /dev/tty > /tmp/rtfm_stty_$$")
    # Reset terminal to sane/cooked mode for editor
    system('stty sane < /dev/tty')
    # Clear and reset terminal for editor
    system('clear < /dev/tty > /dev/tty')
    Cursor.show
    # Launch editor
    editor = ENV.fetch('EDITOR', 'vi')
    system("#{editor} #{Shellwords.escape(@selected)}")
    # Restore terminal state
    system("stty $(cat /tmp/rtfm_stty_$$) < /dev/tty")
    system("rm -f /tmp/rtfm_stty_$$")
    # Flush any pending input and reset stdin
    $stdin.iflush if $stdin.respond_to?(:iflush)
    system('stty raw -echo isig < /dev/tty')
    $stdin.raw!
    $stdin.echo = false
    # Reinitialize rcurses
    Rcurses.init!
    Cursor.hide
    Rcurses.clear_screen
    refresh
    render
    return
  end
  if @runmailcap # Open with run-mailcap or xdg-open
    arg = paths.map { |p| Shellwords.escape(p) }.join(' ')
    shell("run-mailcap #{arg} &", err: tmpfile)
  else
    shell("xdg-open #{Shellwords.escape(@selected)} &", err: tmpfile)
  end
  # Clean up
  Rcurses.clear_screen; refresh; render
  if File.exist?(tmpfile)
    sleep 0.5
    err = File.read(tmpfile)
    showimage('clear') if @image
    @pR.say(err.fg(196))
    File.delete(tmpfile)
  end
end

def conf_write(all: false) # WRITE TO ~/.rtfm/conf {{{2
  @conf = @conf.dup # work on a mutable copy
  assignments = {
    'marks'       => "@marks       = #{@marks}",
    'hash'        => "@hash        = #{@hash}",
    'history'     => "@history     = #{@pCmd.history.reverse.uniq.reverse.last(40)}",
    'rubyhistory' => "@rubyhistory = #{@pRuby.history.reverse.uniq.reverse.last(40)}",
    'aihistory'   => "@aihistory   = #{@pAI.history.reverse.uniq.reverse.last(40)}",
    'sshhistory'  => "@sshhistory  = #{@pSsh.history.reverse.uniq.reverse.last(40)}"
  }
  if all
    assignments.merge!(
      'lslong'      => "@lslong      = '#{@lslong}'",
      'lsall'       => "@lsall       = '#{@lsall}'",
      'lsorder'     => "@lsorder     = '#{@lsorder}'",
      'lsinvert'    => "@lsinvert    = '#{@lsinvert}'",
      'width'       => "@width       = #{@width}",
      'border'      => "@border      = #{@border}",
      'preview'     => "@preview     = #{@preview}",
      'trash'       => "@trash       = #{@trash}",
      'interactive' => "@interactive = '#{@interactive}'"
    )
  end
  assignments.each do |var, line| # For each var, replace its line or append if missing
    regex = /^@#{Regexp.escape(var)}\b.*$/
    if @conf.match?(regex)
      @conf.sub!(regex, line)
    else
      @conf << "\n" unless @conf.end_with?("\n")
      @conf << line << "\n"
    end
  end
  show_config
  @pB.say('Configuration written to ~/.rtfm/conf')
  File.write(CONFIG_FILE, @conf)
end

def exit_rtfm # CLEAN EXIT {{{2
  # If invoked with a stub filename, write out our cwd
  if ARGV[0]
    begin
      File.write(ARGV[0], Dir.pwd)
    rescue StandardError # rubocop:disable Lint/SuppressedException
    end
  end
  # Also record for the 'r' launcher
  last = File.join(Dir.home, '.rtfm_last_dir')
  begin
    File.write(last, Dir.pwd)
  rescue StandardError
    # ignore write errors
  end
  exit(0)
end

def log(msg) # LOG TO LOGFILE {{{2
  File.write(LOG_PATH, "#{Time.now.iso8601} #{msg}\n", mode: 'a')
end

# RIGHT PANE FUNCTIONS {{{1
def showcommand(cmd) # Helper function for showcontent {{{2
  c = command(cmd)
  
  # If command failed or returned empty, try fallback to cat
  if c == '' || c.nil?
    # Extract the original filename from the command
    # Handle various command patterns
    if cmd =~ /pandoc\s+'([^']+)'/ || cmd =~ /pandoc\s+(\S+)/
      # Markdown file - fallback to cat
      filename = $1
      c = command("cat #{Shellwords.escape(filename)}")
    elsif cmd =~ /#{@bat}\s+[^']*'([^']+)'/ || cmd =~ /#{@bat}\s+[^']*(\S+)/
      # Shell scripts and text files - fallback to cat  
      filename = $1
      c = command("cat #{Shellwords.escape(filename)}")
    end
  end
  
  return if c == ''

  @pR.say(c)
end

def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
  if @pR.update
    showimage('clear') if @image
    @image = false
    @pR.clear
  end
  begin
    # Handle remote mode separately
    if @remote_mode && @files && @files[@index] && @remote_files_cache[@index]
      selected_file = @remote_files_cache[@index]
      show_remote_file_info(selected_file)
      return
    end
    
    if @selected && File.directory?(@selected)
      @pR.say(dirlist(left: false))
    else # Look up first matching handler
      entry = PREVIEW_HANDLERS.find { |re, _| re.match?(@selected) }
      pattern, tmpl = entry || [nil, nil]
      if tmpl   # Command-based preview
        if !@batuse && tmpl.match?(/\b#{@bat}\b/)
          escaped = Shellwords.escape(@selected)
          showcommand("cat #{escaped}")
        else
          # For complex commands with pipes, we need to quote the filename
          # within the shell command string since it's executed via bash -c
          # First escape any single quotes in the filename
          safe_filename = @selected.gsub("'", "'\"'\"'")
          # Then wrap in single quotes for the shell command
          cmd = tmpl.gsub('@s', "'#{safe_filename}'")
          showcommand(cmd)
        end
      elsif pattern # Nil template → image or video
        case @selected
        when /\.svg$/i
          # Convert SVG to PNG for display
          begin
            file_size = File.size(@selected)
            if file_size > 50_000_000  # 50MB limit for images
              @pR.say("SVG too large for preview (#{(file_size / 1024.0 / 1024.0).round(1)}MB > 50MB limit)")
            else
              tn = '/tmp/rtfm_svg_preview.png'
              # Try direct conversion first
              if system("convert #{Shellwords.escape(@selected)} #{Shellwords.escape(tn)} 2>/dev/null")
                showimage(tn)
                @image = true
              else
                # If direct conversion fails, try fixing XML entities and retry
                temp_svg = '/tmp/rtfm_svg_fixed.svg'
                begin
                  content = File.read(@selected, encoding: 'UTF-8')
                  # Fix common XML entity issues (replace unescaped ampersands)
                  # First escape all &, then restore valid entities
                  fixed_content = content.gsub('&', '&amp;')
                                         .gsub('&amp;amp;', '&amp;')
                                         .gsub('&amp;lt;', '&lt;')
                                         .gsub('&amp;gt;', '&gt;')
                                         .gsub('&amp;quot;', '&quot;')
                                         .gsub('&amp;apos;', '&apos;')
                                         .gsub(/&amp;#(\d+);/, '&#\1;')
                                         .gsub(/&amp;#x([0-9a-fA-F]+);/, '&#x\1;')
                  File.write(temp_svg, fixed_content)
                  
                  if system("convert #{Shellwords.escape(temp_svg)} #{Shellwords.escape(tn)} 2>/dev/null")
                    showimage(tn)
                    @image = true
                  else
                    @pR.say("Error converting SVG to PNG for preview")
                  end
                ensure
                  File.delete(temp_svg) if File.exist?(temp_svg)
                end
              end
            end
          rescue => e
            @pR.say("Error processing SVG: #{e}")
          end
        when /\.(?:png|jpe?g|bmp|gif|webp|tiff?)$/i
          # Allow larger image files up to 50MB
          begin
            file_size = File.size(@selected)
            if file_size > 50_000_000  # 50MB limit for images
              @pR.say("Image too large for preview (#{(file_size / 1024.0 / 1024.0).round(1)}MB > 50MB limit)")
            else
              showimage(Shellwords.escape(@selected))
              @image = true
            end
          rescue => e
            @pR.say("Error checking image size: #{e}")
          end
        when /\.(?:mpg|mpeg|avi|mov|mkv|mp4|webm|flv|wmv|m4v)$/i
          # Generate video thumbnail regardless of size
          tn = '/tmp/rtfm_video_tn.jpg'
          showcommand("ffmpegthumbnailer -s 1200 -i #{Shellwords.escape(@selected)} -o #{Shellwords.escape(tn)} 2>/dev/null")
          showimage(tn)
          @image = true
        else
          @pR.say("No preview available for #{@selected}")
        end
      else # Fallback: treat as text if UTF‑8
        # Don't try to preview PDFs as text - they contain binary data
        if @selected&.match(@pdffile)
          @pR.say("No preview available for #{@selected}")
        else
          # Enhanced text file preview with partial loading for large files
          begin
            file_size = File.size(@selected)
            
            # For text files, we can preview them partially even if large
            if file_size > 1_000_000  # Files larger than 1MB
              # Calculate how many lines fit in the preview pane
              preview_lines = @pR.h * 3  # Allow for 3 screens worth of preview
              
              # Read only the first portion of the file
              preview_content = nil
              File.open(@selected, 'r:UTF-8') do |file|
                lines = []
                line_count = 0
                
                file.each_line do |line|
                  lines << line
                  line_count += 1
                  break if line_count >= preview_lines
                end
                
                preview_content = lines.join
              end
              
              if preview_content && preview_content.valid_encoding?
                # Show preview with truncation notice
                @pR.say("=== Showing first #{preview_lines} lines of large file (#{(file_size / 1024.0 / 1024.0).round(1)}MB) ===\n\n")
                
                if @batuse
                  # Write temp file for bat to process
                  require 'tempfile'
                  temp = Tempfile.new(['rtfm_preview', File.extname(@selected)])
                  temp.write(preview_content)
                  temp.close
                  
                  # Try bat with robust fallback
                  bat_cmd = "#{@bat} -O ansi --force --line-numbers #{Shellwords.escape(temp.path)}"
                  bat_output = command(bat_cmd, timeout: 10)
                  if bat_output.empty?
                    # Bat failed or returned empty - show plain text
                    @pR.say(preview_content)
                  else
                    @pR.say(bat_output)
                  end
                  temp.unlink
                else
                  @pR.say(preview_content)
                end
                
                @pR.say("\n\n=== File truncated for preview ===")
              else
                @pR.say("File appears to be binary or has invalid encoding")
              end
            else
              # Small files - read entirely as before
              text = File.read(@selected).force_encoding('UTF-8') rescue ''
              if text.valid_encoding?
                if @batuse
                  # Try bat first, with robust fallback
                  bat_cmd = "#{@bat} -O ansi --force --line-numbers #{Shellwords.escape(@selected)}"
                  bat_output = command(bat_cmd, timeout: 10)
                  if bat_output.empty?
                    # Bat failed or returned empty - try plain cat
                    showcommand("cat #{Shellwords.escape(@selected)}")
                  else
                    @pR.say(bat_output)
                  end
                else
                  showcommand("cat #{Shellwords.escape(@selected)}")
                end
              else
                @pR.say("No preview available for #{@selected}")
              end
            end
          rescue => e
            @pR.say("Error previewing file: #{e}")
          end
        end
      end
    end
  rescue StandardError => e
    errormsg("⚠ Error while previewing #{@selected}", e)
  end
  @pR.update = false
end

def showimage(image) # SHOW THE SELECTED IMAGE IN THE RIGHT WINDOW {{{2
  # Pass 'clear' to clear the window for previous image
  return unless @showimage

  begin
    terminfo    = `xwininfo -id $(xdotool getactivewindow 2>/dev/null) 2>/dev/null`
    term_w      = terminfo.match(/Width: (\d+)/)[1].to_i
    term_h      = terminfo.match(/Height: (\d+)/)[1].to_i
    char_w      = term_w / @w
    char_h      = term_h / @h
    img_x       = char_w * @pR.x
    img_y       = char_h * 2
    img_max_w   = char_w * (@pR.w - 2)
    img_max_h   = char_h * (@pR.h - 1)
    if image == 'clear'
      `clear`
      img_x     -= char_w
      img_max_w += char_w + 2
      img_max_h += 2
      `echo "6;#{img_x};#{img_y};#{img_max_w};#{img_max_h};\n4;\n3;" | #{@imgdisplay} 2>/dev/null`
      @current_image_path = nil  # Clear tracking when image is cleared
    else
      # Use the already-escaped image for shell commands
      dimensions = `identify -format "%wx%h" #{image} 2>/dev/null`
      img_w, img_h = dimensions.split('x').map(&:to_i)

      # Fix: Use simultaneous scaling to prevent overflow
      if img_w > img_max_w || img_h > img_max_h
        scale_w = img_max_w.to_f / img_w
        scale_h = img_max_h.to_f / img_h
        scale = [scale_w, scale_h].min

        img_w = (img_w * scale).to_i
        img_h = (img_h * scale).to_i
      end

      # Fix: Unescape the filename for w3mimgdisplay protocol, then quote it
      unescaped = image.gsub(/\\(.)/, '\1')  # Remove shell escaping
      `echo "0;1;#{img_x};#{img_y};#{img_w};#{img_h};;;;;\"#{unescaped}\"\n4;\n3;" | #{@imgdisplay} 2>/dev/null`
      @current_image_path = unescaped  # Track currently displayed image
    end
  rescue
    @pR.text = 'Error showing image'
  end
end

def marks_info # SHOW MARKS IN RIGHT WINDOW {{{2
  @marks = @marks.sort.to_h
  info = "Directory Marks".b.fg(156) + "\n"
  info << "=" * 50 + "\n\n"
  
  if @marks.empty?
    info << "No marks set".fg(240) + "\n\n"
    info << "Use 'm' followed by a letter to set a mark\n".fg(249)
    info << "Use " + "'" + " followed by a letter to jump to mark".fg(249)
  else
    info << "Current marks:".fg(226) + "\n\n"
    @marks.each do |mark, dir|
      # Color special marks differently
      mark_color = case mark
                   when "'" then 196  # Red for 'latest' mark
                   when /[0-9]/ then 220  # Yellow for number marks
                   else 156  # Green for letter marks
                   end
      
      # Truncate long paths for better display
      display_dir = dir.length > 45 ? "..." + dir[-42..-1] : dir
      info << sprintf("  %s  →  %s\n", mark.fg(mark_color).b, display_dir.fg(249))
      
      # Add spacing after special marks
      info << "\n" if mark == "'"
      info << "\n" if mark == '5'
    end
    info << "\n" + "Press " + "'" + " + letter to jump".fg(240)
  end
  @pR.say(info)
end

def tagged_info # SHOW THE LIST OF TAGGED ITEMS IN @pR {{{2
  info = "Tagged Items".b.fg(204) + "\n"
  info << "=" * 50 + "\n\n"
  
  # Summary information
  size_mb = (@tagsize.to_f / 1_000_000).round(2)
  info << "Summary:\n".fg(226)
  info << sprintf("  %-12s %d\n", "Items:", @tagged.size)
  info << sprintf("  %-12s %.2f MB\n", "Total size:", size_mb)
  info << "\n"
  
  if @tagged.empty?
    info << "No items tagged\n".fg(240)
    info << "\nUse " + "SPACE".fg(156) + " to tag/untag items\n".fg(249)
  else
    info << "Tagged files:\n".fg(226)
    @tagged.each_with_index do |item, i|
      # Show just filename for long paths
      display_name = File.basename(item)
      full_path = item.length > 50 ? "..." + item[-47..-1] : item
      
      # Color based on file type
      color = File.directory?(item) ? 156 : 249
      info << sprintf("  %2d. %s\n", i + 1, display_name.fg(color))
      
      # Show full path on separate line if truncated
      if item.length > 50
        info << sprintf("      %s\n", full_path.fg(240))
      end
    end
  end
  
  info << "\n" + "Currently selected:\n".fg(226)
  selected_name = File.basename(@selected)
  selected_color = File.directory?(@selected) ? 156 : 249
  info << "  \u2192 " + selected_name.fg(selected_color).b + "\n"
  if @selected.length > 50
    info << "    " + @selected.fg(240) + "\n"
  end
  
  @pR.say(info)
end

# MAIN PROGRAM {{{1
# Initialize rcurses (required for rcurses 6.0.0+)
Rcurses.init!

## Get terminal size {{{2
@h, @w = IO.console.winsize

## Create panes {{{2
# rubocop:disable Naming/VariableName
# p = Pane.new(        x,  y,              width, height,  fg,           bg)
@p0 = Pane.new(        1,  1,                 @w,     @h,   0,            0)
@pT = Pane.new(        1,  1,                 @w,      1,   0,    @topcolor)
@pTab = Pane.new(      1,  1,                 @w,      1, 255,          238)  # Tab overlay pane (same coords as @pT)
@pB = Pane.new(        1, @h,                 @w,      1, 252, @bottomcolor)
# Create panes based on mode
if @dual_pane
  # Dual-pane layout: two directory panes + one preview pane
  left_width = (@w - 6) / 3  # Each directory pane gets 1/3 of width
  @pLeft = Pane.new(        2,  3,        left_width, @h - 4, 226,            0)  # Yellow fg for active
  @pRight = Pane.new(left_width + 4,  3,  left_width, @h - 4,  15,            0)  # Normal fg for inactive
  @pPreview = Pane.new(2*left_width + 6,  3, @w - 2*left_width - 6, @h - 4, 255,  0)
  @pLeft.border = true
  @pRight.border = true
  @pPreview.border = false
  @pL = @pLeft
  @pR = @pPreview
else
  # Single-pane layout (default)
  @pL = Pane.new(        2,  3, (@w - 4)*@width/10, @h - 4,  15,            0)
  @pR = Pane.new(@pL.w + 4,  3,     @w - @pL.w - 4, @h - 4, 255,            0)
end
## Create special panes
@pCmd    = Pane.new( 1, @h, @w, 1, 255,    @cmdcolor)
@pSearch = Pane.new( 1, @h, @w, 1, 255, @searchcolor)
@pRuby   = Pane.new( 1, @h, @w, 1, 255,   @rubycolor)
@pAI     = Pane.new( 1, @h, @w, 1, 255,     @aicolor)
@pSsh    = Pane.new( 1, @h, @w, 1, 255,     @sshcolor)
# rubocop:enable Naming/VariableName

## Set pane properties {{{2
@pTab.update    = true
@pT.update      = true
@pL.update      = true
@pR.update      = true
@pB.update      = true
@pSearch.record = true
@pCmd.record    = true
@pCmd.history   = @history
@pRuby.record   = true
@pRuby.history  = @rubyhistory
@pAI.record     = true
@pAI.history    = @aihistory
@pSsh.record    = true
@pSsh.history   = @sshhistory

# Report plugin errors {{{2
@pR.say("Plugin load errors:\n" + @plugin_errors.join("\n").fg(196)) if @plugin_errors.any? 

## Set the borders {{{2
setborder

## Catch change in terminal resize, redraw {{{2
Signal.trap('WINCH') do 
  @h, @w = IO.console.winsize
  @pT.update = @pL.update = @pR.update = @pB.update = true
  refresh
  render
end


## One-time flush {{{2
$stdin.getc while $stdin.wait_readable(0)

## THE LOOP {{{2
loop do
  @dir_old = Dir.pwd
  
  # redraw, but ignore TTY‐focus errors
  begin
    render
  rescue Errno::EIO
    # Note: rcurses 4.9.0+ has enhanced error handling, reducing need for this
  end
  # read key, but ignore TTY-focus errors
  begin
    getkey
  rescue Errno::EIO
    # Note: rcurses 4.9.0+ has enhanced error handling, reducing need for this
  end
  # If cwd was deleted externally, jump home
  begin
    Dir.pwd
  rescue
    Dir.chdir
  end
  # restore index if we cd'd
  if Dir.pwd != @dir_old
    @index = @directory[Dir.pwd] || 0
  end
  unless @navi.empty?
    command(@navi)
    @navi = ''
  end
  system("printf \"\033]0;RTFM: #{Dir.pwd}\007\"") # Update window title
rescue StandardError => e
  errormsg('⚠ Internal Error', e)
end

# vim: set sw=2 sts=2 et fdm=marker fdn=2 fcs=fold\:\ :
