rtfm-filemanager 7.5.3 → 8.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +15 -1
- data/bin/rtfm +911 -116
- data/docs/keyboard-reference.md +15 -3
- data/docs/remote-browsing.md +4 -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 = '
|
|
21
|
+
@version = '8.0.1' # Archive browsing, async file ops, scrollable diff viewer
|
|
22
22
|
|
|
23
23
|
# SAVE & STORE TERMINAL {{{1
|
|
24
24
|
ORIG_STTY = `stty -g`.chomp
|
|
@@ -206,7 +206,7 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
|
|
|
206
206
|
> = Follow symlink to the directory where the target resides
|
|
207
207
|
Ctrl-r = Show recently accessed files and directories (press number to jump)
|
|
208
208
|
Ctrl-e = Browse remote directories via SSH/SFTP (toggle remote mode)
|
|
209
|
-
In remote mode:
|
|
209
|
+
In remote mode: D=download, u=upload, s=shell, →=file info, ←=parent dir
|
|
210
210
|
SSH connections support comments: user@host:/path # Comment
|
|
211
211
|
|
|
212
212
|
DIRECTORY VIEWS
|
|
@@ -236,7 +236,7 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
|
|
|
236
236
|
P = PUT (move) tagged items here
|
|
237
237
|
c = Change/rename selected (adds command to bottom window)
|
|
238
238
|
E = Bulk rename tagged files using patterns (regex, templates, case conversion)
|
|
239
|
-
X = Compare two tagged files (
|
|
239
|
+
X = Compare two tagged files (scrollable diff with j/k, s=side-by-side, q=close)
|
|
240
240
|
s = Create symlink to tagged items here
|
|
241
241
|
d = Delete selected item and tagged items. Confirm with 'y'.
|
|
242
242
|
Moves items to trash directory (~/.rtfm/trash/) if @trash = true
|
|
@@ -267,6 +267,10 @@ CONFIG_FILE = File.join(RTFM_HOME, 'conf')
|
|
|
267
267
|
(fuzzy file finder must be installed https://github.com/junegunn/fzf)
|
|
268
268
|
|
|
269
269
|
ARCHIVES
|
|
270
|
+
ENTER = Browse inside archive (zip, tar, gz, bz2, xz, rar, 7z) - virtual browsing
|
|
271
|
+
Press LEFT at archive root to exit back to normal browsing
|
|
272
|
+
x = Open archive with external program (bypass archive browsing)
|
|
273
|
+
Inside an archive: d=delete, D=extract, p=add files, t=tag
|
|
270
274
|
z = Extract tagged zipped archive to current directory
|
|
271
275
|
Z = Create zipped archive from tagged files/directories
|
|
272
276
|
|
|
@@ -587,6 +591,12 @@ $stdin.set_encoding(Encoding::UTF_8)
|
|
|
587
591
|
@max_undo_levels = 20 # Maximum number of undo levels to keep
|
|
588
592
|
@undo_enabled = true # Enable/disable undo system
|
|
589
593
|
|
|
594
|
+
## Async file operations
|
|
595
|
+
@file_op_thread = nil # Current background operation thread
|
|
596
|
+
@file_op_progress = nil # Progress message for bottom pane display
|
|
597
|
+
@file_op_complete = false # Flag when operation finishes
|
|
598
|
+
@file_op_result = nil # Result message when operation finishes
|
|
599
|
+
|
|
590
600
|
## Recently accessed files/directories
|
|
591
601
|
@recent_files = [] # Last 50 accessed files
|
|
592
602
|
@recent_dirs = [] # Last 20 accessed directories
|
|
@@ -601,6 +611,15 @@ $stdin.set_encoding(Encoding::UTF_8)
|
|
|
601
611
|
@remote_path = '~' # Current remote directory path
|
|
602
612
|
@remote_files_cache = [] # Cache of current remote directory files with full info
|
|
603
613
|
|
|
614
|
+
## Archive browsing variables
|
|
615
|
+
@archive_mode = false # Whether currently browsing inside an archive
|
|
616
|
+
@archive_path = nil # Full path to the archive file on disk
|
|
617
|
+
@archive_current_dir = '' # Current virtual directory within the archive
|
|
618
|
+
@archive_entries = [] # Parsed entries: [{name:, type:, size:, full_path:}]
|
|
619
|
+
@archive_files_cache = [] # Filtered entries for current virtual directory
|
|
620
|
+
@archive_origin_dir = nil # Directory we were in before entering archive mode
|
|
621
|
+
@archive_origin_tagged = [] # Tagged files from before entering archive mode
|
|
622
|
+
|
|
604
623
|
# TAB MANAGEMENT FUNCTIONS {{{1
|
|
605
624
|
def create_tab(directory = Dir.pwd, name = nil) # {{{2
|
|
606
625
|
@tab_counter += 1
|
|
@@ -857,6 +876,7 @@ preview_specs = {
|
|
|
857
876
|
}
|
|
858
877
|
@imagefile ||= /\.(?:png|jpe?g|bmp|gif|webp|tiff?|svg)$/i
|
|
859
878
|
@pdffile ||= /\.pdf$/i
|
|
879
|
+
ARCHIVE_RE = /\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2?|tar\.xz|txz|tar\.zst|rar|7z)$/i
|
|
860
880
|
# rubocop:enable Style/StringLiterals
|
|
861
881
|
|
|
862
882
|
# USER PLUGINS {{{1
|
|
@@ -1149,6 +1169,12 @@ def refresh_all # {{{3
|
|
|
1149
1169
|
@remote_files_cache = []
|
|
1150
1170
|
@pL.update = true
|
|
1151
1171
|
end
|
|
1172
|
+
# Refresh archive listing if in archive mode
|
|
1173
|
+
if @archive_mode && @archive_path
|
|
1174
|
+
@archive_files_cache = []
|
|
1175
|
+
@archive_entries = parse_archive_listing(@archive_path)
|
|
1176
|
+
@pL.update = true
|
|
1177
|
+
end
|
|
1152
1178
|
refresh
|
|
1153
1179
|
end
|
|
1154
1180
|
|
|
@@ -1235,8 +1261,8 @@ end
|
|
|
1235
1261
|
def move_down # {{{3
|
|
1236
1262
|
@index = @index >= @max_index ? @min_index : @index + 1
|
|
1237
1263
|
@pL.update = true
|
|
1238
|
-
# In remote mode, only update bottom pane (for file attributes)
|
|
1239
|
-
if @remote_mode
|
|
1264
|
+
# In remote/archive mode, only update bottom pane (for file attributes)
|
|
1265
|
+
if @remote_mode || @archive_mode
|
|
1240
1266
|
@pB.update = true
|
|
1241
1267
|
else
|
|
1242
1268
|
@pR.update = @pB.update = true
|
|
@@ -1246,8 +1272,8 @@ end
|
|
|
1246
1272
|
def move_up # {{{3
|
|
1247
1273
|
@index = @index <= @min_index ? @max_index : @index - 1
|
|
1248
1274
|
@pL.update = true
|
|
1249
|
-
# In remote mode, only update bottom pane (for file attributes)
|
|
1250
|
-
if @remote_mode
|
|
1275
|
+
# In remote/archive mode, only update bottom pane (for file attributes)
|
|
1276
|
+
if @remote_mode || @archive_mode
|
|
1251
1277
|
@pB.update = true
|
|
1252
1278
|
else
|
|
1253
1279
|
@pR.update = @pB.update = true
|
|
@@ -1256,7 +1282,18 @@ end
|
|
|
1256
1282
|
|
|
1257
1283
|
def move_left # {{{3
|
|
1258
1284
|
clear_image
|
|
1259
|
-
if @
|
|
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,502 @@ def exit_remote_mode # {{{3
|
|
|
2589
2744
|
render
|
|
2590
2745
|
end
|
|
2591
2746
|
|
|
2747
|
+
# ARCHIVE BROWSING {{{2
|
|
2748
|
+
def parse_archive_listing(archive_path) # {{{3
|
|
2749
|
+
escaped = Shellwords.escape(archive_path)
|
|
2750
|
+
entries = []
|
|
2751
|
+
begin
|
|
2752
|
+
raw = case archive_path.downcase
|
|
2753
|
+
when /\.zip$/
|
|
2754
|
+
command("unzip -l #{escaped} 2>/dev/null")
|
|
2755
|
+
when /\.rar$/
|
|
2756
|
+
command("unrar l #{escaped} 2>/dev/null")
|
|
2757
|
+
when /\.7z$/
|
|
2758
|
+
command("7z l #{escaped} 2>/dev/null")
|
|
2759
|
+
when /\.tar\.bz2$|\.tbz2?$/
|
|
2760
|
+
command("tar -tjvf #{escaped} 2>/dev/null")
|
|
2761
|
+
when /\.tar\.xz$|\.txz$/
|
|
2762
|
+
command("tar -tJvf #{escaped} 2>/dev/null")
|
|
2763
|
+
when /\.tar\.zst$/
|
|
2764
|
+
command("tar --zstd -tvf #{escaped} 2>/dev/null")
|
|
2765
|
+
when /\.tar$|\.tar\.gz$|\.tgz$|\.gz$/
|
|
2766
|
+
command("tar -tzvf #{escaped} 2>/dev/null")
|
|
2767
|
+
else
|
|
2768
|
+
command("tar -tzvf #{escaped} 2>/dev/null")
|
|
2769
|
+
end
|
|
2770
|
+
|
|
2771
|
+
case archive_path.downcase
|
|
2772
|
+
when /\.zip$/
|
|
2773
|
+
parse_zip_listing(raw, entries)
|
|
2774
|
+
when /\.rar$/
|
|
2775
|
+
parse_rar_listing(raw, entries)
|
|
2776
|
+
when /\.7z$/
|
|
2777
|
+
parse_7z_listing(raw, entries)
|
|
2778
|
+
else
|
|
2779
|
+
parse_tar_listing(raw, entries)
|
|
2780
|
+
end
|
|
2781
|
+
rescue => e
|
|
2782
|
+
@pB.say("Error reading archive: #{e.message}".fg(196))
|
|
2783
|
+
end
|
|
2784
|
+
entries
|
|
2785
|
+
end
|
|
2786
|
+
|
|
2787
|
+
def parse_tar_listing(raw, entries) # {{{3
|
|
2788
|
+
raw.each_line do |line|
|
|
2789
|
+
parts = line.strip.split(/\s+/, 6)
|
|
2790
|
+
next if parts.size < 6
|
|
2791
|
+
perms = parts[0]
|
|
2792
|
+
size = parts[2].to_i
|
|
2793
|
+
raw_path = parts[5]
|
|
2794
|
+
next if raw_path.nil? || raw_path.empty? || raw_path == './' || raw_path == '.'
|
|
2795
|
+
path = raw_path.sub(%r{^\./}, '') # Remove leading ./ for display
|
|
2796
|
+
next if path.empty?
|
|
2797
|
+
is_dir = perms.start_with?('d') || path.end_with?('/')
|
|
2798
|
+
path = path.chomp('/')
|
|
2799
|
+
# Store raw_path (with ./ if present) for tar extract/delete operations
|
|
2800
|
+
entries << { name: File.basename(path), type: is_dir ? 'directory' : 'file',
|
|
2801
|
+
size: size, full_path: path, permissions: perms,
|
|
2802
|
+
raw_path: raw_path.chomp('/') }
|
|
2803
|
+
end
|
|
2804
|
+
end
|
|
2805
|
+
|
|
2806
|
+
def parse_zip_listing(raw, entries) # {{{3
|
|
2807
|
+
in_files = false
|
|
2808
|
+
raw.each_line do |line|
|
|
2809
|
+
if line =~ /^\s*-+\s+-+/
|
|
2810
|
+
in_files = !in_files
|
|
2811
|
+
next
|
|
2812
|
+
end
|
|
2813
|
+
next unless in_files
|
|
2814
|
+
# Format: Length Date Time Name
|
|
2815
|
+
if line =~ /^\s*(\d+)\s+\S+\s+\S+\s+(.+)$/
|
|
2816
|
+
size = $1.to_i
|
|
2817
|
+
path = $2.strip
|
|
2818
|
+
next if path.empty? || path == '.' || path == './'
|
|
2819
|
+
is_dir = path.end_with?('/')
|
|
2820
|
+
path = path.chomp('/')
|
|
2821
|
+
next if path.empty?
|
|
2822
|
+
entries << { name: File.basename(path), type: is_dir ? 'directory' : 'file',
|
|
2823
|
+
size: size, full_path: path, permissions: '' }
|
|
2824
|
+
end
|
|
2825
|
+
end
|
|
2826
|
+
end
|
|
2827
|
+
|
|
2828
|
+
def parse_rar_listing(raw, entries) # {{{3
|
|
2829
|
+
in_files = false
|
|
2830
|
+
raw.each_line do |line|
|
|
2831
|
+
if line =~ /^\s*-+/
|
|
2832
|
+
in_files = !in_files
|
|
2833
|
+
next
|
|
2834
|
+
end
|
|
2835
|
+
next unless in_files
|
|
2836
|
+
parts = line.strip.split(/\s+/)
|
|
2837
|
+
next if parts.size < 5
|
|
2838
|
+
# unrar l format: Attributes Size Packed Ratio Date Time Name
|
|
2839
|
+
perms = parts[0]
|
|
2840
|
+
size = parts[1].to_i
|
|
2841
|
+
path = parts[6..].join(' ')
|
|
2842
|
+
next if path.nil? || path.empty?
|
|
2843
|
+
is_dir = perms.include?('D') || perms.include?('d') || path.end_with?('/')
|
|
2844
|
+
path = path.chomp('/')
|
|
2845
|
+
next if path.empty?
|
|
2846
|
+
entries << { name: File.basename(path), type: is_dir ? 'directory' : 'file',
|
|
2847
|
+
size: size, full_path: path, permissions: perms }
|
|
2848
|
+
end
|
|
2849
|
+
end
|
|
2850
|
+
|
|
2851
|
+
def parse_7z_listing(raw, entries) # {{{3
|
|
2852
|
+
in_files = false
|
|
2853
|
+
raw.each_line do |line|
|
|
2854
|
+
if line =~ /^-{4,}/
|
|
2855
|
+
in_files = !in_files
|
|
2856
|
+
next
|
|
2857
|
+
end
|
|
2858
|
+
next unless in_files
|
|
2859
|
+
# 7z l format: Date Time Attr Size Compressed Name
|
|
2860
|
+
parts = line.strip.split(/\s+/, 6)
|
|
2861
|
+
next if parts.size < 6
|
|
2862
|
+
attr = parts[2]
|
|
2863
|
+
size = parts[3].to_i
|
|
2864
|
+
path = parts[5]
|
|
2865
|
+
next if path.nil? || path.empty?
|
|
2866
|
+
is_dir = attr.include?('D')
|
|
2867
|
+
path = path.chomp('/')
|
|
2868
|
+
next if path.empty?
|
|
2869
|
+
entries << { name: File.basename(path), type: is_dir ? 'directory' : 'file',
|
|
2870
|
+
size: size, full_path: path, permissions: attr }
|
|
2871
|
+
end
|
|
2872
|
+
end
|
|
2873
|
+
|
|
2874
|
+
def archive_entries_for_dir(virtual_dir) # {{{3
|
|
2875
|
+
# Collect implicit directories from paths
|
|
2876
|
+
all_dirs = Set.new
|
|
2877
|
+
@archive_entries.each do |entry|
|
|
2878
|
+
path = entry[:full_path]
|
|
2879
|
+
# Add all parent directories
|
|
2880
|
+
parts = path.split('/')
|
|
2881
|
+
(1...parts.size).each do |i|
|
|
2882
|
+
all_dirs << parts[0, i].join('/')
|
|
2883
|
+
end
|
|
2884
|
+
end
|
|
2885
|
+
|
|
2886
|
+
prefix = virtual_dir.empty? ? '' : "#{virtual_dir}/"
|
|
2887
|
+
depth = virtual_dir.empty? ? 0 : virtual_dir.count('/') + 1
|
|
2888
|
+
seen = Set.new
|
|
2889
|
+
result = []
|
|
2890
|
+
|
|
2891
|
+
# Add direct children from entries
|
|
2892
|
+
@archive_entries.each do |entry|
|
|
2893
|
+
path = entry[:full_path]
|
|
2894
|
+
next unless virtual_dir.empty? ? true : path.start_with?(prefix)
|
|
2895
|
+
|
|
2896
|
+
parts = path.split('/')
|
|
2897
|
+
next if parts.size <= depth # Skip the directory itself
|
|
2898
|
+
|
|
2899
|
+
child_name = parts[depth]
|
|
2900
|
+
next if seen.include?(child_name)
|
|
2901
|
+
seen << child_name
|
|
2902
|
+
|
|
2903
|
+
child_path = parts[0..depth].join('/')
|
|
2904
|
+
if parts.size == depth + 1
|
|
2905
|
+
# Direct child file or directory
|
|
2906
|
+
result << entry.merge(name: child_name)
|
|
2907
|
+
else
|
|
2908
|
+
# Implicit subdirectory
|
|
2909
|
+
result << { name: child_name, type: 'directory', size: 0,
|
|
2910
|
+
full_path: child_path, permissions: 'drwxr-xr-x' }
|
|
2911
|
+
end
|
|
2912
|
+
end
|
|
2913
|
+
|
|
2914
|
+
# Sort: directories first, then alphabetically
|
|
2915
|
+
result.sort_by { |e| [e[:type] == 'directory' ? 0 : 1, e[:name].downcase] }
|
|
2916
|
+
end
|
|
2917
|
+
|
|
2918
|
+
def enter_archive_mode(archive_path) # {{{3
|
|
2919
|
+
@archive_origin_dir = Dir.pwd
|
|
2920
|
+
@archive_origin_tagged = @tagged.dup
|
|
2921
|
+
@archive_mode = true
|
|
2922
|
+
@archive_path = archive_path
|
|
2923
|
+
@archive_current_dir = ''
|
|
2924
|
+
@index = 0
|
|
2925
|
+
@tagged = []
|
|
2926
|
+
@archive_entries = parse_archive_listing(archive_path)
|
|
2927
|
+
@archive_files_cache = []
|
|
2928
|
+
if @archive_entries.empty?
|
|
2929
|
+
@pB.say("Archive is empty or unreadable: #{File.basename(archive_path)}".fg(196))
|
|
2930
|
+
@archive_mode = false
|
|
2931
|
+
@archive_path = nil
|
|
2932
|
+
@tagged = @archive_origin_tagged
|
|
2933
|
+
return
|
|
2934
|
+
end
|
|
2935
|
+
@pB.say(" ARCHIVE: #{File.basename(archive_path)} (#{@archive_entries.size} entries, LEFT to exit)".fg(226))
|
|
2936
|
+
@pL.update = @pR.update = @pT.update = @pB.update = true
|
|
2937
|
+
dirlist
|
|
2938
|
+
render
|
|
2939
|
+
end
|
|
2940
|
+
|
|
2941
|
+
def exit_archive_mode # {{{3
|
|
2942
|
+
@archive_mode = false
|
|
2943
|
+
@archive_path = nil
|
|
2944
|
+
@archive_current_dir = ''
|
|
2945
|
+
@archive_entries = []
|
|
2946
|
+
@archive_files_cache = []
|
|
2947
|
+
@tagged = @archive_origin_tagged || []
|
|
2948
|
+
@archive_origin_tagged = []
|
|
2949
|
+
@archive_origin_dir = nil
|
|
2950
|
+
@index = 0
|
|
2951
|
+
@pB.say("Returned to local browsing".fg(156))
|
|
2952
|
+
@pL.update = @pR.update = @pT.update = @pB.update = true
|
|
2953
|
+
dirlist
|
|
2954
|
+
render
|
|
2955
|
+
end
|
|
2956
|
+
|
|
2957
|
+
def show_archive_file_info(entry) # {{{3
|
|
2958
|
+
info_text = "Archive File Information\n".b.fg(226)
|
|
2959
|
+
info_text << "=" * 40 + "\n\n"
|
|
2960
|
+
info_text << "Archive: #{File.basename(@archive_path)}\n".fg(255)
|
|
2961
|
+
info_text << "Name: #{entry[:name]}\n".fg(255)
|
|
2962
|
+
info_text << "Type: #{entry[:type].capitalize}\n".fg(255)
|
|
2963
|
+
info_text << "Size: #{format_size_simple(entry[:size])}\n".fg(255)
|
|
2964
|
+
info_text << "Path: #{entry[:full_path]}\n".fg(240)
|
|
2965
|
+
info_text << "Permissions: #{entry[:permissions]}\n".fg(255) unless entry[:permissions].to_s.empty?
|
|
2966
|
+
info_text << "\n"
|
|
2967
|
+
info_text << "Actions:\n".fg(226)
|
|
2968
|
+
if entry[:type] == 'directory'
|
|
2969
|
+
info_text << " Enter = Navigate into directory\n".fg(240)
|
|
2970
|
+
end
|
|
2971
|
+
info_text << " d = Delete from archive\n".fg(240)
|
|
2972
|
+
info_text << " D = Extract to origin directory\n".fg(240)
|
|
2973
|
+
info_text << " p = Add local files into archive\n".fg(240)
|
|
2974
|
+
info_text << " t = Tag/untag for bulk operations\n".fg(240)
|
|
2975
|
+
info_text << " LEFT = Go to parent / exit archive\n".fg(240)
|
|
2976
|
+
@pR.say(info_text)
|
|
2977
|
+
@pR.update = false
|
|
2978
|
+
end
|
|
2979
|
+
|
|
2980
|
+
def archive_extract_entries # {{{3
|
|
2981
|
+
return unless @archive_mode && @archive_path
|
|
2982
|
+
|
|
2983
|
+
# Get entries to extract: tagged archive entries, or selected
|
|
2984
|
+
entries = archive_tagged_or_selected
|
|
2985
|
+
return if entries.empty?
|
|
2986
|
+
|
|
2987
|
+
dest = @archive_origin_dir || Dir.pwd
|
|
2988
|
+
escaped_archive = Shellwords.escape(@archive_path)
|
|
2989
|
+
entry_paths = entries.map { |e| e[:full_path] }
|
|
2990
|
+
# For tar archives, use raw_path (preserves ./ prefix) for correct extraction
|
|
2991
|
+
tar_paths = entries.map { |e| e[:raw_path] || e[:full_path] }
|
|
2992
|
+
|
|
2993
|
+
@pB.say(" Extracting #{entries.size} item(s) to #{dest}...".fg(226))
|
|
2994
|
+
|
|
2995
|
+
success = case @archive_path.downcase
|
|
2996
|
+
when /\.zip$/
|
|
2997
|
+
escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
|
|
2998
|
+
system("unzip -o #{escaped_archive} #{escaped_paths} -d #{Shellwords.escape(dest)} >/dev/null 2>&1")
|
|
2999
|
+
when /\.rar$/
|
|
3000
|
+
escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
|
|
3001
|
+
system("unrar x -o+ #{escaped_archive} #{escaped_paths} #{Shellwords.escape(dest)}/ >/dev/null 2>&1")
|
|
3002
|
+
when /\.7z$/
|
|
3003
|
+
escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
|
|
3004
|
+
system("7z x #{escaped_archive} #{escaped_paths} -o#{Shellwords.escape(dest)} -y >/dev/null 2>&1")
|
|
3005
|
+
else
|
|
3006
|
+
# tar variants — use raw_path to match archive entries exactly
|
|
3007
|
+
escaped_paths = tar_paths.map { |p| Shellwords.escape(p) }.join(' ')
|
|
3008
|
+
tar_flag = tar_decompress_flag(@archive_path)
|
|
3009
|
+
system("tar x#{tar_flag}f #{escaped_archive} -C #{Shellwords.escape(dest)} #{escaped_paths} 2>/dev/null")
|
|
3010
|
+
end
|
|
3011
|
+
|
|
3012
|
+
if success
|
|
3013
|
+
@pB.say(" Extracted #{entries.size} item(s) to #{dest}".fg(156))
|
|
3014
|
+
else
|
|
3015
|
+
@pB.say(" Extraction failed".fg(196))
|
|
3016
|
+
end
|
|
3017
|
+
@tagged = []
|
|
3018
|
+
@pL.update = @pR.update = true
|
|
3019
|
+
end
|
|
3020
|
+
|
|
3021
|
+
def archive_delete_entries # {{{3
|
|
3022
|
+
return unless @archive_mode && @archive_path
|
|
3023
|
+
|
|
3024
|
+
entries = archive_tagged_or_selected
|
|
3025
|
+
return if entries.empty?
|
|
3026
|
+
|
|
3027
|
+
clear_image
|
|
3028
|
+
names = entries.map { |e| e[:name] }
|
|
3029
|
+
warning = "\nDelete from Archive\n".b.fg(196)
|
|
3030
|
+
warning << "=" * 40 + "\n\n"
|
|
3031
|
+
warning << "Archive: #{File.basename(@archive_path)}\n".fg(255)
|
|
3032
|
+
warning << "\nItems to delete:\n".fg(226)
|
|
3033
|
+
entries.first(10).each { |e| warning << " #{e[:full_path]}\n".fg(255) }
|
|
3034
|
+
warning << " ... and #{entries.size - 10} more\n".fg(240) if entries.size > 10
|
|
3035
|
+
warning << "\nThis modifies the archive file permanently!\n".fg(196).b
|
|
3036
|
+
warning << "Press " + "y".fg(156).b + " to confirm, any other key to cancel".fg(249)
|
|
3037
|
+
@pR.say(warning)
|
|
3038
|
+
@pB.say(" Delete #{entries.size} item(s) from archive? (y/n)".fg(196))
|
|
3039
|
+
|
|
3040
|
+
unless getchr == 'y'
|
|
3041
|
+
@pB.say(" Cancelled".fg(240))
|
|
3042
|
+
@pR.update = true
|
|
3043
|
+
return
|
|
3044
|
+
end
|
|
3045
|
+
|
|
3046
|
+
escaped_archive = Shellwords.escape(@archive_path)
|
|
3047
|
+
entry_paths = entries.map { |e| e[:full_path] }
|
|
3048
|
+
|
|
3049
|
+
success = case @archive_path.downcase
|
|
3050
|
+
when /\.zip$/
|
|
3051
|
+
escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
|
|
3052
|
+
system("zip -d #{escaped_archive} #{escaped_paths} >/dev/null 2>&1")
|
|
3053
|
+
when /\.rar$/
|
|
3054
|
+
escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
|
|
3055
|
+
system("rar d #{escaped_archive} #{escaped_paths} >/dev/null 2>&1")
|
|
3056
|
+
when /\.7z$/
|
|
3057
|
+
escaped_paths = entry_paths.map { |p| Shellwords.escape(p) }.join(' ')
|
|
3058
|
+
system("7z d #{escaped_archive} #{escaped_paths} >/dev/null 2>&1")
|
|
3059
|
+
else
|
|
3060
|
+
archive_tar_modify(:delete, entry_paths)
|
|
3061
|
+
end
|
|
3062
|
+
|
|
3063
|
+
if success
|
|
3064
|
+
@pB.say(" Deleted #{entries.size} item(s) from archive".fg(156))
|
|
3065
|
+
archive_refresh
|
|
3066
|
+
else
|
|
3067
|
+
@pB.say(" Delete failed".fg(196))
|
|
3068
|
+
end
|
|
3069
|
+
@tagged = []
|
|
3070
|
+
@pR.update = true
|
|
3071
|
+
end
|
|
3072
|
+
|
|
3073
|
+
def archive_add_files # {{{3
|
|
3074
|
+
return unless @archive_mode && @archive_path
|
|
3075
|
+
|
|
3076
|
+
# Use files tagged before entering archive mode, or ask
|
|
3077
|
+
if @archive_origin_tagged.empty?
|
|
3078
|
+
@pB.say(" No files were tagged before entering archive. Tag files first, then enter archive.".fg(196))
|
|
3079
|
+
return
|
|
3080
|
+
end
|
|
3081
|
+
|
|
3082
|
+
files_to_add = @archive_origin_tagged.select { |f| File.exist?(f) }
|
|
3083
|
+
if files_to_add.empty?
|
|
3084
|
+
@pB.say(" Tagged files no longer exist".fg(196))
|
|
3085
|
+
return
|
|
3086
|
+
end
|
|
3087
|
+
|
|
3088
|
+
clear_image
|
|
3089
|
+
info = "\nAdd Files to Archive\n".b.fg(226)
|
|
3090
|
+
info << "=" * 40 + "\n\n"
|
|
3091
|
+
info << "Archive: #{File.basename(@archive_path)}\n".fg(255)
|
|
3092
|
+
target = @archive_current_dir.empty? ? "archive root" : @archive_current_dir
|
|
3093
|
+
info << "Target: #{target}\n".fg(255)
|
|
3094
|
+
info << "\nFiles to add:\n".fg(226)
|
|
3095
|
+
files_to_add.first(10).each { |f| info << " #{File.basename(f)}\n".fg(255) }
|
|
3096
|
+
info << " ... and #{files_to_add.size - 10} more\n".fg(240) if files_to_add.size > 10
|
|
3097
|
+
info << "\nPress " + "y".fg(156).b + " to confirm, any other key to cancel".fg(249)
|
|
3098
|
+
@pR.say(info)
|
|
3099
|
+
@pB.say(" Add #{files_to_add.size} file(s) to archive? (y/n)".fg(226))
|
|
3100
|
+
|
|
3101
|
+
unless getchr == 'y'
|
|
3102
|
+
@pB.say(" Cancelled".fg(240))
|
|
3103
|
+
@pR.update = true
|
|
3104
|
+
return
|
|
3105
|
+
end
|
|
3106
|
+
|
|
3107
|
+
escaped_archive = Shellwords.escape(@archive_path)
|
|
3108
|
+
escaped_files = files_to_add.map { |f| Shellwords.escape(f) }.join(' ')
|
|
3109
|
+
|
|
3110
|
+
success = case @archive_path.downcase
|
|
3111
|
+
when /\.zip$/
|
|
3112
|
+
if @archive_current_dir.empty?
|
|
3113
|
+
system("zip -j #{escaped_archive} #{escaped_files} >/dev/null 2>&1")
|
|
3114
|
+
else
|
|
3115
|
+
# zip doesn't have a native "add to subdir" flag - use a temp dir
|
|
3116
|
+
archive_add_to_subdir_zip(files_to_add)
|
|
3117
|
+
end
|
|
3118
|
+
when /\.rar$/
|
|
3119
|
+
# rar -ap sets the archive path prefix
|
|
3120
|
+
ap = @archive_current_dir.empty? ? '' : "-ap#{Shellwords.escape(@archive_current_dir)}"
|
|
3121
|
+
system("rar a #{ap} #{escaped_archive} #{escaped_files} >/dev/null 2>&1")
|
|
3122
|
+
when /\.7z$/
|
|
3123
|
+
# 7z doesn't support adding to subdirectory natively - use temp dir approach
|
|
3124
|
+
if @archive_current_dir.empty?
|
|
3125
|
+
system("7z a #{escaped_archive} #{escaped_files} >/dev/null 2>&1")
|
|
3126
|
+
else
|
|
3127
|
+
archive_add_to_subdir_generic(files_to_add)
|
|
3128
|
+
end
|
|
3129
|
+
else
|
|
3130
|
+
archive_tar_modify(:add, files_to_add)
|
|
3131
|
+
end
|
|
3132
|
+
|
|
3133
|
+
if success
|
|
3134
|
+
@pB.say(" Added #{files_to_add.size} file(s) to archive".fg(156))
|
|
3135
|
+
@archive_origin_tagged = []
|
|
3136
|
+
archive_refresh
|
|
3137
|
+
else
|
|
3138
|
+
@pB.say(" Add failed".fg(196))
|
|
3139
|
+
end
|
|
3140
|
+
@pR.update = true
|
|
3141
|
+
end
|
|
3142
|
+
|
|
3143
|
+
def archive_add_to_subdir_zip(files) # {{{3
|
|
3144
|
+
# To add files to a specific subdirectory in a zip, create temp structure
|
|
3145
|
+
Dir.mktmpdir('rtfm_zip_add') do |tmpdir|
|
|
3146
|
+
target = File.join(tmpdir, @archive_current_dir)
|
|
3147
|
+
FileUtils.mkdir_p(target)
|
|
3148
|
+
files.each { |f| FileUtils.cp(f, target) }
|
|
3149
|
+
Dir.chdir(tmpdir) do
|
|
3150
|
+
escaped_files = files.map { |f| Shellwords.escape(File.join(@archive_current_dir, File.basename(f))) }.join(' ')
|
|
3151
|
+
system("zip #{Shellwords.escape(@archive_path)} #{escaped_files} >/dev/null 2>&1")
|
|
3152
|
+
end
|
|
3153
|
+
end
|
|
3154
|
+
end
|
|
3155
|
+
|
|
3156
|
+
def archive_add_to_subdir_generic(files) # {{{3
|
|
3157
|
+
Dir.mktmpdir('rtfm_7z_add') do |tmpdir|
|
|
3158
|
+
target = File.join(tmpdir, @archive_current_dir)
|
|
3159
|
+
FileUtils.mkdir_p(target)
|
|
3160
|
+
files.each { |f| FileUtils.cp(f, target) }
|
|
3161
|
+
Dir.chdir(tmpdir) do
|
|
3162
|
+
escaped_files = files.map { |f| Shellwords.escape(File.join(@archive_current_dir, File.basename(f))) }.join(' ')
|
|
3163
|
+
system("7z a #{Shellwords.escape(@archive_path)} #{escaped_files} >/dev/null 2>&1")
|
|
3164
|
+
end
|
|
3165
|
+
end
|
|
3166
|
+
end
|
|
3167
|
+
|
|
3168
|
+
def archive_tar_modify(action, paths) # {{{3
|
|
3169
|
+
# For tar-based archives: extract all, modify, re-archive
|
|
3170
|
+
# This is expensive but tar doesn't support in-place modifications on compressed archives
|
|
3171
|
+
Dir.mktmpdir('rtfm_tar_mod') do |tmpdir|
|
|
3172
|
+
escaped_archive = Shellwords.escape(@archive_path)
|
|
3173
|
+
tar_flag = tar_decompress_flag(@archive_path)
|
|
3174
|
+
|
|
3175
|
+
# Extract everything
|
|
3176
|
+
unless system("tar x#{tar_flag}f #{escaped_archive} -C #{Shellwords.escape(tmpdir)} 2>/dev/null")
|
|
3177
|
+
@pB.say(" Failed to extract archive for modification".fg(196))
|
|
3178
|
+
return false
|
|
3179
|
+
end
|
|
3180
|
+
|
|
3181
|
+
case action
|
|
3182
|
+
when :delete
|
|
3183
|
+
paths.each do |p|
|
|
3184
|
+
target = File.join(tmpdir, p)
|
|
3185
|
+
FileUtils.rm_rf(target) if File.exist?(target) || File.symlink?(target)
|
|
3186
|
+
end
|
|
3187
|
+
when :add
|
|
3188
|
+
target_dir = @archive_current_dir.empty? ? tmpdir : File.join(tmpdir, @archive_current_dir)
|
|
3189
|
+
FileUtils.mkdir_p(target_dir)
|
|
3190
|
+
paths.each { |f| FileUtils.cp(f, target_dir) }
|
|
3191
|
+
end
|
|
3192
|
+
|
|
3193
|
+
# Re-archive
|
|
3194
|
+
tar_compress = tar_compress_flag(@archive_path)
|
|
3195
|
+
Dir.chdir(tmpdir) do
|
|
3196
|
+
all_entries = Dir.glob('*', File::FNM_DOTMATCH).reject { |e| e == '.' || e == '..' }
|
|
3197
|
+
escaped_entries = all_entries.map { |e| Shellwords.escape(e) }.join(' ')
|
|
3198
|
+
system("tar c#{tar_compress}f #{escaped_archive} #{escaped_entries} 2>/dev/null")
|
|
3199
|
+
end
|
|
3200
|
+
end
|
|
3201
|
+
end
|
|
3202
|
+
|
|
3203
|
+
def tar_decompress_flag(path) # {{{3
|
|
3204
|
+
case path.downcase
|
|
3205
|
+
when /\.tar\.gz$|\.tgz$/ then 'z'
|
|
3206
|
+
when /\.tar\.bz2$|\.tbz2?$/ then 'j'
|
|
3207
|
+
when /\.tar\.xz$|\.txz$/ then 'J'
|
|
3208
|
+
when /\.tar\.zst$/ then ' --zstd '
|
|
3209
|
+
else ''
|
|
3210
|
+
end
|
|
3211
|
+
end
|
|
3212
|
+
|
|
3213
|
+
def tar_compress_flag(path) # {{{3
|
|
3214
|
+
case path.downcase
|
|
3215
|
+
when /\.tar\.gz$|\.tgz$/ then 'z'
|
|
3216
|
+
when /\.tar\.bz2$|\.tbz2?$/ then 'j'
|
|
3217
|
+
when /\.tar\.xz$|\.txz$/ then 'J'
|
|
3218
|
+
when /\.tar\.zst$/ then ' --zstd '
|
|
3219
|
+
else ''
|
|
3220
|
+
end
|
|
3221
|
+
end
|
|
3222
|
+
|
|
3223
|
+
def archive_tagged_or_selected # {{{3
|
|
3224
|
+
if @tagged.any?
|
|
3225
|
+
# Map tagged file names back to archive entries
|
|
3226
|
+
@archive_files_cache.select { |e| @tagged.include?("#{@archive_path}:#{e[:full_path]}") }
|
|
3227
|
+
elsif @archive_files_cache[@index]
|
|
3228
|
+
[@archive_files_cache[@index]]
|
|
3229
|
+
else
|
|
3230
|
+
[]
|
|
3231
|
+
end
|
|
3232
|
+
end
|
|
3233
|
+
|
|
3234
|
+
def archive_refresh # {{{3
|
|
3235
|
+
@archive_entries = parse_archive_listing(@archive_path)
|
|
3236
|
+
@archive_files_cache = []
|
|
3237
|
+
@index = 0 if @index >= archive_entries_for_dir(@archive_current_dir).size
|
|
3238
|
+
@pL.update = @pR.update = @pT.update = true
|
|
3239
|
+
dirlist
|
|
3240
|
+
render
|
|
3241
|
+
end
|
|
3242
|
+
|
|
2592
3243
|
def show_remote_file_info(file) # {{{3
|
|
2593
3244
|
info_text = "Remote File Information\n".b.fg(156)
|
|
2594
3245
|
info_text << "=" * 40 + "\n\n"
|
|
@@ -3202,7 +3853,7 @@ def build_remote_help # {{{3
|
|
|
3202
3853
|
|
|
3203
3854
|
help_text << "Remote Navigation:\n".fg(226)
|
|
3204
3855
|
help_text << " • Use arrow keys to navigate directories\n"
|
|
3205
|
-
help_text << " • Press " + "
|
|
3856
|
+
help_text << " • Press " + "D".fg(156) + " to download files\n"
|
|
3206
3857
|
help_text << " • Press " + "u".fg(156) + " to upload files\n"
|
|
3207
3858
|
help_text << " • Press " + "s".fg(156) + " to open SSH shell\n"
|
|
3208
3859
|
help_text << " • Press " + "→".fg(156) + " to view file info\n"
|
|
@@ -3213,14 +3864,17 @@ end
|
|
|
3213
3864
|
|
|
3214
3865
|
# MANIPULATE ITEMS {{{2
|
|
3215
3866
|
def copy_items # {{{3
|
|
3867
|
+
if @archive_mode
|
|
3868
|
+
# In archive mode, 'p' adds local files into the archive
|
|
3869
|
+
archive_add_files
|
|
3870
|
+
return
|
|
3871
|
+
end
|
|
3216
3872
|
copy_move_link('copy')
|
|
3217
|
-
# Dual-pane refresh is handled in copy_move_link function
|
|
3218
3873
|
@pR.update = true
|
|
3219
3874
|
end
|
|
3220
3875
|
|
|
3221
3876
|
def move_items # {{{3
|
|
3222
3877
|
copy_move_link('move')
|
|
3223
|
-
# Dual-pane refresh is handled in copy_move_link function
|
|
3224
3878
|
@pR.update = true
|
|
3225
3879
|
end
|
|
3226
3880
|
|
|
@@ -3281,11 +3935,14 @@ def link_items # {{{3
|
|
|
3281
3935
|
end
|
|
3282
3936
|
|
|
3283
3937
|
def delete_items # {{{3
|
|
3284
|
-
if @
|
|
3285
|
-
# In
|
|
3286
|
-
|
|
3938
|
+
if @archive_mode
|
|
3939
|
+
# In archive mode, 'd' = delete from archive (consistent with normal mode)
|
|
3940
|
+
archive_delete_entries
|
|
3287
3941
|
return
|
|
3288
3942
|
end
|
|
3943
|
+
if @remote_mode
|
|
3944
|
+
return # No delete in remote mode; download is on 'D'
|
|
3945
|
+
end
|
|
3289
3946
|
|
|
3290
3947
|
tagged_info
|
|
3291
3948
|
|
|
@@ -3392,6 +4049,16 @@ def delete_items # {{{3
|
|
|
3392
4049
|
end
|
|
3393
4050
|
|
|
3394
4051
|
def empty_trash # {{{3
|
|
4052
|
+
if @archive_mode
|
|
4053
|
+
# In archive mode, 'D' = extract entries to origin directory
|
|
4054
|
+
archive_extract_entries
|
|
4055
|
+
return
|
|
4056
|
+
end
|
|
4057
|
+
if @remote_mode
|
|
4058
|
+
# In remote mode, 'D' = download selected file
|
|
4059
|
+
remote_download_selected
|
|
4060
|
+
return
|
|
4061
|
+
end
|
|
3395
4062
|
@pB.say(" Really empty Trash (~/.rtfm/trash)? (press 'y')")
|
|
3396
4063
|
return unless getchr == 'y'
|
|
3397
4064
|
|
|
@@ -4587,6 +5254,47 @@ def get_cached_file_metadata(file_path) # {{{2
|
|
|
4587
5254
|
end
|
|
4588
5255
|
end
|
|
4589
5256
|
|
|
5257
|
+
def dirlist_archive # {{{2
|
|
5258
|
+
return '' unless @archive_mode && @archive_path
|
|
5259
|
+
|
|
5260
|
+
current_index = @index || 0
|
|
5261
|
+
current_index = current_index.to_i
|
|
5262
|
+
width = @pL.w
|
|
5263
|
+
|
|
5264
|
+
files = archive_entries_for_dir(@archive_current_dir)
|
|
5265
|
+
@files = files.map { |f| f[:name] }
|
|
5266
|
+
@archive_files_cache = files
|
|
5267
|
+
|
|
5268
|
+
if @files[current_index] && files[current_index]
|
|
5269
|
+
entry = files[current_index]
|
|
5270
|
+
vpath = @archive_current_dir.empty? ? entry[:name] : "#{@archive_current_dir}/#{entry[:name]}"
|
|
5271
|
+
@selected = "#{@archive_path}:#{vpath}"
|
|
5272
|
+
@fileattr = "#{entry[:permissions]} #{format_size_simple(entry[:size])}"
|
|
5273
|
+
end
|
|
5274
|
+
|
|
5275
|
+
search_regex = @searched.empty? ? nil : /#{@searched}/
|
|
5276
|
+
|
|
5277
|
+
result = files.map.with_index do |entry, i|
|
|
5278
|
+
name = entry[:name]
|
|
5279
|
+
color = entry[:type] == 'directory' ? 156 : 255
|
|
5280
|
+
n = name.fg(color)
|
|
5281
|
+
n = n.inject('/', -1) if entry[:type] == 'directory'
|
|
5282
|
+
n = n.bg(238) if search_regex && name.match(search_regex)
|
|
5283
|
+
tag_key = "#{@archive_path}:#{entry[:full_path]}"
|
|
5284
|
+
n = n.r if @tagged.include?(tag_key)
|
|
5285
|
+
n = n.shorten(width - 5).inject('...', -1) if name.length > width - 6
|
|
5286
|
+
|
|
5287
|
+
if i == current_index
|
|
5288
|
+
n = '→ ' + n.u.bg(58) # Yellow-green background for archive mode selection
|
|
5289
|
+
else
|
|
5290
|
+
n = ' ' + n.bg(58) # Yellow-green background for archive mode
|
|
5291
|
+
end
|
|
5292
|
+
n
|
|
5293
|
+
end
|
|
5294
|
+
|
|
5295
|
+
result.join("\n")
|
|
5296
|
+
end
|
|
5297
|
+
|
|
4590
5298
|
def dirlist_remote # {{{2
|
|
4591
5299
|
return '' unless @current_remote && @remote_mode
|
|
4592
5300
|
|
|
@@ -4669,7 +5377,10 @@ def dirlist(left: true, directory: nil) # LIST DIRECTORIES {{{2
|
|
|
4669
5377
|
current_index = @index || 0
|
|
4670
5378
|
current_index = current_index.to_i
|
|
4671
5379
|
|
|
4672
|
-
# Handle remote mode for left pane
|
|
5380
|
+
# Handle archive/remote mode for left pane
|
|
5381
|
+
if left && @archive_mode
|
|
5382
|
+
return dirlist_archive
|
|
5383
|
+
end
|
|
4673
5384
|
if left && @remote_mode
|
|
4674
5385
|
return dirlist_remote
|
|
4675
5386
|
end
|
|
@@ -4804,14 +5515,16 @@ def current_render_state # {{{2
|
|
|
4804
5515
|
dual_pane: false,
|
|
4805
5516
|
dir: Dir.pwd,
|
|
4806
5517
|
index: @index,
|
|
4807
|
-
files_mtime: File.mtime(Dir.pwd).to_i,
|
|
5518
|
+
files_mtime: @archive_mode ? 0 : File.mtime(Dir.pwd).to_i,
|
|
4808
5519
|
selected: @selected,
|
|
4809
5520
|
tagged_count: @tagged.size,
|
|
4810
5521
|
preview: @preview,
|
|
4811
5522
|
showimage: @showimage,
|
|
4812
5523
|
searched: @searched,
|
|
4813
5524
|
filters: [@filter, @filtered].join,
|
|
4814
|
-
tabs_state: [@tabs.size, @current_tab, @tabs.map { |t| t[:name] }.join('|')]
|
|
5525
|
+
tabs_state: [@tabs.size, @current_tab, @tabs.map { |t| t[:name] }.join('|')],
|
|
5526
|
+
archive_mode: @archive_mode,
|
|
5527
|
+
archive_dir: @archive_current_dir
|
|
4815
5528
|
}
|
|
4816
5529
|
end
|
|
4817
5530
|
end
|
|
@@ -4916,16 +5629,22 @@ def render # RENDER ALL PANES {{{2
|
|
|
4916
5629
|
# TOP PANE {{{3
|
|
4917
5630
|
if @pT.update
|
|
4918
5631
|
toptext = @pT.text
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
text += @
|
|
4922
|
-
text += "
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
5632
|
+
if @archive_mode
|
|
5633
|
+
text = ' ARCHIVE: ' + File.basename(@archive_path)
|
|
5634
|
+
text += "/#{@archive_current_dir}" unless @archive_current_dir.empty?
|
|
5635
|
+
text += " (#{@fileattr})" if defined?(@fileattr) && !@fileattr.to_s.empty?
|
|
5636
|
+
else
|
|
5637
|
+
text = ' ' + ENV.fetch('USER') + '@' + `hostname 2>/dev/null`.chomp + ': '
|
|
5638
|
+
unless @selected.nil?
|
|
5639
|
+
text += @selected
|
|
5640
|
+
text += " → #{File.readlink(@selected)}" if File.symlink?(@selected)
|
|
5641
|
+
end
|
|
5642
|
+
# File attributes
|
|
5643
|
+
text += " (#{@fileattr})" if defined?(@fileattr)
|
|
5644
|
+
end
|
|
5645
|
+
# Image or PDF metadata using cache (skip in archive mode)
|
|
4927
5646
|
begin
|
|
4928
|
-
cached_meta = get_cached_file_metadata(@selected)
|
|
5647
|
+
cached_meta = @archive_mode ? nil : get_cached_file_metadata(@selected)
|
|
4929
5648
|
if cached_meta
|
|
4930
5649
|
text += cached_meta
|
|
4931
5650
|
elsif @preview && @selected&.match(@imagefile) && cmd?('identify')
|
|
@@ -4947,8 +5666,8 @@ def render # RENDER ALL PANES {{{2
|
|
|
4947
5666
|
rescue Errno::ENOENT, Errno::EACCES
|
|
4948
5667
|
# ignore missing or permission errors
|
|
4949
5668
|
end
|
|
4950
|
-
# Directory children count
|
|
4951
|
-
if @selected && Dir.exist?(@selected)
|
|
5669
|
+
# Directory children count (skip in archive mode)
|
|
5670
|
+
if !@archive_mode && @selected && Dir.exist?(@selected)
|
|
4952
5671
|
begin
|
|
4953
5672
|
count = Dir.children(@selected).count
|
|
4954
5673
|
text += " [#{count} items]"
|
|
@@ -4971,8 +5690,12 @@ def render # RENDER ALL PANES {{{2
|
|
|
4971
5690
|
end
|
|
4972
5691
|
|
|
4973
5692
|
@pT.text = text
|
|
4974
|
-
|
|
4975
|
-
@pT.bg
|
|
5693
|
+
|
|
5694
|
+
@pT.bg = if @archive_mode
|
|
5695
|
+
58 # Yellow-green background for archive mode
|
|
5696
|
+
else
|
|
5697
|
+
@topmatch.find { |name, _| name.empty? || Dir.pwd.include?(name) }&.last
|
|
5698
|
+
end
|
|
4976
5699
|
@pT.refresh unless @pT.text == toptext
|
|
4977
5700
|
end
|
|
4978
5701
|
|
|
@@ -5166,7 +5889,17 @@ def command(cmd, timeout: 5, return_both: false) # {{{2
|
|
|
5166
5889
|
end
|
|
5167
5890
|
end
|
|
5168
5891
|
|
|
5892
|
+
def file_op_running? # {{{3
|
|
5893
|
+
@file_op_thread&.alive?
|
|
5894
|
+
end
|
|
5895
|
+
|
|
5169
5896
|
def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
|
|
5897
|
+
# Block if another async operation is running
|
|
5898
|
+
if file_op_running?
|
|
5899
|
+
@pB.say(" Another file operation is in progress. Please wait.".fg(196))
|
|
5900
|
+
return
|
|
5901
|
+
end
|
|
5902
|
+
|
|
5170
5903
|
# Use tagged items if any exist, otherwise use selected item
|
|
5171
5904
|
items = @tagged.empty? ? [@selected] : @tagged.uniq
|
|
5172
5905
|
|
|
@@ -5177,14 +5910,66 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
|
|
|
5177
5910
|
Dir.pwd
|
|
5178
5911
|
end
|
|
5179
5912
|
|
|
5180
|
-
#
|
|
5913
|
+
# Symlinks are instant - run synchronously
|
|
5914
|
+
if type == 'link'
|
|
5915
|
+
copy_move_link_sync(type, items, dest_dir)
|
|
5916
|
+
return
|
|
5917
|
+
end
|
|
5918
|
+
|
|
5919
|
+
# For small operations (single file, not a directory), run synchronously
|
|
5920
|
+
if items.size == 1 && !File.directory?(items[0])
|
|
5921
|
+
copy_move_link_sync(type, items, dest_dir)
|
|
5922
|
+
return
|
|
5923
|
+
end
|
|
5924
|
+
|
|
5925
|
+
# Run copy/move asynchronously for larger operations
|
|
5926
|
+
@tagged = []
|
|
5927
|
+
previously_selected = type == 'move' ? (items[0] ? File.basename(items[0]) : nil) : nil
|
|
5928
|
+
@file_op_progress = " #{type.capitalize}ing #{items.size} item(s)...".fg(226)
|
|
5929
|
+
@pB.say(@file_op_progress)
|
|
5930
|
+
|
|
5931
|
+
@file_op_thread = Thread.new do
|
|
5932
|
+
operations = []
|
|
5933
|
+
begin
|
|
5934
|
+
items.each_with_index do |item, idx|
|
|
5935
|
+
@file_op_progress = " #{type.capitalize}ing #{idx + 1}/#{items.size}: #{File.basename(item)}".fg(226)
|
|
5936
|
+
dest = File.join(dest_dir, File.basename(item))
|
|
5937
|
+
dest += '1' if File.exist?(dest)
|
|
5938
|
+
while File.exist?(dest)
|
|
5939
|
+
dest = dest.chop + (dest[-1].to_i + 1).to_s
|
|
5940
|
+
end
|
|
5941
|
+
case type
|
|
5942
|
+
when 'copy'
|
|
5943
|
+
FileUtils.cp_r(item, dest)
|
|
5944
|
+
when 'move'
|
|
5945
|
+
FileUtils.mv(item, dest)
|
|
5946
|
+
end
|
|
5947
|
+
operations << { source_path: item, dest_path: dest }
|
|
5948
|
+
end
|
|
5949
|
+
|
|
5950
|
+
# Record undo
|
|
5951
|
+
unless operations.empty?
|
|
5952
|
+
undo_key = type == 'copy' ? :copies : :moves
|
|
5953
|
+
add_undo_operation({ type: type, undo_key => operations, timestamp: Time.now })
|
|
5954
|
+
end
|
|
5955
|
+
|
|
5956
|
+
@file_op_result = " #{type.capitalize} complete: #{operations.size} item(s)".fg(156)
|
|
5957
|
+
rescue => e
|
|
5958
|
+
@file_op_result = " #{type.capitalize} error: #{e.message}".fg(196)
|
|
5959
|
+
ensure
|
|
5960
|
+
@file_op_progress = nil
|
|
5961
|
+
@file_op_complete = true
|
|
5962
|
+
end
|
|
5963
|
+
end
|
|
5964
|
+
end
|
|
5965
|
+
|
|
5966
|
+
def copy_move_link_sync(type, items, dest_dir) # {{{3
|
|
5181
5967
|
operations = []
|
|
5182
5968
|
|
|
5183
5969
|
items.each do |item|
|
|
5184
5970
|
dest = File.join(dest_dir, File.basename(item))
|
|
5185
5971
|
dest += '1' if File.exist?(dest)
|
|
5186
5972
|
while File.exist?(dest)
|
|
5187
|
-
# Replace the last character (presumed to be a digit) by incrementing it
|
|
5188
5973
|
dest = dest.chop + (dest[-1].to_i + 1).to_s
|
|
5189
5974
|
end
|
|
5190
5975
|
begin
|
|
@@ -5206,53 +5991,38 @@ def copy_move_link(type) # COPY OR MOVE TAGGED ITEMS {{{2
|
|
|
5206
5991
|
@pB.say(e.to_s)
|
|
5207
5992
|
end
|
|
5208
5993
|
end
|
|
5209
|
-
|
|
5210
|
-
# Record undo information
|
|
5994
|
+
|
|
5995
|
+
# Record undo information
|
|
5211
5996
|
unless operations.empty?
|
|
5212
5997
|
case type
|
|
5213
5998
|
when 'copy'
|
|
5214
|
-
add_undo_operation({
|
|
5215
|
-
type: 'copy',
|
|
5216
|
-
copies: operations,
|
|
5217
|
-
timestamp: Time.now
|
|
5218
|
-
})
|
|
5999
|
+
add_undo_operation({ type: 'copy', copies: operations, timestamp: Time.now })
|
|
5219
6000
|
when 'move'
|
|
5220
|
-
add_undo_operation({
|
|
5221
|
-
type: 'move',
|
|
5222
|
-
moves: operations,
|
|
5223
|
-
timestamp: Time.now
|
|
5224
|
-
})
|
|
6001
|
+
add_undo_operation({ type: 'move', moves: operations, timestamp: Time.now })
|
|
5225
6002
|
when 'link'
|
|
5226
|
-
add_undo_operation({
|
|
5227
|
-
type: 'link',
|
|
5228
|
-
links: operations,
|
|
5229
|
-
timestamp: Time.now
|
|
5230
|
-
})
|
|
6003
|
+
add_undo_operation({ type: 'link', links: operations, timestamp: Time.now })
|
|
5231
6004
|
end
|
|
5232
6005
|
end
|
|
5233
|
-
|
|
6006
|
+
|
|
5234
6007
|
@tagged = []
|
|
5235
6008
|
|
|
5236
|
-
#
|
|
6009
|
+
# Restore selection after move operations
|
|
5237
6010
|
if type == 'move'
|
|
5238
6011
|
previously_selected = @selected ? File.basename(@selected) : nil
|
|
5239
6012
|
end
|
|
5240
6013
|
|
|
5241
|
-
# Set update flags for proper refresh
|
|
6014
|
+
# Set update flags for proper refresh
|
|
5242
6015
|
if @dual_pane
|
|
5243
|
-
# Update the destination pane
|
|
5244
6016
|
if @active_pane == :left
|
|
5245
6017
|
@pLeft.update = true
|
|
5246
6018
|
else
|
|
5247
6019
|
@pRight.update = true
|
|
5248
6020
|
end
|
|
5249
|
-
# Also update the preview pane
|
|
5250
6021
|
@pPreview.update = true if @pPreview
|
|
5251
6022
|
end
|
|
5252
6023
|
|
|
5253
6024
|
render
|
|
5254
6025
|
|
|
5255
|
-
# Restore selection after move operations
|
|
5256
6026
|
if type == 'move' && previously_selected && @files
|
|
5257
6027
|
restored_index = @files.index(previously_selected)
|
|
5258
6028
|
@index = restored_index if restored_index
|
|
@@ -5325,6 +6095,12 @@ def open_selected(html = nil) # OPEN SELECTED FILE {{{2
|
|
|
5325
6095
|
track_directory_access(@selected)
|
|
5326
6096
|
return
|
|
5327
6097
|
end
|
|
6098
|
+
|
|
6099
|
+
# Enter archive browsing mode for archive files (unless forced open with 'x')
|
|
6100
|
+
if !html && @selected && ARCHIVE_RE.match?(@selected) && File.file?(@selected)
|
|
6101
|
+
enter_archive_mode(@selected)
|
|
6102
|
+
return
|
|
6103
|
+
end
|
|
5328
6104
|
|
|
5329
6105
|
# Track file access when opening files
|
|
5330
6106
|
track_file_access(@selected)
|
|
@@ -5548,6 +6324,12 @@ def showcontent # SHOW CONTENTS IN THE RIGHT WINDOW {{{2
|
|
|
5548
6324
|
@pR.clear
|
|
5549
6325
|
end
|
|
5550
6326
|
begin
|
|
6327
|
+
# Handle archive mode separately
|
|
6328
|
+
if @archive_mode && @files && @files[@index] && @archive_files_cache[@index]
|
|
6329
|
+
show_archive_file_info(@archive_files_cache[@index])
|
|
6330
|
+
return
|
|
6331
|
+
end
|
|
6332
|
+
|
|
5551
6333
|
# Handle remote mode separately
|
|
5552
6334
|
if @remote_mode && @files && @files[@index] && @remote_files_cache[@index]
|
|
5553
6335
|
selected_file = @remote_files_cache[@index]
|
|
@@ -5957,7 +6739,20 @@ $stdin.getc while $stdin.wait_readable(0)
|
|
|
5957
6739
|
## THE LOOP {{{2
|
|
5958
6740
|
loop do
|
|
5959
6741
|
@dir_old = Dir.pwd
|
|
5960
|
-
|
|
6742
|
+
|
|
6743
|
+
# Check async file operation progress
|
|
6744
|
+
if @file_op_complete
|
|
6745
|
+
@file_op_complete = false
|
|
6746
|
+
@pB.say(@file_op_result || " Operation complete.".fg(156))
|
|
6747
|
+
@file_op_result = nil
|
|
6748
|
+
@file_op_progress = nil
|
|
6749
|
+
@dir_cache.delete_if { |key, _| key.start_with?("#{Dir.pwd}:") }
|
|
6750
|
+
@pL.update = @pR.update = @pT.update = @pB.update = true
|
|
6751
|
+
elsif @file_op_progress
|
|
6752
|
+
@pB.say(@file_op_progress)
|
|
6753
|
+
@pB.update = false
|
|
6754
|
+
end
|
|
6755
|
+
|
|
5961
6756
|
# redraw, but ignore TTY‐focus errors
|
|
5962
6757
|
begin
|
|
5963
6758
|
render
|
|
@@ -5976,8 +6771,8 @@ loop do
|
|
|
5976
6771
|
rescue
|
|
5977
6772
|
Dir.chdir
|
|
5978
6773
|
end
|
|
5979
|
-
# If selected file was removed externally, force pane refresh
|
|
5980
|
-
if @selected && !File.exist?(@selected)
|
|
6774
|
+
# If selected file was removed externally, force pane refresh (skip in archive/remote mode)
|
|
6775
|
+
if !@archive_mode && !@remote_mode && @selected && !File.exist?(@selected)
|
|
5981
6776
|
@dir_cache.delete_if { |key, _| key.start_with?("#{Dir.pwd}:") }
|
|
5982
6777
|
@index = [@index, (@files.size - 2)].min
|
|
5983
6778
|
@index = 0 if @index.negative?
|