rtfm-filemanager 7.5.3 → 8.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/rtfm CHANGED
@@ -18,7 +18,7 @@
18
18
  # get a great understanding of the code itself by simply sending
19
19
  # or pasting this whole file into you favorite AI for coding with
20
20
  # a prompt like this: "Help me understand every part of this code".
21
- @version = '7.5.3' # Fix stale display on external file removal
21
+ @version = '8.0.1' # Archive browsing, async file ops, scrollable diff viewer
22
22
 
23
23
  # SAVE & STORE TERMINAL {{{1
24
24
  ORIG_STTY = `stty -g`.chomp
@@ -206,7 +206,7 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
206
206
  > = Follow symlink to the directory where the target resides
207
207
  Ctrl-r = Show recently accessed files and directories (press number to jump)
208
208
  Ctrl-e = Browse remote directories via SSH/SFTP (toggle remote mode)
209
- In remote mode: d=download, u=upload, s=shell, →=file info, ←=parent dir
209
+ In remote mode: D=download, u=upload, s=shell, →=file info, ←=parent dir
210
210
  SSH connections support comments: user@host:/path # Comment
211
211
 
212
212
  DIRECTORY VIEWS
@@ -236,7 +236,7 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
236
236
  P = PUT (move) tagged items here
237
237
  c = Change/rename selected (adds command to bottom window)
238
238
  E = Bulk rename tagged files using patterns (regex, templates, case conversion)
239
- X = Compare two tagged files (text diff or binary comparison)
239
+ X = Compare two tagged files (scrollable diff with j/k, s=side-by-side, q=close)
240
240
  s = Create symlink to tagged items here
241
241
  d = Delete selected item and tagged items. Confirm with 'y'.
242
242
  Moves items to trash directory (~/.rtfm/trash/) if @trash = true
@@ -267,6 +267,10 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
267
267
  (fuzzy file finder must be installed https://github.com/junegunn/fzf)
268
268
 
269
269
  ARCHIVES
270
+ ENTER = Browse inside archive (zip, tar, gz, bz2, xz, rar, 7z) - virtual browsing
271
+ Press LEFT at archive root to exit back to normal browsing
272
+ x = Open archive with external program (bypass archive browsing)
273
+ Inside an archive: d=delete, D=extract, p=add files, t=tag
270
274
  z = Extract tagged zipped archive to current directory
271
275
  Z = Create zipped archive from tagged files/directories
272
276
 
@@ -587,6 +591,12 @@ $stdin.set_encoding(Encoding::UTF_8)
587
591
  @max_undo_levels = 20 # Maximum number of undo levels to keep
588
592
  @undo_enabled = true # Enable/disable undo system
589
593
 
594
+ ## Async file operations
595
+ @file_op_thread = nil # Current background operation thread
596
+ @file_op_progress = nil # Progress message for bottom pane display
597
+ @file_op_complete = false # Flag when operation finishes
598
+ @file_op_result = nil # Result message when operation finishes
599
+
590
600
  ## Recently accessed files/directories
591
601
  @recent_files = [] # Last 50 accessed files
592
602
  @recent_dirs = [] # Last 20 accessed directories
@@ -601,6 +611,15 @@ $stdin.set_encoding(Encoding::UTF_8)
601
611
  @remote_path = '~' # Current remote directory path
602
612
  @remote_files_cache = [] # Cache of current remote directory files with full info
603
613
 
614
+ ## Archive browsing variables
615
+ @archive_mode = false # Whether currently browsing inside an archive
616
+ @archive_path = nil # Full path to the archive file on disk
617
+ @archive_current_dir = '' # Current virtual directory within the archive
618
+ @archive_entries = [] # Parsed entries: [{name:, type:, size:, full_path:}]
619
+ @archive_files_cache = [] # Filtered entries for current virtual directory
620
+ @archive_origin_dir = nil # Directory we were in before entering archive mode
621
+ @archive_origin_tagged = [] # Tagged files from before entering archive mode
622
+
604
623
  # TAB MANAGEMENT FUNCTIONS {{{1
605
624
  def create_tab(directory = Dir.pwd, name = nil) # {{{2
606
625
  @tab_counter += 1
@@ -857,6 +876,7 @@ preview_specs = {
857
876
  }
858
877
  @imagefile ||= /\.(?:png|jpe?g|bmp|gif|webp|tiff?|svg)$/i
859
878
  @pdffile ||= /\.pdf$/i
879
+ ARCHIVE_RE = /\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2?|tar\.xz|txz|tar\.zst|rar|7z)$/i
860
880
  # rubocop:enable Style/StringLiterals
861
881
 
862
882
  # USER PLUGINS {{{1
@@ -1149,6 +1169,12 @@ def refresh_all # {{{3
1149
1169
  @remote_files_cache = []
1150
1170
  @pL.update = true
1151
1171
  end
1172
+ # Refresh archive listing if in archive mode
1173
+ if @archive_mode && @archive_path
1174
+ @archive_files_cache = []
1175
+ @archive_entries = parse_archive_listing(@archive_path)
1176
+ @pL.update = true
1177
+ end
1152
1178
  refresh
1153
1179
  end
1154
1180
 
@@ -1235,8 +1261,8 @@ end
1235
1261
  def move_down # {{{3
1236
1262
  @index = @index >= @max_index ? @min_index : @index + 1
1237
1263
  @pL.update = true
1238
- # In remote mode, only update bottom pane (for file attributes)
1239
- if @remote_mode
1264
+ # In remote/archive mode, only update bottom pane (for file attributes)
1265
+ if @remote_mode || @archive_mode
1240
1266
  @pB.update = true
1241
1267
  else
1242
1268
  @pR.update = @pB.update = true
@@ -1246,8 +1272,8 @@ end
1246
1272
  def move_up # {{{3
1247
1273
  @index = @index <= @min_index ? @max_index : @index - 1
1248
1274
  @pL.update = true
1249
- # In remote mode, only update bottom pane (for file attributes)
1250
- if @remote_mode
1275
+ # In remote/archive mode, only update bottom pane (for file attributes)
1276
+ if @remote_mode || @archive_mode
1251
1277
  @pB.update = true
1252
1278
  else
1253
1279
  @pR.update = @pB.update = true
@@ -1256,7 +1282,18 @@ end
1256
1282
 
1257
1283
  def move_left # {{{3
1258
1284
  clear_image
1259
- if @remote_mode
1285
+ if @archive_mode
1286
+ if @archive_current_dir == '' || @archive_current_dir == '/'
1287
+ exit_archive_mode
1288
+ else
1289
+ @archive_current_dir = File.dirname(@archive_current_dir)
1290
+ @archive_current_dir = '' if @archive_current_dir == '.'
1291
+ @archive_files_cache = []
1292
+ @index = 0
1293
+ @pL.update = @pR.update = @pB.update = true
1294
+ end
1295
+ return
1296
+ elsif @remote_mode
1260
1297
  # Remote mode - go to parent directory
1261
1298
  return if @remote_path == '/' || @remote_path == '~'
1262
1299
 
@@ -1287,7 +1324,20 @@ end
1287
1324
  # dirlist_simple function removed - was only for dual-pane navigation
1288
1325
 
1289
1326
  def move_right # {{{3
1290
- if @remote_mode
1327
+ if @archive_mode
1328
+ return unless @files && @files[@index] && @archive_files_cache[@index]
1329
+ selected_entry = @archive_files_cache[@index]
1330
+ if selected_entry[:type] == 'directory'
1331
+ clear_image
1332
+ @archive_current_dir = selected_entry[:full_path]
1333
+ @archive_files_cache = []
1334
+ @index = 0
1335
+ @pL.update = @pR.update = @pB.update = true
1336
+ else
1337
+ show_archive_file_info(selected_entry)
1338
+ end
1339
+ return
1340
+ elsif @remote_mode
1291
1341
  # Remote mode - enter directory or perform action on file
1292
1342
  return unless @files && @files[@index] && @remote_files_cache[@index]
1293
1343
 
@@ -1326,8 +1376,7 @@ def page_down # {{{3
1326
1376
  @index += @pL.h - 2
1327
1377
  @index = @max_index if @index > @max_index
1328
1378
  @pL.update = true
1329
- # In remote mode, only update bottom pane (for file attributes)
1330
- if @remote_mode
1379
+ if @remote_mode || @archive_mode
1331
1380
  @pB.update = true
1332
1381
  else
1333
1382
  @pR.update = @pB.update = true
@@ -1338,8 +1387,7 @@ def page_up # {{{3
1338
1387
  @index -= @pL.h - 2
1339
1388
  @index = @min_index if @index < @min_index
1340
1389
  @pL.update = true
1341
- # In remote mode, only update bottom pane (for file attributes)
1342
- if @remote_mode
1390
+ if @remote_mode || @archive_mode
1343
1391
  @pB.update = true
1344
1392
  else
1345
1393
  @pR.update = @pB.update = true
@@ -1349,8 +1397,7 @@ end
1349
1397
  def go_last # {{{3
1350
1398
  @index = @max_index
1351
1399
  @pL.update = true
1352
- # In remote mode, only update bottom pane (for file attributes)
1353
- if @remote_mode
1400
+ if @remote_mode || @archive_mode
1354
1401
  @pB.update = true
1355
1402
  else
1356
1403
  @pR.update = @pB.update = true
@@ -1360,8 +1407,7 @@ end
1360
1407
  def go_first # {{{3
1361
1408
  @index = @min_index
1362
1409
  @pL.update = true
1363
- # In remote mode, only update bottom pane (for file attributes)
1364
- if @remote_mode
1410
+ if @remote_mode || @archive_mode
1365
1411
  @pB.update = true
1366
1412
  else
1367
1413
  @pR.update = @pB.update = true
@@ -1584,6 +1630,18 @@ def tag_current # {{{3
1584
1630
 
1585
1631
  @pPreview.update = true if @pPreview
1586
1632
  end
1633
+ elsif @archive_mode
1634
+ # Archive mode: tag using archive_path:entry_path format
1635
+ entry = @archive_files_cache[@index]
1636
+ return unless entry
1637
+ item = "#{@archive_path}:#{entry[:full_path]}"
1638
+ if @tagged.include?(item)
1639
+ @tagged.delete(item); @tagsize -= entry[:size] rescue 0
1640
+ else
1641
+ @tagged.push(item); @tagsize += entry[:size] rescue 0
1642
+ end
1643
+ @index = [@index + 1, (@files.size - 1)].min
1644
+ @pL.update = true
1587
1645
  else
1588
1646
  # Original single-pane logic
1589
1647
  item = @selected
@@ -1595,7 +1653,7 @@ def tag_current # {{{3
1595
1653
  @index = [@index + 1, (@files.size - 1)].min
1596
1654
  @pL.update = true
1597
1655
  end
1598
-
1656
+
1599
1657
  @pB.say(" Tagged #{@tagged.size} files (#{(@tagsize.to_f / 1_000_000).round(2)}MB)".fg(204))
1600
1658
  @pB.update = false; @pR.update = true
1601
1659
  end
@@ -2292,48 +2350,47 @@ end
2292
2350
  def show_file_comparison(file1, file2) # {{{3
2293
2351
  basename1 = File.basename(file1)
2294
2352
  basename2 = File.basename(file2)
2295
-
2296
- text = "File Comparison\n".b.fg(156)
2297
- text << "=" * 50 + "\n\n"
2298
- text << sprintf("%-25s vs %s\n", basename1.fg(156), basename2.fg(156))
2299
- text << "=" * 50 + "\n\n"
2300
-
2353
+
2354
+ header = "File Comparison\n".b.fg(156)
2355
+ header << "=" * 50 + "\n\n"
2356
+ header << sprintf("%-25s vs %s\n", basename1.fg(156), basename2.fg(156))
2357
+ header << "=" * 50 + "\n\n"
2358
+
2301
2359
  # Basic file info comparison
2302
2360
  stat1 = File.stat(file1)
2303
2361
  stat2 = File.stat(file2)
2304
-
2305
- text << "File Information:\n".fg(226)
2306
- text << sprintf(" %-20s %-25s %s\n", "Size:", format_size_simple(stat1.size), format_size_simple(stat2.size))
2307
- text << sprintf(" %-20s %-25s %s\n", "Modified:", stat1.mtime.strftime("%Y-%m-%d %H:%M"), stat2.mtime.strftime("%Y-%m-%d %H:%M"))
2308
- text << sprintf(" %-20s %-25s %s\n", "Type:", File.ftype(file1), File.ftype(file2))
2309
-
2362
+
2363
+ header << "File Information:\n".fg(226)
2364
+ header << sprintf(" %-20s %-25s %s\n", "Size:", format_size_simple(stat1.size), format_size_simple(stat2.size))
2365
+ header << sprintf(" %-20s %-25s %s\n", "Modified:", stat1.mtime.strftime("%Y-%m-%d %H:%M"), stat2.mtime.strftime("%Y-%m-%d %H:%M"))
2366
+ header << sprintf(" %-20s %-25s %s\n", "Type:", File.ftype(file1), File.ftype(file2))
2367
+
2310
2368
  # Check if files are identical
2311
2369
  if stat1.size == stat2.size && files_identical?(file1, file2)
2312
- text << "\n"
2313
- text << "Files are identical! ✓".fg(156).b
2314
- @pR.say(text)
2370
+ header << "\n"
2371
+ header << "Files are identical!\n".fg(156).b
2372
+ header << "\nPress any key to close...".fg(240)
2373
+ @pR.say(header)
2315
2374
  @pR.update = false
2316
2375
  getchr
2317
2376
  @pR.update = true
2318
2377
  return
2319
2378
  end
2320
-
2321
- text << "\n"
2322
-
2379
+
2380
+ header << "\n"
2381
+
2323
2382
  # Determine comparison type
2324
2383
  if binary_file?(file1) || binary_file?(file2)
2325
- text << show_binary_comparison(file1, file2, stat1, stat2)
2384
+ text = header + show_binary_comparison(file1, file2, stat1, stat2)
2385
+ text << "\nPress any key to close...".fg(240)
2386
+ @pR.say(text)
2387
+ @pR.update = false
2388
+ getchr
2389
+ @pR.update = true
2326
2390
  else
2327
- text << show_text_comparison(file1, file2)
2391
+ # Scrollable text diff viewer
2392
+ scroll_diff_viewer(header, file1, file2)
2328
2393
  end
2329
-
2330
- text << "\n"
2331
- text << "Press any key to close...".fg(240)
2332
-
2333
- @pR.say(text)
2334
- @pR.update = false
2335
- getchr
2336
- @pR.update = true
2337
2394
  end
2338
2395
 
2339
2396
  def show_binary_comparison(file1, file2, stat1, stat2) # {{{3
@@ -2383,16 +2440,14 @@ def show_binary_comparison(file1, file2, stat1, stat2) # {{{3
2383
2440
  end
2384
2441
 
2385
2442
  def show_text_comparison(file1, file2) # {{{3
2386
- text = "Text File Comparison:\n".fg(226)
2387
-
2388
2443
  begin
2389
2444
  lines1 = File.readlines(file1, chomp: true)
2390
2445
  lines2 = File.readlines(file2, chomp: true)
2391
2446
  rescue StandardError => e
2392
2447
  return "Error reading files: #{e.message}\n".fg(196)
2393
2448
  end
2394
-
2395
- # Line count comparison
2449
+
2450
+ text = "Text File Comparison:\n".fg(226)
2396
2451
  if lines1.length == lines2.length
2397
2452
  text << " Same line count: #{lines1.length}\n".fg(156)
2398
2453
  else
@@ -2400,31 +2455,131 @@ def show_text_comparison(file1, file2) # {{{3
2400
2455
  sign = diff > 0 ? "+" : ""
2401
2456
  text << " Line count: #{lines1.length} vs #{lines2.length} (#{sign}#{diff})\n".fg(diff > 0 ? 196 : 156)
2402
2457
  end
2403
-
2404
- # Generate and show diff
2458
+
2405
2459
  diff_lines = generate_unified_diff(lines1, lines2, File.basename(file1), File.basename(file2))
2406
-
2460
+
2407
2461
  if diff_lines.empty?
2408
- text << " Content: Identical ✓\n".fg(156)
2462
+ text << " Content: Identical\n".fg(156)
2409
2463
  else
2410
2464
  text << "\n Differences (unified diff):\n".fg(226)
2411
-
2412
- # Show first 15 lines of diff
2413
- diff_lines.first(15).each do |line|
2465
+ diff_lines.each do |line|
2414
2466
  color = case line[0]
2415
- when '+' then 156 # Green for additions
2416
- when '-' then 196 # Red for deletions
2417
- when '@' then 226 # Yellow for headers
2418
- else 240 # Gray for context
2467
+ when '+' then 156
2468
+ when '-' then 196
2469
+ when '@' then 226
2470
+ else 240
2419
2471
  end
2420
2472
  text << " #{line}\n".fg(color)
2421
2473
  end
2422
-
2423
- if diff_lines.length > 15
2424
- text << " ... and #{diff_lines.length - 15} more lines\n".fg(240)
2474
+ end
2475
+ text
2476
+ end
2477
+
2478
+ def scroll_diff_viewer(header, file1, file2) # {{{3
2479
+ begin
2480
+ lines1 = File.readlines(file1, chomp: true)
2481
+ lines2 = File.readlines(file2, chomp: true)
2482
+ rescue StandardError => e
2483
+ @pR.say(header + "Error reading files: #{e.message}\n".fg(196))
2484
+ @pR.update = false
2485
+ getchr
2486
+ @pR.update = true
2487
+ return
2488
+ end
2489
+
2490
+ diff_lines = generate_unified_diff(lines1, lines2, File.basename(file1), File.basename(file2))
2491
+
2492
+ if diff_lines.empty?
2493
+ @pR.say(header + " Content: Identical\n".fg(156) + "\nPress any key to close...".fg(240))
2494
+ @pR.update = false
2495
+ getchr
2496
+ @pR.update = true
2497
+ return
2498
+ end
2499
+
2500
+ # Count header lines (approximate)
2501
+ header_lines = header.count("\n") + 2
2502
+ offset = 0
2503
+ side_by_side = false
2504
+
2505
+ loop do
2506
+ page_height = @pR.h - header_lines - 3
2507
+ page_height = [page_height, 5].max
2508
+
2509
+ text = header.dup
2510
+
2511
+ if side_by_side
2512
+ text << render_side_by_side(lines1, lines2, File.basename(file1), File.basename(file2), offset, page_height)
2513
+ total = [lines1.size, lines2.size].max
2514
+ else
2515
+ text << "\n Differences (unified diff):\n".fg(226)
2516
+ visible = diff_lines[offset, page_height] || []
2517
+ visible.each do |line|
2518
+ color = case line[0]
2519
+ when '+' then 156
2520
+ when '-' then 196
2521
+ when '@' then 226
2522
+ else 240
2523
+ end
2524
+ text << " #{line}\n".fg(color)
2525
+ end
2526
+ total = diff_lines.size
2527
+ end
2528
+
2529
+ max_offset = [total - page_height, 0].max
2530
+ shown_end = [offset + page_height, total].min
2531
+ mode_label = side_by_side ? "side-by-side" : "unified"
2532
+ text << "\n j/k=scroll PgUp/PgDn=page s=#{side_by_side ? 'unified' : 'side-by-side'} q=close (#{offset + 1}-#{shown_end}/#{total}) [#{mode_label}]".fg(240)
2533
+
2534
+ @pR.say(text)
2535
+ @pR.update = false
2536
+
2537
+ chr = getchr
2538
+ case chr
2539
+ when 'j', 'DOWN'
2540
+ offset = [offset + 1, max_offset].min
2541
+ when 'k', 'UP'
2542
+ offset = [offset - 1, 0].max
2543
+ when 'PgDOWN', 'TAB', 'S-RIGHT'
2544
+ offset = [offset + page_height, max_offset].min
2545
+ when 'PgUP', 'S-TAB', 'S-LEFT'
2546
+ offset = [offset - page_height, 0].max
2547
+ when 'S', 's'
2548
+ side_by_side = !side_by_side
2549
+ offset = 0
2550
+ when 'q', 'ESC', 'LEFT', 'h'
2551
+ break
2552
+ when 'HOME', 'g'
2553
+ offset = 0
2554
+ when 'END', 'G'
2555
+ offset = max_offset
2556
+ else
2557
+ break if chr.nil?
2425
2558
  end
2426
2559
  end
2427
-
2560
+
2561
+ @pR.update = true
2562
+ end
2563
+
2564
+ def render_side_by_side(lines1, lines2, name1, name2, offset, page_height) # {{{3
2565
+ half_width = (@pR.w - 7) / 2
2566
+ half_width = [half_width, 10].max
2567
+ text = "\n"
2568
+ text << sprintf(" %-#{half_width}s | %s\n", name1[0, half_width], name2[0, half_width]).fg(226)
2569
+ text << " " + "-" * half_width + "-+-" + "-" * half_width + "\n"
2570
+
2571
+ max_lines = [lines1.size, lines2.size].max
2572
+ end_line = [offset + page_height, max_lines].min
2573
+
2574
+ (offset...end_line).each do |i|
2575
+ l1 = (lines1[i] || '')[0, half_width]
2576
+ l2 = (lines2[i] || '')[0, half_width]
2577
+ same = lines1[i] == lines2[i]
2578
+ color = same ? 240 : (lines1[i].nil? ? 156 : (lines2[i].nil? ? 196 : 226))
2579
+
2580
+ text << sprintf(" %-#{half_width}s | %s\n", l1, l2).fg(color)
2581
+ end
2582
+
2428
2583
  text
2429
2584
  end
2430
2585
 
@@ -2470,7 +2625,7 @@ def generate_unified_diff(lines1, lines2, name1, name2) # {{{3
2470
2625
 
2471
2626
  i1, i2 = temp_i1, temp_i2
2472
2627
 
2473
- break if diff_lines.length > 50 # Prevent huge diffs
2628
+ break if diff_lines.length > 5000 # Safety limit for very large diffs
2474
2629
  end
2475
2630
  end
2476
2631
 
@@ -2589,6 +2744,502 @@ def exit_remote_mode # {{{3
2589
2744
  render
2590
2745
  end
2591
2746
 
2747
+ # ARCHIVE BROWSING {{{2
2748
+ def parse_archive_listing(archive_path) # {{{3
2749
+ escaped = Shellwords.escape(archive_path)
2750
+ entries = []
2751
+ begin
2752
+ raw = case archive_path.downcase
2753
+ when /\.zip$/
2754
+ command("unzip -l #{escaped} 2>/dev/null")
2755
+ when /\.rar$/
2756
+ command("unrar l #{escaped} 2>/dev/null")
2757
+ when /\.7z$/
2758
+ command("7z l #{escaped} 2>/dev/null")
2759
+ when /\.tar\.bz2$|\.tbz2?$/
2760
+ command("tar -tjvf #{escaped} 2>/dev/null")
2761
+ when /\.tar\.xz$|\.txz$/
2762
+ command("tar -tJvf #{escaped} 2>/dev/null")
2763
+ when /\.tar\.zst$/
2764
+ command("tar --zstd -tvf #{escaped} 2>/dev/null")
2765
+ when /\.tar$|\.tar\.gz$|\.tgz$|\.gz$/
2766
+ command("tar -tzvf #{escaped} 2>/dev/null")
2767
+ else
2768
+ command("tar -tzvf #{escaped} 2>/dev/null")
2769
+ end
2770
+
2771
+ case archive_path.downcase
2772
+ when /\.zip$/
2773
+ parse_zip_listing(raw, entries)
2774
+ when /\.rar$/
2775
+ parse_rar_listing(raw, entries)
2776
+ when /\.7z$/
2777
+ parse_7z_listing(raw, entries)
2778
+ else
2779
+ parse_tar_listing(raw, entries)
2780
+ end
2781
+ rescue => e
2782
+ @pB.say("Error reading archive: #{e.message}".fg(196))
2783
+ end
2784
+ entries
2785
+ end
2786
+
2787
+ def parse_tar_listing(raw, entries) # {{{3
2788
+ raw.each_line do |line|
2789
+ parts = line.strip.split(/\s+/, 6)
2790
+ next if parts.size < 6
2791
+ perms = parts[0]
2792
+ size = parts[2].to_i
2793
+ raw_path = parts[5]
2794
+ next if raw_path.nil? || raw_path.empty? || raw_path == './' || raw_path == '.'
2795
+ path = raw_path.sub(%r{^\./}, '') # Remove leading ./ for display
2796
+ next if path.empty?
2797
+ is_dir = perms.start_with?('d') || path.end_with?('/')
2798
+ path = path.chomp('/')
2799
+ # Store raw_path (with ./ if present) for tar extract/delete operations
2800
+ entries << { name: File.basename(path), type: is_dir ? 'directory' : 'file',
2801
+ size: size, full_path: path, permissions: perms,
2802
+ raw_path: raw_path.chomp('/') }
2803
+ end
2804
+ end
2805
+
2806
+ def parse_zip_listing(raw, entries) # {{{3
2807
+ in_files = false
2808
+ raw.each_line do |line|
2809
+ if line =~ /^\s*-+\s+-+/
2810
+ in_files = !in_files
2811
+ next
2812
+ end
2813
+ next unless in_files
2814
+ # Format: Length Date Time Name
2815
+ if line =~ /^\s*(\d+)\s+\S+\s+\S+\s+(.+)$/
2816
+ size = $1.to_i
2817
+ path = $2.strip
2818
+ next if path.empty? || path == '.' || path == './'
2819
+ is_dir = path.end_with?('/')
2820
+ path = path.chomp('/')
2821
+ next if path.empty?
2822
+ entries << { name: File.basename(path), type: is_dir ? 'directory' : 'file',
2823
+ size: size, full_path: path, permissions: '' }
2824
+ end
2825
+ end
2826
+ end
2827
+
2828
+ def parse_rar_listing(raw, entries) # {{{3
2829
+ in_files = false
2830
+ raw.each_line do |line|
2831
+ if line =~ /^\s*-+/
2832
+ in_files = !in_files
2833
+ next
2834
+ end
2835
+ next unless in_files
2836
+ parts = line.strip.split(/\s+/)
2837
+ next if parts.size < 5
2838
+ # unrar l format: Attributes Size Packed Ratio Date Time Name
2839
+ perms = parts[0]
2840
+ size = parts[1].to_i
2841
+ path = parts[6..].join(' ')
2842
+ next if path.nil? || path.empty?
2843
+ is_dir = perms.include?('D') || perms.include?('d') || path.end_with?('/')
2844
+ path = path.chomp('/')
2845
+ next if path.empty?
2846
+ entries << { name: File.basename(path), type: is_dir ? 'directory' : 'file',
2847
+ size: size, full_path: path, permissions: perms }
2848
+ end
2849
+ end
2850
+
2851
+ def parse_7z_listing(raw, entries) # {{{3
2852
+ in_files = false
2853
+ raw.each_line do |line|
2854
+ if line =~ /^-{4,}/
2855
+ in_files = !in_files
2856
+ next
2857
+ end
2858
+ next unless in_files
2859
+ # 7z l format: Date Time Attr Size Compressed Name
2860
+ parts = line.strip.split(/\s+/, 6)
2861
+ next if parts.size < 6
2862
+ attr = parts[2]
2863
+ size = parts[3].to_i
2864
+ path = parts[5]
2865
+ next if path.nil? || path.empty?
2866
+ is_dir = attr.include?('D')
2867
+ path = path.chomp('/')
2868
+ next if path.empty?
2869
+ entries << { name: File.basename(path), type: is_dir ? 'directory' : 'file',
2870
+ size: size, full_path: path, permissions: attr }
2871
+ end
2872
+ end
2873
+
2874
+ def archive_entries_for_dir(virtual_dir) # {{{3
2875
+ # Collect implicit directories from paths
2876
+ all_dirs = Set.new
2877
+ @archive_entries.each do |entry|
2878
+ path = entry[:full_path]
2879
+ # Add all parent directories
2880
+ parts = path.split('/')
2881
+ (1...parts.size).each do |i|
2882
+ all_dirs << parts[0, i].join('/')
2883
+ end
2884
+ end
2885
+
2886
+ prefix = virtual_dir.empty? ? '' : "#{virtual_dir}/"
2887
+ depth = virtual_dir.empty? ? 0 : virtual_dir.count('/') + 1
2888
+ seen = Set.new
2889
+ result = []
2890
+
2891
+ # Add direct children from entries
2892
+ @archive_entries.each do |entry|
2893
+ path = entry[:full_path]
2894
+ next unless virtual_dir.empty? ? true : path.start_with?(prefix)
2895
+
2896
+ parts = path.split('/')
2897
+ next if parts.size <= depth # Skip the directory itself
2898
+
2899
+ child_name = parts[depth]
2900
+ next if seen.include?(child_name)
2901
+ seen << child_name
2902
+
2903
+ child_path = parts[0..depth].join('/')
2904
+ if parts.size == depth + 1
2905
+ # Direct child file or directory
2906
+ result << entry.merge(name: child_name)
2907
+ else
2908
+ # Implicit subdirectory
2909
+ result << { name: child_name, type: 'directory', size: 0,
2910
+ full_path: child_path, permissions: 'drwxr-xr-x' }
2911
+ end
2912
+ end
2913
+
2914
+ # Sort: directories first, then alphabetically
2915
+ result.sort_by { |e| [e[:type] == 'directory' ? 0 : 1, e[:name].downcase] }
2916
+ end
2917
+
2918
+ def enter_archive_mode(archive_path) # {{{3
2919
+ @archive_origin_dir = Dir.pwd
2920
+ @archive_origin_tagged = @tagged.dup
2921
+ @archive_mode = true
2922
+ @archive_path = archive_path
2923
+ @archive_current_dir = ''
2924
+ @index = 0
2925
+ @tagged = []
2926
+ @archive_entries = parse_archive_listing(archive_path)
2927
+ @archive_files_cache = []
2928
+ if @archive_entries.empty?
2929
+ @pB.say("Archive is empty or unreadable: #{File.basename(archive_path)}".fg(196))
2930
+ @archive_mode = false
2931
+ @archive_path = nil
2932
+ @tagged = @archive_origin_tagged
2933
+ return
2934
+ end
2935
+ @pB.say(" ARCHIVE: #{File.basename(archive_path)} (#{@archive_entries.size} entries, LEFT to exit)".fg(226))
2936
+ @pL.update = @pR.update = @pT.update = @pB.update = true
2937
+ dirlist
2938
+ render
2939
+ end
2940
+
2941
+ def exit_archive_mode # {{{3
2942
+ @archive_mode = false
2943
+ @archive_path = nil
2944
+ @archive_current_dir = ''
2945
+ @archive_entries = []
2946
+ @archive_files_cache = []
2947
+ @tagged = @archive_origin_tagged || []
2948
+ @archive_origin_tagged = []
2949
+ @archive_origin_dir = nil
2950
+ @index = 0
2951
+ @pB.say("Returned to local browsing".fg(156))
2952
+ @pL.update = @pR.update = @pT.update = @pB.update = true
2953
+ dirlist
2954
+ render
2955
+ end
2956
+
2957
+ def show_archive_file_info(entry) # {{{3
2958
+ info_text = "Archive File Information\n".b.fg(226)
2959
+ info_text << "=" * 40 + "\n\n"
2960
+ info_text << "Archive: #{File.basename(@archive_path)}\n".fg(255)
2961
+ info_text << "Name: #{entry[:name]}\n".fg(255)
2962
+ info_text << "Type: #{entry[:type].capitalize}\n".fg(255)
2963
+ info_text << "Size: #{format_size_simple(entry[:size])}\n".fg(255)
2964
+ info_text << "Path: #{entry[:full_path]}\n".fg(240)
2965
+ info_text << "Permissions: #{entry[:permissions]}\n".fg(255) unless entry[:permissions].to_s.empty?
2966
+ info_text << "\n"
2967
+ info_text << "Actions:\n".fg(226)
2968
+ if entry[:type] == 'directory'
2969
+ info_text << " Enter = Navigate into directory\n".fg(240)
2970
+ end
2971
+ info_text << " d = Delete from archive\n".fg(240)
2972
+ info_text << " D = Extract to origin directory\n".fg(240)
2973
+ info_text << " p = Add local files into archive\n".fg(240)
2974
+ info_text << " t = Tag/untag for bulk operations\n".fg(240)
2975
+ info_text << " LEFT = Go to parent / exit archive\n".fg(240)
2976
+ @pR.say(info_text)
2977
+ @pR.update = false
2978
+ end
2979
+
2980
+ def archive_extract_entries # {{{3
2981
+ return unless @archive_mode && @archive_path
2982
+
2983
+ # Get entries to extract: tagged archive entries, or selected
2984
+ entries = archive_tagged_or_selected
2985
+ return if entries.empty?
2986
+
2987
+ dest = @archive_origin_dir || Dir.pwd
2988
+ escaped_archive = Shellwords.escape(@archive_path)
2989
+ entry_paths = entries.map { |e| e[:full_path] }
2990
+ # For tar archives, use raw_path (preserves ./ prefix) for correct extraction
2991
+ tar_paths = entries.map { |e| e[:raw_path] || e[:full_path] }
2992
+
2993
+ @pB.say(" Extracting #{entries.size} item(s) to #{dest}...".fg(226))
2994
+
2995
+ success = case @archive_path.downcase
2996
+ when /\.zip$/
2997
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
2998
+ system("unzip -o #{escaped_archive} #{escaped_paths} -d #{Shellwords.escape(dest)} >/dev/null 2>&1")
2999
+ when /\.rar$/
3000
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
3001
+ system("unrar x -o+ #{escaped_archive} #{escaped_paths} #{Shellwords.escape(dest)}/ >/dev/null 2>&1")
3002
+ when /\.7z$/
3003
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
3004
+ system("7z x #{escaped_archive} #{escaped_paths} -o#{Shellwords.escape(dest)} -y >/dev/null 2>&1")
3005
+ else
3006
+ # tar variants — use raw_path to match archive entries exactly
3007
+ escaped_paths = tar_paths.map { |p| Shellwords.escape(p) }.join(' ')
3008
+ tar_flag = tar_decompress_flag(@archive_path)
3009
+ system("tar x#{tar_flag}f #{escaped_archive} -C #{Shellwords.escape(dest)} #{escaped_paths} 2>/dev/null")
3010
+ end
3011
+
3012
+ if success
3013
+ @pB.say(" Extracted #{entries.size} item(s) to #{dest}".fg(156))
3014
+ else
3015
+ @pB.say(" Extraction failed".fg(196))
3016
+ end
3017
+ @tagged = []
3018
+ @pL.update = @pR.update = true
3019
+ end
3020
+
3021
+ def archive_delete_entries # {{{3
3022
+ return unless @archive_mode && @archive_path
3023
+
3024
+ entries = archive_tagged_or_selected
3025
+ return if entries.empty?
3026
+
3027
+ clear_image
3028
+ names = entries.map { |e| e[:name] }
3029
+ warning = "\nDelete from Archive\n".b.fg(196)
3030
+ warning << "=" * 40 + "\n\n"
3031
+ warning << "Archive: #{File.basename(@archive_path)}\n".fg(255)
3032
+ warning << "\nItems to delete:\n".fg(226)
3033
+ entries.first(10).each { |e| warning << " #{e[:full_path]}\n".fg(255) }
3034
+ warning << " ... and #{entries.size - 10} more\n".fg(240) if entries.size > 10
3035
+ warning << "\nThis modifies the archive file permanently!\n".fg(196).b
3036
+ warning << "Press " + "y".fg(156).b + " to confirm, any other key to cancel".fg(249)
3037
+ @pR.say(warning)
3038
+ @pB.say(" Delete #{entries.size} item(s) from archive? (y/n)".fg(196))
3039
+
3040
+ unless getchr == 'y'
3041
+ @pB.say(" Cancelled".fg(240))
3042
+ @pR.update = true
3043
+ return
3044
+ end
3045
+
3046
+ escaped_archive = Shellwords.escape(@archive_path)
3047
+ entry_paths = entries.map { |e| e[:full_path] }
3048
+
3049
+ success = case @archive_path.downcase
3050
+ when /\.zip$/
3051
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
3052
+ system("zip -d #{escaped_archive} #{escaped_paths} >/dev/null 2>&1")
3053
+ when /\.rar$/
3054
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
3055
+ system("rar d #{escaped_archive} #{escaped_paths} >/dev/null 2>&1")
3056
+ when /\.7z$/
3057
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
3058
+ system("7z d #{escaped_archive} #{escaped_paths} >/dev/null 2>&1")
3059
+ else
3060
+ archive_tar_modify(:delete, entry_paths)
3061
+ end
3062
+
3063
+ if success
3064
+ @pB.say(" Deleted #{entries.size} item(s) from archive".fg(156))
3065
+ archive_refresh
3066
+ else
3067
+ @pB.say(" Delete failed".fg(196))
3068
+ end
3069
+ @tagged = []
3070
+ @pR.update = true
3071
+ end
3072
+
3073
+ def archive_add_files # {{{3
3074
+ return unless @archive_mode && @archive_path
3075
+
3076
+ # Use files tagged before entering archive mode, or ask
3077
+ if @archive_origin_tagged.empty?
3078
+ @pB.say(" No files were tagged before entering archive. Tag files first, then enter archive.".fg(196))
3079
+ return
3080
+ end
3081
+
3082
+ files_to_add = @archive_origin_tagged.select { |f| File.exist?(f) }
3083
+ if files_to_add.empty?
3084
+ @pB.say(" Tagged files no longer exist".fg(196))
3085
+ return
3086
+ end
3087
+
3088
+ clear_image
3089
+ info = "\nAdd Files to Archive\n".b.fg(226)
3090
+ info << "=" * 40 + "\n\n"
3091
+ info << "Archive: #{File.basename(@archive_path)}\n".fg(255)
3092
+ target = @archive_current_dir.empty? ? "archive root" : @archive_current_dir
3093
+ info << "Target: #{target}\n".fg(255)
3094
+ info << "\nFiles to add:\n".fg(226)
3095
+ files_to_add.first(10).each { |f| info << " #{File.basename(f)}\n".fg(255) }
3096
+ info << " ... and #{files_to_add.size - 10} more\n".fg(240) if files_to_add.size > 10
3097
+ info << "\nPress " + "y".fg(156).b + " to confirm, any other key to cancel".fg(249)
3098
+ @pR.say(info)
3099
+ @pB.say(" Add #{files_to_add.size} file(s) to archive? (y/n)".fg(226))
3100
+
3101
+ unless getchr == 'y'
3102
+ @pB.say(" Cancelled".fg(240))
3103
+ @pR.update = true
3104
+ return
3105
+ end
3106
+
3107
+ escaped_archive = Shellwords.escape(@archive_path)
3108
+ escaped_files = files_to_add.map { |f| Shellwords.escape(f) }.join(' ')
3109
+
3110
+ success = case @archive_path.downcase
3111
+ when /\.zip$/
3112
+ if @archive_current_dir.empty?
3113
+ system("zip -j #{escaped_archive} #{escaped_files} >/dev/null 2>&1")
3114
+ else
3115
+ # zip doesn't have a native "add to subdir" flag - use a temp dir
3116
+ archive_add_to_subdir_zip(files_to_add)
3117
+ end
3118
+ when /\.rar$/
3119
+ # rar -ap sets the archive path prefix
3120
+ ap = @archive_current_dir.empty? ? '' : "-ap#{Shellwords.escape(@archive_current_dir)}"
3121
+ system("rar a #{ap} #{escaped_archive} #{escaped_files} >/dev/null 2>&1")
3122
+ when /\.7z$/
3123
+ # 7z doesn't support adding to subdirectory natively - use temp dir approach
3124
+ if @archive_current_dir.empty?
3125
+ system("7z a #{escaped_archive} #{escaped_files} >/dev/null 2>&1")
3126
+ else
3127
+ archive_add_to_subdir_generic(files_to_add)
3128
+ end
3129
+ else
3130
+ archive_tar_modify(:add, files_to_add)
3131
+ end
3132
+
3133
+ if success
3134
+ @pB.say(" Added #{files_to_add.size} file(s) to archive".fg(156))
3135
+ @archive_origin_tagged = []
3136
+ archive_refresh
3137
+ else
3138
+ @pB.say(" Add failed".fg(196))
3139
+ end
3140
+ @pR.update = true
3141
+ end
3142
+
3143
+ def archive_add_to_subdir_zip(files) # {{{3
3144
+ # To add files to a specific subdirectory in a zip, create temp structure
3145
+ Dir.mktmpdir('rtfm_zip_add') do |tmpdir|
3146
+ target = File.join(tmpdir, @archive_current_dir)
3147
+ FileUtils.mkdir_p(target)
3148
+ files.each { |f| FileUtils.cp(f, target) }
3149
+ Dir.chdir(tmpdir) do
3150
+ escaped_files = files.map { |f| Shellwords.escape(File.join(@archive_current_dir, File.basename(f))) }.join(' ')
3151
+ system("zip #{Shellwords.escape(@archive_path)} #{escaped_files} >/dev/null 2>&1")
3152
+ end
3153
+ end
3154
+ end
3155
+
3156
+ def archive_add_to_subdir_generic(files) # {{{3
3157
+ Dir.mktmpdir('rtfm_7z_add') do |tmpdir|
3158
+ target = File.join(tmpdir, @archive_current_dir)
3159
+ FileUtils.mkdir_p(target)
3160
+ files.each { |f| FileUtils.cp(f, target) }
3161
+ Dir.chdir(tmpdir) do
3162
+ escaped_files = files.map { |f| Shellwords.escape(File.join(@archive_current_dir, File.basename(f))) }.join(' ')
3163
+ system("7z a #{Shellwords.escape(@archive_path)} #{escaped_files} >/dev/null 2>&1")
3164
+ end
3165
+ end
3166
+ end
3167
+
3168
+ def archive_tar_modify(action, paths) # {{{3
3169
+ # For tar-based archives: extract all, modify, re-archive
3170
+ # This is expensive but tar doesn't support in-place modifications on compressed archives
3171
+ Dir.mktmpdir('rtfm_tar_mod') do |tmpdir|
3172
+ escaped_archive = Shellwords.escape(@archive_path)
3173
+ tar_flag = tar_decompress_flag(@archive_path)
3174
+
3175
+ # Extract everything
3176
+ unless system("tar x#{tar_flag}f #{escaped_archive} -C #{Shellwords.escape(tmpdir)} 2>/dev/null")
3177
+ @pB.say(" Failed to extract archive for modification".fg(196))
3178
+ return false
3179
+ end
3180
+
3181
+ case action
3182
+ when :delete
3183
+ paths.each do |p|
3184
+ target = File.join(tmpdir, p)
3185
+ FileUtils.rm_rf(target) if File.exist?(target) || File.symlink?(target)
3186
+ end
3187
+ when :add
3188
+ target_dir = @archive_current_dir.empty? ? tmpdir : File.join(tmpdir, @archive_current_dir)
3189
+ FileUtils.mkdir_p(target_dir)
3190
+ paths.each { |f| FileUtils.cp(f, target_dir) }
3191
+ end
3192
+
3193
+ # Re-archive
3194
+ tar_compress = tar_compress_flag(@archive_path)
3195
+ Dir.chdir(tmpdir) do
3196
+ all_entries = Dir.glob('*', File::FNM_DOTMATCH).reject { |e| e == '.' || e == '..' }
3197
+ escaped_entries = all_entries.map { |e| Shellwords.escape(e) }.join(' ')
3198
+ system("tar c#{tar_compress}f #{escaped_archive} #{escaped_entries} 2>/dev/null")
3199
+ end
3200
+ end
3201
+ end
3202
+
3203
+ def tar_decompress_flag(path) # {{{3
3204
+ case path.downcase
3205
+ when /\.tar\.gz$|\.tgz$/ then 'z'
3206
+ when /\.tar\.bz2$|\.tbz2?$/ then 'j'
3207
+ when /\.tar\.xz$|\.txz$/ then 'J'
3208
+ when /\.tar\.zst$/ then ' --zstd '
3209
+ else ''
3210
+ end
3211
+ end
3212
+
3213
+ def tar_compress_flag(path) # {{{3
3214
+ case path.downcase
3215
+ when /\.tar\.gz$|\.tgz$/ then 'z'
3216
+ when /\.tar\.bz2$|\.tbz2?$/ then 'j'
3217
+ when /\.tar\.xz$|\.txz$/ then 'J'
3218
+ when /\.tar\.zst$/ then ' --zstd '
3219
+ else ''
3220
+ end
3221
+ end
3222
+
3223
+ def archive_tagged_or_selected # {{{3
3224
+ if @tagged.any?
3225
+ # Map tagged file names back to archive entries
3226
+ @archive_files_cache.select { |e| @tagged.include?("#{@archive_path}:#{e[:full_path]}") }
3227
+ elsif @archive_files_cache[@index]
3228
+ [@archive_files_cache[@index]]
3229
+ else
3230
+ []
3231
+ end
3232
+ end
3233
+
3234
+ def archive_refresh # {{{3
3235
+ @archive_entries = parse_archive_listing(@archive_path)
3236
+ @archive_files_cache = []
3237
+ @index = 0 if @index >= archive_entries_for_dir(@archive_current_dir).size
3238
+ @pL.update = @pR.update = @pT.update = true
3239
+ dirlist
3240
+ render
3241
+ end
3242
+
2592
3243
  def show_remote_file_info(file) # {{{3
2593
3244
  info_text = "Remote File Information\n".b.fg(156)
2594
3245
  info_text << "=" * 40 + "\n\n"
@@ -3202,7 +3853,7 @@ def build_remote_help # {{{3
3202
3853
 
3203
3854
  help_text << "Remote Navigation:\n".fg(226)
3204
3855
  help_text << " • Use arrow keys to navigate directories\n"
3205
- help_text << " • Press " + "d".fg(156) + " to download files\n"
3856
+ help_text << " • Press " + "D".fg(156) + " to download files\n"
3206
3857
  help_text << " • Press " + "u".fg(156) + " to upload files\n"
3207
3858
  help_text << " • Press " + "s".fg(156) + " to open SSH shell\n"
3208
3859
  help_text << " • Press " + "→".fg(156) + " to view file info\n"
@@ -3213,14 +3864,17 @@ end
3213
3864
 
3214
3865
  # MANIPULATE ITEMS {{{2
3215
3866
  def copy_items # {{{3
3867
+ if @archive_mode
3868
+ # In archive mode, 'p' adds local files into the archive
3869
+ archive_add_files
3870
+ return
3871
+ end
3216
3872
  copy_move_link('copy')
3217
- # Dual-pane refresh is handled in copy_move_link function
3218
3873
  @pR.update = true
3219
3874
  end
3220
3875
 
3221
3876
  def move_items # {{{3
3222
3877
  copy_move_link('move')
3223
- # Dual-pane refresh is handled in copy_move_link function
3224
3878
  @pR.update = true
3225
3879
  end
3226
3880
 
@@ -3281,11 +3935,14 @@ def link_items # {{{3
3281
3935
  end
3282
3936
 
3283
3937
  def delete_items # {{{3
3284
- if @remote_mode
3285
- # In remote mode, 'd' key downloads the selected file
3286
- remote_download_selected
3938
+ if @archive_mode
3939
+ # In archive mode, 'd' = delete from archive (consistent with normal mode)
3940
+ archive_delete_entries
3287
3941
  return
3288
3942
  end
3943
+ if @remote_mode
3944
+ return # No delete in remote mode; download is on 'D'
3945
+ end
3289
3946
 
3290
3947
  tagged_info
3291
3948
 
@@ -3392,6 +4049,16 @@ def delete_items # {{{3
3392
4049
  end
3393
4050
 
3394
4051
  def empty_trash # {{{3
4052
+ if @archive_mode
4053
+ # In archive mode, 'D' = extract entries to origin directory
4054
+ archive_extract_entries
4055
+ return
4056
+ end
4057
+ if @remote_mode
4058
+ # In remote mode, 'D' = download selected file
4059
+ remote_download_selected
4060
+ return
4061
+ end
3395
4062
  @pB.say(" Really empty Trash (~/.rtfm/trash)? (press 'y')")
3396
4063
  return unless getchr == 'y'
3397
4064
 
@@ -4587,6 +5254,47 @@ def get_cached_file_metadata(file_path) # {{{2
4587
5254
  end
4588
5255
  end
4589
5256
 
5257
+ def dirlist_archive # {{{2
5258
+ return '' unless @archive_mode && @archive_path
5259
+
5260
+ current_index = @index || 0
5261
+ current_index = current_index.to_i
5262
+ width = @pL.w
5263
+
5264
+ files = archive_entries_for_dir(@archive_current_dir)
5265
+ @files = files.map { |f| f[:name] }
5266
+ @archive_files_cache = files
5267
+
5268
+ if @files[current_index] && files[current_index]
5269
+ entry = files[current_index]
5270
+ vpath = @archive_current_dir.empty? ? entry[:name] : "#{@archive_current_dir}/#{entry[:name]}"
5271
+ @selected = "#{@archive_path}:#{vpath}"
5272
+ @fileattr = "#{entry[:permissions]} #{format_size_simple(entry[:size])}"
5273
+ end
5274
+
5275
+ search_regex = @searched.empty? ? nil : /#{@searched}/
5276
+
5277
+ result = files.map.with_index do |entry, i|
5278
+ name = entry[:name]
5279
+ color = entry[:type] == 'directory' ? 156 : 255
5280
+ n = name.fg(color)
5281
+ n = n.inject('/', -1) if entry[:type] == 'directory'
5282
+ n = n.bg(238) if search_regex && name.match(search_regex)
5283
+ tag_key = "#{@archive_path}:#{entry[:full_path]}"
5284
+ n = n.r if @tagged.include?(tag_key)
5285
+ n = n.shorten(width - 5).inject('...', -1) if name.length > width - 6
5286
+
5287
+ if i == current_index
5288
+ n = '→ ' + n.u.bg(58) # Yellow-green background for archive mode selection
5289
+ else
5290
+ n = ' ' + n.bg(58) # Yellow-green background for archive mode
5291
+ end
5292
+ n
5293
+ end
5294
+
5295
+ result.join("\n")
5296
+ end
5297
+
4590
5298
  def dirlist_remote # {{{2
4591
5299
  return '' unless @current_remote && @remote_mode
4592
5300
 
@@ -4669,7 +5377,10 @@ def dirlist(left: true, directory: nil) # LIST DIRECTORIES {{{2
4669
5377
  current_index = @index || 0
4670
5378
  current_index = current_index.to_i
4671
5379
 
4672
- # Handle remote mode for left pane
5380
+ # Handle archive/remote mode for left pane
5381
+ if left && @archive_mode
5382
+ return dirlist_archive
5383
+ end
4673
5384
  if left && @remote_mode
4674
5385
  return dirlist_remote
4675
5386
  end
@@ -4804,14 +5515,16 @@ def current_render_state # {{{2
4804
5515
  dual_pane: false,
4805
5516
  dir: Dir.pwd,
4806
5517
  index: @index,
4807
- files_mtime: File.mtime(Dir.pwd).to_i,
5518
+ files_mtime: @archive_mode ? 0 : File.mtime(Dir.pwd).to_i,
4808
5519
  selected: @selected,
4809
5520
  tagged_count: @tagged.size,
4810
5521
  preview: @preview,
4811
5522
  showimage: @showimage,
4812
5523
  searched: @searched,
4813
5524
  filters: [@filter, @filtered].join,
4814
- tabs_state: [@tabs.size, @current_tab, @tabs.map { |t| t[:name] }.join('|')]
5525
+ tabs_state: [@tabs.size, @current_tab, @tabs.map { |t| t[:name] }.join('|')],
5526
+ archive_mode: @archive_mode,
5527
+ archive_dir: @archive_current_dir
4815
5528
  }
4816
5529
  end
4817
5530
  end
@@ -4916,16 +5629,22 @@ def render # RENDER ALL PANES {{{2
4916
5629
  # TOP PANE {{{3
4917
5630
  if @pT.update
4918
5631
  toptext = @pT.text
4919
- text = ' ' + ENV.fetch('USER') + '@' + `hostname 2>/dev/null`.chomp + ': '
4920
- unless @selected.nil?
4921
- text += @selected
4922
- text += " #{File.readlink(@selected)}" if File.symlink?(@selected)
4923
- end
4924
- # File attributes
4925
- text += " (#{@fileattr})" if defined?(@fileattr)
4926
- # Image or PDF metadata using cache
5632
+ if @archive_mode
5633
+ text = ' ARCHIVE: ' + File.basename(@archive_path)
5634
+ text += "/#{@archive_current_dir}" unless @archive_current_dir.empty?
5635
+ text += " (#{@fileattr})" if defined?(@fileattr) && !@fileattr.to_s.empty?
5636
+ else
5637
+ text = ' ' + ENV.fetch('USER') + '@' + `hostname 2>/dev/null`.chomp + ': '
5638
+ unless @selected.nil?
5639
+ text += @selected
5640
+ text += " → #{File.readlink(@selected)}" if File.symlink?(@selected)
5641
+ end
5642
+ # File attributes
5643
+ text += " (#{@fileattr})" if defined?(@fileattr)
5644
+ end
5645
+ # Image or PDF metadata using cache (skip in archive mode)
4927
5646
  begin
4928
- cached_meta = get_cached_file_metadata(@selected)
5647
+ cached_meta = @archive_mode ? nil : get_cached_file_metadata(@selected)
4929
5648
  if cached_meta
4930
5649
  text += cached_meta
4931
5650
  elsif @preview && @selected&.match(@imagefile) && cmd?('identify')
@@ -4947,8 +5666,8 @@ def render # RENDER ALL PANES {{{2
4947
5666
  rescue Errno::ENOENT, Errno::EACCES
4948
5667
  # ignore missing or permission errors
4949
5668
  end
4950
- # Directory children count
4951
- if @selected && Dir.exist?(@selected)
5669
+ # Directory children count (skip in archive mode)
5670
+ if !@archive_mode && @selected && Dir.exist?(@selected)
4952
5671
  begin
4953
5672
  count = Dir.children(@selected).count
4954
5673
  text += " [#{count} items]"
@@ -4971,8 +5690,12 @@ def render # RENDER ALL PANES {{{2
4971
5690
  end
4972
5691
 
4973
5692
  @pT.text = text
4974
-
4975
- @pT.bg = @topmatch.find { |name, _| name.empty? || Dir.pwd.include?(name) }&.last
5693
+
5694
+ @pT.bg = if @archive_mode
5695
+ 58 # Yellow-green background for archive mode
5696
+ else
5697
+ @topmatch.find { |name, _| name.empty? || Dir.pwd.include?(name) }&.last
5698
+ end
4976
5699
  @pT.refresh unless @pT.text == toptext
4977
5700
  end
4978
5701
 
@@ -5166,7 +5889,17 @@ def command(cmd, timeout: 5, return_both: false) # {{{2
5166
5889
  end
5167
5890
  end
5168
5891
 
5892
+ def file_op_running? # {{{3
5893
+ @file_op_thread&.alive?
5894
+ end
5895
+
5169
5896
  def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
5897
+ # Block if another async operation is running
5898
+ if file_op_running?
5899
+ @pB.say(" Another file operation is in progress. Please wait.".fg(196))
5900
+ return
5901
+ end
5902
+
5170
5903
  # Use tagged items if any exist, otherwise use selected item
5171
5904
  items = @tagged.empty? ? [@selected] : @tagged.uniq
5172
5905
 
@@ -5177,14 +5910,66 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
5177
5910
  Dir.pwd
5178
5911
  end
5179
5912
 
5180
- # Track operations for undo
5913
+ # Symlinks are instant - run synchronously
5914
+ if type == 'link'
5915
+ copy_move_link_sync(type, items, dest_dir)
5916
+ return
5917
+ end
5918
+
5919
+ # For small operations (single file, not a directory), run synchronously
5920
+ if items.size == 1 && !File.directory?(items[0])
5921
+ copy_move_link_sync(type, items, dest_dir)
5922
+ return
5923
+ end
5924
+
5925
+ # Run copy/move asynchronously for larger operations
5926
+ @tagged = []
5927
+ previously_selected = type == 'move' ? (items[0] ? File.basename(items[0]) : nil) : nil
5928
+ @file_op_progress = " #{type.capitalize}ing #{items.size} item(s)...".fg(226)
5929
+ @pB.say(@file_op_progress)
5930
+
5931
+ @file_op_thread = Thread.new do
5932
+ operations = []
5933
+ begin
5934
+ items.each_with_index do |item, idx|
5935
+ @file_op_progress = " #{type.capitalize}ing #{idx + 1}/#{items.size}: #{File.basename(item)}".fg(226)
5936
+ dest = File.join(dest_dir, File.basename(item))
5937
+ dest += '1' if File.exist?(dest)
5938
+ while File.exist?(dest)
5939
+ dest = dest.chop + (dest[-1].to_i + 1).to_s
5940
+ end
5941
+ case type
5942
+ when 'copy'
5943
+ FileUtils.cp_r(item, dest)
5944
+ when 'move'
5945
+ FileUtils.mv(item, dest)
5946
+ end
5947
+ operations << { source_path: item, dest_path: dest }
5948
+ end
5949
+
5950
+ # Record undo
5951
+ unless operations.empty?
5952
+ undo_key = type == 'copy' ? :copies : :moves
5953
+ add_undo_operation({ type: type, undo_key => operations, timestamp: Time.now })
5954
+ end
5955
+
5956
+ @file_op_result = " #{type.capitalize} complete: #{operations.size} item(s)".fg(156)
5957
+ rescue => e
5958
+ @file_op_result = " #{type.capitalize} error: #{e.message}".fg(196)
5959
+ ensure
5960
+ @file_op_progress = nil
5961
+ @file_op_complete = true
5962
+ end
5963
+ end
5964
+ end
5965
+
5966
+ def copy_move_link_sync(type, items, dest_dir) # {{{3
5181
5967
  operations = []
5182
5968
 
5183
5969
  items.each do |item|
5184
5970
  dest = File.join(dest_dir, File.basename(item))
5185
5971
  dest += '1' if File.exist?(dest)
5186
5972
  while File.exist?(dest)
5187
- # Replace the last character (presumed to be a digit) by incrementing it
5188
5973
  dest = dest.chop + (dest[-1].to_i + 1).to_s
5189
5974
  end
5190
5975
  begin
@@ -5206,53 +5991,38 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
5206
5991
  @pB.say(e.to_s)
5207
5992
  end
5208
5993
  end
5209
-
5210
- # Record undo information if operations were successful
5994
+
5995
+ # Record undo information
5211
5996
  unless operations.empty?
5212
5997
  case type
5213
5998
  when 'copy'
5214
- add_undo_operation({
5215
- type: 'copy',
5216
- copies: operations,
5217
- timestamp: Time.now
5218
- })
5999
+ add_undo_operation({ type: 'copy', copies: operations, timestamp: Time.now })
5219
6000
  when 'move'
5220
- add_undo_operation({
5221
- type: 'move',
5222
- moves: operations,
5223
- timestamp: Time.now
5224
- })
6001
+ add_undo_operation({ type: 'move', moves: operations, timestamp: Time.now })
5225
6002
  when 'link'
5226
- add_undo_operation({
5227
- type: 'link',
5228
- links: operations,
5229
- timestamp: Time.now
5230
- })
6003
+ add_undo_operation({ type: 'link', links: operations, timestamp: Time.now })
5231
6004
  end
5232
6005
  end
5233
-
6006
+
5234
6007
  @tagged = []
5235
6008
 
5236
- # Store the currently selected item to restore selection after refresh (for move operations)
6009
+ # Restore selection after move operations
5237
6010
  if type == 'move'
5238
6011
  previously_selected = @selected ? File.basename(@selected) : nil
5239
6012
  end
5240
6013
 
5241
- # Set update flags for proper refresh - let render system handle the actual refresh
6014
+ # Set update flags for proper refresh
5242
6015
  if @dual_pane
5243
- # Update the destination pane
5244
6016
  if @active_pane == :left
5245
6017
  @pLeft.update = true
5246
6018
  else
5247
6019
  @pRight.update = true
5248
6020
  end
5249
- # Also update the preview pane
5250
6021
  @pPreview.update = true if @pPreview
5251
6022
  end
5252
6023
 
5253
6024
  render
5254
6025
 
5255
- # Restore selection after move operations
5256
6026
  if type == 'move' && previously_selected && @files
5257
6027
  restored_index = @files.index(previously_selected)
5258
6028
  @index = restored_index if restored_index
@@ -5325,6 +6095,12 @@ def open_selected(html = nil) # OPEN SELECTED FILE {{{2
5325
6095
  track_directory_access(@selected)
5326
6096
  return
5327
6097
  end
6098
+
6099
+ # Enter archive browsing mode for archive files (unless forced open with 'x')
6100
+ if !html && @selected && ARCHIVE_RE.match?(@selected) && File.file?(@selected)
6101
+ enter_archive_mode(@selected)
6102
+ return
6103
+ end
5328
6104
 
5329
6105
  # Track file access when opening files
5330
6106
  track_file_access(@selected)
@@ -5548,6 +6324,12 @@ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
5548
6324
  @pR.clear
5549
6325
  end
5550
6326
  begin
6327
+ # Handle archive mode separately
6328
+ if @archive_mode && @files && @files[@index] && @archive_files_cache[@index]
6329
+ show_archive_file_info(@archive_files_cache[@index])
6330
+ return
6331
+ end
6332
+
5551
6333
  # Handle remote mode separately
5552
6334
  if @remote_mode && @files && @files[@index] && @remote_files_cache[@index]
5553
6335
  selected_file = @remote_files_cache[@index]
@@ -5957,7 +6739,20 @@ $stdin.getc while $stdin.wait_readable(0)
5957
6739
  ## THE LOOP {{{2
5958
6740
  loop do
5959
6741
  @dir_old = Dir.pwd
5960
-
6742
+
6743
+ # Check async file operation progress
6744
+ if @file_op_complete
6745
+ @file_op_complete = false
6746
+ @pB.say(@file_op_result || " Operation complete.".fg(156))
6747
+ @file_op_result = nil
6748
+ @file_op_progress = nil
6749
+ @dir_cache.delete_if { |key, _| key.start_with?("#{Dir.pwd}:") }
6750
+ @pL.update = @pR.update = @pT.update = @pB.update = true
6751
+ elsif @file_op_progress
6752
+ @pB.say(@file_op_progress)
6753
+ @pB.update = false
6754
+ end
6755
+
5961
6756
  # redraw, but ignore TTY‐focus errors
5962
6757
  begin
5963
6758
  render
@@ -5976,8 +6771,8 @@ loop do
5976
6771
  rescue
5977
6772
  Dir.chdir
5978
6773
  end
5979
- # If selected file was removed externally, force pane refresh
5980
- if @selected && !File.exist?(@selected)
6774
+ # If selected file was removed externally, force pane refresh (skip in archive/remote mode)
6775
+ if !@archive_mode && !@remote_mode && @selected && !File.exist?(@selected)
5981
6776
  @dir_cache.delete_if { |key, _| key.start_with?("#{Dir.pwd}:") }
5982
6777
  @index = [@index, (@files.size - 2)].min
5983
6778
  @index = 0 if @index.negative?