rtfm-filemanager 7.5.2 → 8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/bin/rtfm +920 -109
  4. metadata +8 -8
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.1' # WezTerm support, removed bold from top pane
21
+ @version = '8.0' # Archive browsing, async file ops, scrollable diff viewer
22
22
 
23
23
  # SAVE & STORE TERMINAL {{{1
24
24
  ORIG_STTY = `stty -g`.chomp
@@ -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=extract, D=delete, p=add files, t=tag, P=move out
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,503 @@ 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 = Extract to origin directory\n".fg(240)
2972
+ info_text << " D = Delete from archive\n".fg(240)
2973
+ info_text << " p = Add local files into archive\n".fg(240)
2974
+ info_text << " P = Move out (extract + delete from archive)\n".fg(240)
2975
+ info_text << " t = Tag/untag for bulk operations\n".fg(240)
2976
+ info_text << " LEFT = Go to parent / exit archive\n".fg(240)
2977
+ @pR.say(info_text)
2978
+ @pR.update = false
2979
+ end
2980
+
2981
+ def archive_extract_entries # {{{3
2982
+ return unless @archive_mode && @archive_path
2983
+
2984
+ # Get entries to extract: tagged archive entries, or selected
2985
+ entries = archive_tagged_or_selected
2986
+ return if entries.empty?
2987
+
2988
+ dest = @archive_origin_dir || Dir.pwd
2989
+ escaped_archive = Shellwords.escape(@archive_path)
2990
+ entry_paths = entries.map { |e| e[:full_path] }
2991
+ # For tar archives, use raw_path (preserves ./ prefix) for correct extraction
2992
+ tar_paths = entries.map { |e| e[:raw_path] || e[:full_path] }
2993
+
2994
+ @pB.say(" Extracting #{entries.size} item(s) to #{dest}...".fg(226))
2995
+
2996
+ success = case @archive_path.downcase
2997
+ when /\.zip$/
2998
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
2999
+ system("unzip -o #{escaped_archive} #{escaped_paths} -d #{Shellwords.escape(dest)} >/dev/null 2>&1")
3000
+ when /\.rar$/
3001
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
3002
+ system("unrar x -o+ #{escaped_archive} #{escaped_paths} #{Shellwords.escape(dest)}/ >/dev/null 2>&1")
3003
+ when /\.7z$/
3004
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
3005
+ system("7z x #{escaped_archive} #{escaped_paths} -o#{Shellwords.escape(dest)} -y >/dev/null 2>&1")
3006
+ else
3007
+ # tar variants — use raw_path to match archive entries exactly
3008
+ escaped_paths = tar_paths.map { |p| Shellwords.escape(p) }.join(' ')
3009
+ tar_flag = tar_decompress_flag(@archive_path)
3010
+ system("tar x#{tar_flag}f #{escaped_archive} -C #{Shellwords.escape(dest)} #{escaped_paths} 2>/dev/null")
3011
+ end
3012
+
3013
+ if success
3014
+ @pB.say(" Extracted #{entries.size} item(s) to #{dest}".fg(156))
3015
+ else
3016
+ @pB.say(" Extraction failed".fg(196))
3017
+ end
3018
+ @tagged = []
3019
+ @pL.update = @pR.update = true
3020
+ end
3021
+
3022
+ def archive_delete_entries # {{{3
3023
+ return unless @archive_mode && @archive_path
3024
+
3025
+ entries = archive_tagged_or_selected
3026
+ return if entries.empty?
3027
+
3028
+ clear_image
3029
+ names = entries.map { |e| e[:name] }
3030
+ warning = "\nDelete from Archive\n".b.fg(196)
3031
+ warning << "=" * 40 + "\n\n"
3032
+ warning << "Archive: #{File.basename(@archive_path)}\n".fg(255)
3033
+ warning << "\nItems to delete:\n".fg(226)
3034
+ entries.first(10).each { |e| warning << " #{e[:full_path]}\n".fg(255) }
3035
+ warning << " ... and #{entries.size - 10} more\n".fg(240) if entries.size > 10
3036
+ warning << "\nThis modifies the archive file permanently!\n".fg(196).b
3037
+ warning << "Press " + "y".fg(156).b + " to confirm, any other key to cancel".fg(249)
3038
+ @pR.say(warning)
3039
+ @pB.say(" Delete #{entries.size} item(s) from archive? (y/n)".fg(196))
3040
+
3041
+ unless getchr == 'y'
3042
+ @pB.say(" Cancelled".fg(240))
3043
+ @pR.update = true
3044
+ return
3045
+ end
3046
+
3047
+ escaped_archive = Shellwords.escape(@archive_path)
3048
+ entry_paths = entries.map { |e| e[:full_path] }
3049
+
3050
+ success = case @archive_path.downcase
3051
+ when /\.zip$/
3052
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
3053
+ system("zip -d #{escaped_archive} #{escaped_paths} >/dev/null 2>&1")
3054
+ when /\.rar$/
3055
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
3056
+ system("rar d #{escaped_archive} #{escaped_paths} >/dev/null 2>&1")
3057
+ when /\.7z$/
3058
+ escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
3059
+ system("7z d #{escaped_archive} #{escaped_paths} >/dev/null 2>&1")
3060
+ else
3061
+ archive_tar_modify(:delete, entry_paths)
3062
+ end
3063
+
3064
+ if success
3065
+ @pB.say(" Deleted #{entries.size} item(s) from archive".fg(156))
3066
+ archive_refresh
3067
+ else
3068
+ @pB.say(" Delete failed".fg(196))
3069
+ end
3070
+ @tagged = []
3071
+ @pR.update = true
3072
+ end
3073
+
3074
+ def archive_add_files # {{{3
3075
+ return unless @archive_mode && @archive_path
3076
+
3077
+ # Use files tagged before entering archive mode, or ask
3078
+ if @archive_origin_tagged.empty?
3079
+ @pB.say(" No files were tagged before entering archive. Tag files first, then enter archive.".fg(196))
3080
+ return
3081
+ end
3082
+
3083
+ files_to_add = @archive_origin_tagged.select { |f| File.exist?(f) }
3084
+ if files_to_add.empty?
3085
+ @pB.say(" Tagged files no longer exist".fg(196))
3086
+ return
3087
+ end
3088
+
3089
+ clear_image
3090
+ info = "\nAdd Files to Archive\n".b.fg(226)
3091
+ info << "=" * 40 + "\n\n"
3092
+ info << "Archive: #{File.basename(@archive_path)}\n".fg(255)
3093
+ target = @archive_current_dir.empty? ? "archive root" : @archive_current_dir
3094
+ info << "Target: #{target}\n".fg(255)
3095
+ info << "\nFiles to add:\n".fg(226)
3096
+ files_to_add.first(10).each { |f| info << " #{File.basename(f)}\n".fg(255) }
3097
+ info << " ... and #{files_to_add.size - 10} more\n".fg(240) if files_to_add.size > 10
3098
+ info << "\nPress " + "y".fg(156).b + " to confirm, any other key to cancel".fg(249)
3099
+ @pR.say(info)
3100
+ @pB.say(" Add #{files_to_add.size} file(s) to archive? (y/n)".fg(226))
3101
+
3102
+ unless getchr == 'y'
3103
+ @pB.say(" Cancelled".fg(240))
3104
+ @pR.update = true
3105
+ return
3106
+ end
3107
+
3108
+ escaped_archive = Shellwords.escape(@archive_path)
3109
+ escaped_files = files_to_add.map { |f| Shellwords.escape(f) }.join(' ')
3110
+
3111
+ success = case @archive_path.downcase
3112
+ when /\.zip$/
3113
+ if @archive_current_dir.empty?
3114
+ system("zip -j #{escaped_archive} #{escaped_files} >/dev/null 2>&1")
3115
+ else
3116
+ # zip doesn't have a native "add to subdir" flag - use a temp dir
3117
+ archive_add_to_subdir_zip(files_to_add)
3118
+ end
3119
+ when /\.rar$/
3120
+ # rar -ap sets the archive path prefix
3121
+ ap = @archive_current_dir.empty? ? '' : "-ap#{Shellwords.escape(@archive_current_dir)}"
3122
+ system("rar a #{ap} #{escaped_archive} #{escaped_files} >/dev/null 2>&1")
3123
+ when /\.7z$/
3124
+ # 7z doesn't support adding to subdirectory natively - use temp dir approach
3125
+ if @archive_current_dir.empty?
3126
+ system("7z a #{escaped_archive} #{escaped_files} >/dev/null 2>&1")
3127
+ else
3128
+ archive_add_to_subdir_generic(files_to_add)
3129
+ end
3130
+ else
3131
+ archive_tar_modify(:add, files_to_add)
3132
+ end
3133
+
3134
+ if success
3135
+ @pB.say(" Added #{files_to_add.size} file(s) to archive".fg(156))
3136
+ @archive_origin_tagged = []
3137
+ archive_refresh
3138
+ else
3139
+ @pB.say(" Add failed".fg(196))
3140
+ end
3141
+ @pR.update = true
3142
+ end
3143
+
3144
+ def archive_add_to_subdir_zip(files) # {{{3
3145
+ # To add files to a specific subdirectory in a zip, create temp structure
3146
+ Dir.mktmpdir('rtfm_zip_add') do |tmpdir|
3147
+ target = File.join(tmpdir, @archive_current_dir)
3148
+ FileUtils.mkdir_p(target)
3149
+ files.each { |f| FileUtils.cp(f, target) }
3150
+ Dir.chdir(tmpdir) do
3151
+ escaped_files = files.map { |f| Shellwords.escape(File.join(@archive_current_dir, File.basename(f))) }.join(' ')
3152
+ system("zip #{Shellwords.escape(@archive_path)} #{escaped_files} >/dev/null 2>&1")
3153
+ end
3154
+ end
3155
+ end
3156
+
3157
+ def archive_add_to_subdir_generic(files) # {{{3
3158
+ Dir.mktmpdir('rtfm_7z_add') do |tmpdir|
3159
+ target = File.join(tmpdir, @archive_current_dir)
3160
+ FileUtils.mkdir_p(target)
3161
+ files.each { |f| FileUtils.cp(f, target) }
3162
+ Dir.chdir(tmpdir) do
3163
+ escaped_files = files.map { |f| Shellwords.escape(File.join(@archive_current_dir, File.basename(f))) }.join(' ')
3164
+ system("7z a #{Shellwords.escape(@archive_path)} #{escaped_files} >/dev/null 2>&1")
3165
+ end
3166
+ end
3167
+ end
3168
+
3169
+ def archive_tar_modify(action, paths) # {{{3
3170
+ # For tar-based archives: extract all, modify, re-archive
3171
+ # This is expensive but tar doesn't support in-place modifications on compressed archives
3172
+ Dir.mktmpdir('rtfm_tar_mod') do |tmpdir|
3173
+ escaped_archive = Shellwords.escape(@archive_path)
3174
+ tar_flag = tar_decompress_flag(@archive_path)
3175
+
3176
+ # Extract everything
3177
+ unless system("tar x#{tar_flag}f #{escaped_archive} -C #{Shellwords.escape(tmpdir)} 2>/dev/null")
3178
+ @pB.say(" Failed to extract archive for modification".fg(196))
3179
+ return false
3180
+ end
3181
+
3182
+ case action
3183
+ when :delete
3184
+ paths.each do |p|
3185
+ target = File.join(tmpdir, p)
3186
+ FileUtils.rm_rf(target) if File.exist?(target) || File.symlink?(target)
3187
+ end
3188
+ when :add
3189
+ target_dir = @archive_current_dir.empty? ? tmpdir : File.join(tmpdir, @archive_current_dir)
3190
+ FileUtils.mkdir_p(target_dir)
3191
+ paths.each { |f| FileUtils.cp(f, target_dir) }
3192
+ end
3193
+
3194
+ # Re-archive
3195
+ tar_compress = tar_compress_flag(@archive_path)
3196
+ Dir.chdir(tmpdir) do
3197
+ all_entries = Dir.glob('*', File::FNM_DOTMATCH).reject { |e| e == '.' || e == '..' }
3198
+ escaped_entries = all_entries.map { |e| Shellwords.escape(e) }.join(' ')
3199
+ system("tar c#{tar_compress}f #{escaped_archive} #{escaped_entries} 2>/dev/null")
3200
+ end
3201
+ end
3202
+ end
3203
+
3204
+ def tar_decompress_flag(path) # {{{3
3205
+ case path.downcase
3206
+ when /\.tar\.gz$|\.tgz$/ then 'z'
3207
+ when /\.tar\.bz2$|\.tbz2?$/ then 'j'
3208
+ when /\.tar\.xz$|\.txz$/ then 'J'
3209
+ when /\.tar\.zst$/ then ' --zstd '
3210
+ else ''
3211
+ end
3212
+ end
3213
+
3214
+ def tar_compress_flag(path) # {{{3
3215
+ case path.downcase
3216
+ when /\.tar\.gz$|\.tgz$/ then 'z'
3217
+ when /\.tar\.bz2$|\.tbz2?$/ then 'j'
3218
+ when /\.tar\.xz$|\.txz$/ then 'J'
3219
+ when /\.tar\.zst$/ then ' --zstd '
3220
+ else ''
3221
+ end
3222
+ end
3223
+
3224
+ def archive_tagged_or_selected # {{{3
3225
+ if @tagged.any?
3226
+ # Map tagged file names back to archive entries
3227
+ @archive_files_cache.select { |e| @tagged.include?("#{@archive_path}:#{e[:full_path]}") }
3228
+ elsif @archive_files_cache[@index]
3229
+ [@archive_files_cache[@index]]
3230
+ else
3231
+ []
3232
+ end
3233
+ end
3234
+
3235
+ def archive_refresh # {{{3
3236
+ @archive_entries = parse_archive_listing(@archive_path)
3237
+ @archive_files_cache = []
3238
+ @index = 0 if @index >= archive_entries_for_dir(@archive_current_dir).size
3239
+ @pL.update = @pR.update = @pT.update = true
3240
+ dirlist
3241
+ render
3242
+ end
3243
+
2592
3244
  def show_remote_file_info(file) # {{{3
2593
3245
  info_text = "Remote File Information\n".b.fg(156)
2594
3246
  info_text << "=" * 40 + "\n\n"
@@ -3213,14 +3865,28 @@ end
3213
3865
 
3214
3866
  # MANIPULATE ITEMS {{{2
3215
3867
  def copy_items # {{{3
3868
+ if @archive_mode
3869
+ # In archive mode, 'p' adds local files into the archive
3870
+ archive_add_files
3871
+ return
3872
+ end
3216
3873
  copy_move_link('copy')
3217
- # Dual-pane refresh is handled in copy_move_link function
3218
3874
  @pR.update = true
3219
3875
  end
3220
3876
 
3221
3877
  def move_items # {{{3
3878
+ if @archive_mode
3879
+ # In archive mode, 'P' extracts then deletes (move out of archive)
3880
+ return unless @archive_mode && @archive_path
3881
+ entries = archive_tagged_or_selected
3882
+ return if entries.empty?
3883
+ archive_extract_entries
3884
+ # Re-tag the entries so archive_delete_entries can find them
3885
+ @tagged = entries.map { |e| "#{@archive_path}:#{e[:full_path]}" }
3886
+ archive_delete_entries if @archive_mode
3887
+ return
3888
+ end
3222
3889
  copy_move_link('move')
3223
- # Dual-pane refresh is handled in copy_move_link function
3224
3890
  @pR.update = true
3225
3891
  end
3226
3892
 
@@ -3281,6 +3947,11 @@ def link_items # {{{3
3281
3947
  end
3282
3948
 
3283
3949
  def delete_items # {{{3
3950
+ if @archive_mode
3951
+ # In archive mode, 'd' key extracts to origin directory
3952
+ archive_extract_entries
3953
+ return
3954
+ end
3284
3955
  if @remote_mode
3285
3956
  # In remote mode, 'd' key downloads the selected file
3286
3957
  remote_download_selected
@@ -3392,6 +4063,11 @@ def delete_items # {{{3
3392
4063
  end
3393
4064
 
3394
4065
  def empty_trash # {{{3
4066
+ if @archive_mode
4067
+ # In archive mode, 'D' deletes entries from archive
4068
+ archive_delete_entries
4069
+ return
4070
+ end
3395
4071
  @pB.say(" Really empty Trash (~/.rtfm/trash)? (press 'y')")
3396
4072
  return unless getchr == 'y'
3397
4073
 
@@ -4587,6 +5263,47 @@ def get_cached_file_metadata(file_path) # {{{2
4587
5263
  end
4588
5264
  end
4589
5265
 
5266
+ def dirlist_archive # {{{2
5267
+ return '' unless @archive_mode && @archive_path
5268
+
5269
+ current_index = @index || 0
5270
+ current_index = current_index.to_i
5271
+ width = @pL.w
5272
+
5273
+ files = archive_entries_for_dir(@archive_current_dir)
5274
+ @files = files.map { |f| f[:name] }
5275
+ @archive_files_cache = files
5276
+
5277
+ if @files[current_index] && files[current_index]
5278
+ entry = files[current_index]
5279
+ vpath = @archive_current_dir.empty? ? entry[:name] : "#{@archive_current_dir}/#{entry[:name]}"
5280
+ @selected = "#{@archive_path}:#{vpath}"
5281
+ @fileattr = "#{entry[:permissions]} #{format_size_simple(entry[:size])}"
5282
+ end
5283
+
5284
+ search_regex = @searched.empty? ? nil : /#{@searched}/
5285
+
5286
+ result = files.map.with_index do |entry, i|
5287
+ name = entry[:name]
5288
+ color = entry[:type] == 'directory' ? 156 : 255
5289
+ n = name.fg(color)
5290
+ n = n.inject('/', -1) if entry[:type] == 'directory'
5291
+ n = n.bg(238) if search_regex && name.match(search_regex)
5292
+ tag_key = "#{@archive_path}:#{entry[:full_path]}"
5293
+ n = n.r if @tagged.include?(tag_key)
5294
+ n = n.shorten(width - 5).inject('...', -1) if name.length > width - 6
5295
+
5296
+ if i == current_index
5297
+ n = '→ ' + n.u.bg(58) # Yellow-green background for archive mode selection
5298
+ else
5299
+ n = ' ' + n.bg(58) # Yellow-green background for archive mode
5300
+ end
5301
+ n
5302
+ end
5303
+
5304
+ result.join("\n")
5305
+ end
5306
+
4590
5307
  def dirlist_remote # {{{2
4591
5308
  return '' unless @current_remote && @remote_mode
4592
5309
 
@@ -4669,7 +5386,10 @@ def dirlist(left: true, directory: nil) # LIST DIRECTORIES {{{2
4669
5386
  current_index = @index || 0
4670
5387
  current_index = current_index.to_i
4671
5388
 
4672
- # Handle remote mode for left pane
5389
+ # Handle archive/remote mode for left pane
5390
+ if left && @archive_mode
5391
+ return dirlist_archive
5392
+ end
4673
5393
  if left && @remote_mode
4674
5394
  return dirlist_remote
4675
5395
  end
@@ -4804,14 +5524,16 @@ def current_render_state # {{{2
4804
5524
  dual_pane: false,
4805
5525
  dir: Dir.pwd,
4806
5526
  index: @index,
4807
- files_mtime: File.mtime(Dir.pwd).to_i,
5527
+ files_mtime: @archive_mode ? 0 : File.mtime(Dir.pwd).to_i,
4808
5528
  selected: @selected,
4809
5529
  tagged_count: @tagged.size,
4810
5530
  preview: @preview,
4811
5531
  showimage: @showimage,
4812
5532
  searched: @searched,
4813
5533
  filters: [@filter, @filtered].join,
4814
- tabs_state: [@tabs.size, @current_tab, @tabs.map { |t| t[:name] }.join('|')]
5534
+ tabs_state: [@tabs.size, @current_tab, @tabs.map { |t| t[:name] }.join('|')],
5535
+ archive_mode: @archive_mode,
5536
+ archive_dir: @archive_current_dir
4815
5537
  }
4816
5538
  end
4817
5539
  end
@@ -4916,16 +5638,22 @@ def render # RENDER ALL PANES {{{2
4916
5638
  # TOP PANE {{{3
4917
5639
  if @pT.update
4918
5640
  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
5641
+ if @archive_mode
5642
+ text = ' ARCHIVE: ' + File.basename(@archive_path)
5643
+ text += "/#{@archive_current_dir}" unless @archive_current_dir.empty?
5644
+ text += " (#{@fileattr})" if defined?(@fileattr) && !@fileattr.to_s.empty?
5645
+ else
5646
+ text = ' ' + ENV.fetch('USER') + '@' + `hostname 2>/dev/null`.chomp + ': '
5647
+ unless @selected.nil?
5648
+ text += @selected
5649
+ text += " → #{File.readlink(@selected)}" if File.symlink?(@selected)
5650
+ end
5651
+ # File attributes
5652
+ text += " (#{@fileattr})" if defined?(@fileattr)
5653
+ end
5654
+ # Image or PDF metadata using cache (skip in archive mode)
4927
5655
  begin
4928
- cached_meta = get_cached_file_metadata(@selected)
5656
+ cached_meta = @archive_mode ? nil : get_cached_file_metadata(@selected)
4929
5657
  if cached_meta
4930
5658
  text += cached_meta
4931
5659
  elsif @preview && @selected&.match(@imagefile) && cmd?('identify')
@@ -4947,8 +5675,8 @@ def render # RENDER ALL PANES {{{2
4947
5675
  rescue Errno::ENOENT, Errno::EACCES
4948
5676
  # ignore missing or permission errors
4949
5677
  end
4950
- # Directory children count
4951
- if @selected && Dir.exist?(@selected)
5678
+ # Directory children count (skip in archive mode)
5679
+ if !@archive_mode && @selected && Dir.exist?(@selected)
4952
5680
  begin
4953
5681
  count = Dir.children(@selected).count
4954
5682
  text += " [#{count} items]"
@@ -4971,8 +5699,12 @@ def render # RENDER ALL PANES {{{2
4971
5699
  end
4972
5700
 
4973
5701
  @pT.text = text
4974
-
4975
- @pT.bg = @topmatch.find { |name, _| name.empty? || Dir.pwd.include?(name) }&.last
5702
+
5703
+ @pT.bg = if @archive_mode
5704
+ 58 # Yellow-green background for archive mode
5705
+ else
5706
+ @topmatch.find { |name, _| name.empty? || Dir.pwd.include?(name) }&.last
5707
+ end
4976
5708
  @pT.refresh unless @pT.text == toptext
4977
5709
  end
4978
5710
 
@@ -5166,7 +5898,17 @@ def command(cmd, timeout: 5, return_both: false) # {{{2
5166
5898
  end
5167
5899
  end
5168
5900
 
5901
+ def file_op_running? # {{{3
5902
+ @file_op_thread&.alive?
5903
+ end
5904
+
5169
5905
  def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
5906
+ # Block if another async operation is running
5907
+ if file_op_running?
5908
+ @pB.say(" Another file operation is in progress. Please wait.".fg(196))
5909
+ return
5910
+ end
5911
+
5170
5912
  # Use tagged items if any exist, otherwise use selected item
5171
5913
  items = @tagged.empty? ? [@selected] : @tagged.uniq
5172
5914
 
@@ -5177,14 +5919,66 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
5177
5919
  Dir.pwd
5178
5920
  end
5179
5921
 
5180
- # Track operations for undo
5922
+ # Symlinks are instant - run synchronously
5923
+ if type == 'link'
5924
+ copy_move_link_sync(type, items, dest_dir)
5925
+ return
5926
+ end
5927
+
5928
+ # For small operations (single file, not a directory), run synchronously
5929
+ if items.size == 1 && !File.directory?(items[0])
5930
+ copy_move_link_sync(type, items, dest_dir)
5931
+ return
5932
+ end
5933
+
5934
+ # Run copy/move asynchronously for larger operations
5935
+ @tagged = []
5936
+ previously_selected = type == 'move' ? (items[0] ? File.basename(items[0]) : nil) : nil
5937
+ @file_op_progress = " #{type.capitalize}ing #{items.size} item(s)...".fg(226)
5938
+ @pB.say(@file_op_progress)
5939
+
5940
+ @file_op_thread = Thread.new do
5941
+ operations = []
5942
+ begin
5943
+ items.each_with_index do |item, idx|
5944
+ @file_op_progress = " #{type.capitalize}ing #{idx + 1}/#{items.size}: #{File.basename(item)}".fg(226)
5945
+ dest = File.join(dest_dir, File.basename(item))
5946
+ dest += '1' if File.exist?(dest)
5947
+ while File.exist?(dest)
5948
+ dest = dest.chop + (dest[-1].to_i + 1).to_s
5949
+ end
5950
+ case type
5951
+ when 'copy'
5952
+ FileUtils.cp_r(item, dest)
5953
+ when 'move'
5954
+ FileUtils.mv(item, dest)
5955
+ end
5956
+ operations << { source_path: item, dest_path: dest }
5957
+ end
5958
+
5959
+ # Record undo
5960
+ unless operations.empty?
5961
+ undo_key = type == 'copy' ? :copies : :moves
5962
+ add_undo_operation({ type: type, undo_key => operations, timestamp: Time.now })
5963
+ end
5964
+
5965
+ @file_op_result = " #{type.capitalize} complete: #{operations.size} item(s)".fg(156)
5966
+ rescue => e
5967
+ @file_op_result = " #{type.capitalize} error: #{e.message}".fg(196)
5968
+ ensure
5969
+ @file_op_progress = nil
5970
+ @file_op_complete = true
5971
+ end
5972
+ end
5973
+ end
5974
+
5975
+ def copy_move_link_sync(type, items, dest_dir) # {{{3
5181
5976
  operations = []
5182
5977
 
5183
5978
  items.each do |item|
5184
5979
  dest = File.join(dest_dir, File.basename(item))
5185
5980
  dest += '1' if File.exist?(dest)
5186
5981
  while File.exist?(dest)
5187
- # Replace the last character (presumed to be a digit) by incrementing it
5188
5982
  dest = dest.chop + (dest[-1].to_i + 1).to_s
5189
5983
  end
5190
5984
  begin
@@ -5206,53 +6000,38 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
5206
6000
  @pB.say(e.to_s)
5207
6001
  end
5208
6002
  end
5209
-
5210
- # Record undo information if operations were successful
6003
+
6004
+ # Record undo information
5211
6005
  unless operations.empty?
5212
6006
  case type
5213
6007
  when 'copy'
5214
- add_undo_operation({
5215
- type: 'copy',
5216
- copies: operations,
5217
- timestamp: Time.now
5218
- })
6008
+ add_undo_operation({ type: 'copy', copies: operations, timestamp: Time.now })
5219
6009
  when 'move'
5220
- add_undo_operation({
5221
- type: 'move',
5222
- moves: operations,
5223
- timestamp: Time.now
5224
- })
6010
+ add_undo_operation({ type: 'move', moves: operations, timestamp: Time.now })
5225
6011
  when 'link'
5226
- add_undo_operation({
5227
- type: 'link',
5228
- links: operations,
5229
- timestamp: Time.now
5230
- })
6012
+ add_undo_operation({ type: 'link', links: operations, timestamp: Time.now })
5231
6013
  end
5232
6014
  end
5233
-
6015
+
5234
6016
  @tagged = []
5235
6017
 
5236
- # Store the currently selected item to restore selection after refresh (for move operations)
6018
+ # Restore selection after move operations
5237
6019
  if type == 'move'
5238
6020
  previously_selected = @selected ? File.basename(@selected) : nil
5239
6021
  end
5240
6022
 
5241
- # Set update flags for proper refresh - let render system handle the actual refresh
6023
+ # Set update flags for proper refresh
5242
6024
  if @dual_pane
5243
- # Update the destination pane
5244
6025
  if @active_pane == :left
5245
6026
  @pLeft.update = true
5246
6027
  else
5247
6028
  @pRight.update = true
5248
6029
  end
5249
- # Also update the preview pane
5250
6030
  @pPreview.update = true if @pPreview
5251
6031
  end
5252
6032
 
5253
6033
  render
5254
6034
 
5255
- # Restore selection after move operations
5256
6035
  if type == 'move' && previously_selected && @files
5257
6036
  restored_index = @files.index(previously_selected)
5258
6037
  @index = restored_index if restored_index
@@ -5325,6 +6104,12 @@ def open_selected(html = nil) # OPEN SELECTED FILE {{{2
5325
6104
  track_directory_access(@selected)
5326
6105
  return
5327
6106
  end
6107
+
6108
+ # Enter archive browsing mode for archive files (unless forced open with 'x')
6109
+ if !html && @selected && ARCHIVE_RE.match?(@selected) && File.file?(@selected)
6110
+ enter_archive_mode(@selected)
6111
+ return
6112
+ end
5328
6113
 
5329
6114
  # Track file access when opening files
5330
6115
  track_file_access(@selected)
@@ -5548,6 +6333,12 @@ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
5548
6333
  @pR.clear
5549
6334
  end
5550
6335
  begin
6336
+ # Handle archive mode separately
6337
+ if @archive_mode && @files && @files[@index] && @archive_files_cache[@index]
6338
+ show_archive_file_info(@archive_files_cache[@index])
6339
+ return
6340
+ end
6341
+
5551
6342
  # Handle remote mode separately
5552
6343
  if @remote_mode && @files && @files[@index] && @remote_files_cache[@index]
5553
6344
  selected_file = @remote_files_cache[@index]
@@ -5957,7 +6748,20 @@ $stdin.getc while $stdin.wait_readable(0)
5957
6748
  ## THE LOOP {{{2
5958
6749
  loop do
5959
6750
  @dir_old = Dir.pwd
5960
-
6751
+
6752
+ # Check async file operation progress
6753
+ if @file_op_complete
6754
+ @file_op_complete = false
6755
+ @pB.say(@file_op_result || " Operation complete.".fg(156))
6756
+ @file_op_result = nil
6757
+ @file_op_progress = nil
6758
+ @dir_cache.delete_if { |key, _| key.start_with?("#{Dir.pwd}:") }
6759
+ @pL.update = @pR.update = @pT.update = @pB.update = true
6760
+ elsif @file_op_progress
6761
+ @pB.say(@file_op_progress)
6762
+ @pB.update = false
6763
+ end
6764
+
5961
6765
  # redraw, but ignore TTY‐focus errors
5962
6766
  begin
5963
6767
  render
@@ -5976,6 +6780,13 @@ loop do
5976
6780
  rescue
5977
6781
  Dir.chdir
5978
6782
  end
6783
+ # If selected file was removed externally, force pane refresh (skip in archive/remote mode)
6784
+ if !@archive_mode && !@remote_mode && @selected && !File.exist?(@selected)
6785
+ @dir_cache.delete_if { |key, _| key.start_with?("#{Dir.pwd}:") }
6786
+ @index = [@index, (@files.size - 2)].min
6787
+ @index = 0 if @index.negative?
6788
+ @pL.update = @pR.update = @pT.update = true
6789
+ end
5979
6790
  # restore index if we cd'd
5980
6791
  if Dir.pwd != @dir_old
5981
6792
  @index = @directory[Dir.pwd] || 0