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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/bin/rtfm +920 -109
- 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 = '
|
|
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 (
|
|
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 @
|
|
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 @
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
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
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
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
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
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
|
-
|
|
2322
|
-
|
|
2379
|
+
|
|
2380
|
+
header << "\n"
|
|
2381
|
+
|
|
2323
2382
|
# Determine comparison type
|
|
2324
2383
|
if binary_file?(file1) || binary_file?(file2)
|
|
2325
|
-
text
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
2416
|
-
when '-' then 196
|
|
2417
|
-
when '@' then 226
|
|
2418
|
-
else 240
|
|
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
|
-
|
|
2424
|
-
|
|
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 >
|
|
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
|
-
|
|
4920
|
-
|
|
4921
|
-
text += @
|
|
4922
|
-
text += "
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|