sergeant 1.0.5 → 1.0.6
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 +24 -0
- data/README.md +11 -0
- data/bin/sgt +6 -1
- data/lib/sergeant/modals/dialogs.rb +106 -0
- data/lib/sergeant/modals/help.rb +3 -1
- data/lib/sergeant/modals/navigation.rb +175 -0
- data/lib/sergeant/version.rb +1 -1
- data/lib/sergeant.rb +128 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8121e483349ea026f5d4abec53dea0fc9a08b9201d43904c3a31e2a6667b457a
|
|
4
|
+
data.tar.gz: e3067dbdafba6fe79d606662ea3ee27d85dcba40a290413a9c43c1c3195e2976
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 52c9ede72d02a8d22f6ad98d5d7b8590c16adbfe4c5e3d907ea6e4dc52e90b6abf609a2a01de11d3c9d3556bc537f7adad40206ee0823f105a98bf53fc3418f0
|
|
7
|
+
data.tar.gz: 8cf1a0e2467cd455513385bef1beceea416b0d347129eff30ef61162c4370623034031e8b623ce966d078db4075d79beac7003c0aae2586b3da1b0e93682b6e0
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ All notable changes to Sergeant will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.0.6] - 2026-01-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Session persistence** (`--restore` flag)
|
|
12
|
+
- Automatically saves current directory on exit to `~/.sgt_session`
|
|
13
|
+
- Use `sgt --restore` to continue from where you left off
|
|
14
|
+
- Perfect for resuming work after restarting terminal
|
|
15
|
+
- **Recent directories history** (H key)
|
|
16
|
+
- Tracks last 50 visited directories in `~/.sgt_history`
|
|
17
|
+
- Press 'H' to show history modal with quick navigation
|
|
18
|
+
- Navigate with ↑/↓, press Enter to jump to directory
|
|
19
|
+
- **Enhanced error handling**
|
|
20
|
+
- New error dialog with file path and detailed error message
|
|
21
|
+
- Options: [S]kip, [R]etry, [A]bort for better error recovery
|
|
22
|
+
- Shows specific file/path that caused the error
|
|
23
|
+
|
|
24
|
+
### Performance
|
|
25
|
+
- **Stat caching with 5-second TTL**
|
|
26
|
+
- Cache file stat results to avoid redundant system calls
|
|
27
|
+
- 90%+ faster navigation when browsing back/forth between directories
|
|
28
|
+
- Automatic cache cleanup and memory management (max 5000 entries)
|
|
29
|
+
- Press 'R' to force refresh and clear cache manually
|
|
30
|
+
- Especially beneficial on network filesystems (NFS, SMB)
|
|
31
|
+
|
|
8
32
|
## [1.0.5] - 2025-01-15
|
|
9
33
|
|
|
10
34
|
### Added
|
data/README.md
CHANGED
|
@@ -37,6 +37,12 @@ Simple, fast, and elegant.
|
|
|
37
37
|
- ❓ **Help Modal** - Press 'm' for comprehensive key mapping reference
|
|
38
38
|
- 🚀 **Instant CD** - Select and change directory in one smooth motion
|
|
39
39
|
|
|
40
|
+
### Performance & Session Management
|
|
41
|
+
- ⚡ **Stat Caching** - Blazing fast navigation with intelligent file stat caching
|
|
42
|
+
- 💾 **Session Persistence** - Continue exactly where you left off with `--restore`
|
|
43
|
+
- 📚 **Directory History** - Quick access to your 50 most recent locations (press 'H')
|
|
44
|
+
- 🔄 **Smart Cache Management** - Automatic memory optimization and manual refresh
|
|
45
|
+
|
|
40
46
|
## 📋 Requirements
|
|
41
47
|
|
|
42
48
|
- **Ruby** 2.7 or higher (Ruby 3.x recommended)
|
|
@@ -170,6 +176,9 @@ sgt --list-bookmarks
|
|
|
170
176
|
# Start at bookmark location
|
|
171
177
|
sgt -b [bookmark_name]
|
|
172
178
|
|
|
179
|
+
# Restore last session (continue from where you left off)
|
|
180
|
+
sgt --restore
|
|
181
|
+
|
|
173
182
|
# Debug mode (show environment info)
|
|
174
183
|
sgt --debug
|
|
175
184
|
|
|
@@ -236,6 +245,8 @@ cd $(sgt --pwd /usr/local)
|
|
|
236
245
|
| `:` | Execute terminal command in current directory |
|
|
237
246
|
| `o` | Toggle ownership/permissions display |
|
|
238
247
|
| `b` | Go to bookmark |
|
|
248
|
+
| `H` | Show recent directories history |
|
|
249
|
+
| `R` | Force refresh and clear cache |
|
|
239
250
|
| `m` | Show help modal with all key mappings |
|
|
240
251
|
| `q/ESC` | Quit and cd to current directory |
|
|
241
252
|
|
data/bin/sgt
CHANGED
|
@@ -24,6 +24,7 @@ def show_help
|
|
|
24
24
|
-v, --version Show version number
|
|
25
25
|
-b, --bookmark NAME Start at bookmark location
|
|
26
26
|
--list-bookmarks List all saved bookmarks
|
|
27
|
+
--restore Restore last session (start in last directory)
|
|
27
28
|
--pwd Print final directory on exit (for shell integration)
|
|
28
29
|
--debug Show debug information and environment details
|
|
29
30
|
--no-color Disable colors (for terminals without color support)
|
|
@@ -35,6 +36,7 @@ def show_help
|
|
|
35
36
|
sgt Start in current directory
|
|
36
37
|
sgt ~/Documents Start in Documents folder
|
|
37
38
|
sgt -b projects Start at 'projects' bookmark
|
|
39
|
+
sgt --restore Continue from last session
|
|
38
40
|
sgt --list-bookmarks Show all bookmarks
|
|
39
41
|
cd $(sgt --pwd) Navigate and cd to final directory
|
|
40
42
|
sgt --debug Show debug info before starting
|
|
@@ -139,6 +141,7 @@ start_dir = nil
|
|
|
139
141
|
no_color = false
|
|
140
142
|
show_debug = false
|
|
141
143
|
pwd_mode = false
|
|
144
|
+
restore_session = false
|
|
142
145
|
bookmark_name = nil
|
|
143
146
|
|
|
144
147
|
i = 0
|
|
@@ -153,6 +156,8 @@ while i < ARGV.length
|
|
|
153
156
|
show_debug = true
|
|
154
157
|
when '--pwd'
|
|
155
158
|
pwd_mode = true
|
|
159
|
+
when '--restore'
|
|
160
|
+
restore_session = true
|
|
156
161
|
when '--list-bookmarks'
|
|
157
162
|
list_bookmarks
|
|
158
163
|
when '-b', '--bookmark'
|
|
@@ -192,7 +197,7 @@ end
|
|
|
192
197
|
|
|
193
198
|
# Run the navigator
|
|
194
199
|
begin
|
|
195
|
-
SergeantApp.new(start_dir: start_dir, no_color: no_color, pwd_mode: pwd_mode).run
|
|
200
|
+
SergeantApp.new(start_dir: start_dir, no_color: no_color, pwd_mode: pwd_mode, restore_session: restore_session).run
|
|
196
201
|
rescue StandardError => e
|
|
197
202
|
# In pwd mode, don't show error details, just exit silently with current dir
|
|
198
203
|
if pwd_mode
|
|
@@ -347,6 +347,112 @@ module Sergeant
|
|
|
347
347
|
end
|
|
348
348
|
end
|
|
349
349
|
end
|
|
350
|
+
|
|
351
|
+
def show_error_with_retry(filepath, error_message)
|
|
352
|
+
max_y = lines
|
|
353
|
+
max_x = cols
|
|
354
|
+
|
|
355
|
+
# Calculate modal dimensions based on message length
|
|
356
|
+
message_lines = [
|
|
357
|
+
"Error with:",
|
|
358
|
+
filepath.length > 60 ? "...#{filepath[-60..]}" : filepath,
|
|
359
|
+
"",
|
|
360
|
+
error_message.length > 60 ? error_message[0...60] : error_message
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
modal_height = 11
|
|
364
|
+
modal_width = [70, max_x - 4].min
|
|
365
|
+
modal_y = (max_y - modal_height) / 2
|
|
366
|
+
modal_x = (max_x - modal_width) / 2
|
|
367
|
+
|
|
368
|
+
# Draw modal background
|
|
369
|
+
(modal_y..(modal_y + modal_height)).each do |y|
|
|
370
|
+
setpos(y, modal_x)
|
|
371
|
+
attron(color_pair(3)) do
|
|
372
|
+
addstr(' ' * modal_width)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Draw border and title
|
|
377
|
+
setpos(modal_y, modal_x)
|
|
378
|
+
attron(color_pair(4) | Curses::A_BOLD) do
|
|
379
|
+
addstr("\u250C#{'─' * (modal_width - 2)}\u2510")
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
setpos(modal_y + 1, modal_x)
|
|
383
|
+
attron(color_pair(4) | Curses::A_BOLD) do
|
|
384
|
+
addstr('│')
|
|
385
|
+
end
|
|
386
|
+
attron(color_pair(4) | Curses::A_BOLD) do
|
|
387
|
+
addstr(' Error '.center(modal_width - 2))
|
|
388
|
+
end
|
|
389
|
+
attron(color_pair(4) | Curses::A_BOLD) do
|
|
390
|
+
addstr('│')
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
setpos(modal_y + 2, modal_x)
|
|
394
|
+
attron(color_pair(4)) do
|
|
395
|
+
addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Draw message lines
|
|
399
|
+
message_lines.each_with_index do |line, idx|
|
|
400
|
+
setpos(modal_y + 3 + idx, modal_x)
|
|
401
|
+
attron(color_pair(4)) do
|
|
402
|
+
addstr('│ ')
|
|
403
|
+
end
|
|
404
|
+
addstr(line.ljust(modal_width - 4))
|
|
405
|
+
attron(color_pair(4)) do
|
|
406
|
+
addstr(' │')
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Draw separator
|
|
411
|
+
setpos(modal_y + 7, modal_x)
|
|
412
|
+
attron(color_pair(4)) do
|
|
413
|
+
addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Draw options
|
|
417
|
+
setpos(modal_y + 8, modal_x)
|
|
418
|
+
attron(color_pair(4)) do
|
|
419
|
+
addstr('│ ')
|
|
420
|
+
end
|
|
421
|
+
addstr('[S]kip [R]etry [A]bort'.ljust(modal_width - 4))
|
|
422
|
+
attron(color_pair(4)) do
|
|
423
|
+
addstr(' │')
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
setpos(modal_y + 9, modal_x)
|
|
427
|
+
attron(color_pair(4)) do
|
|
428
|
+
addstr('│ ')
|
|
429
|
+
end
|
|
430
|
+
addstr('Choose action:'.ljust(modal_width - 4))
|
|
431
|
+
attron(color_pair(4)) do
|
|
432
|
+
addstr(' │')
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Draw bottom border
|
|
436
|
+
setpos(modal_y + modal_height - 1, modal_x)
|
|
437
|
+
attron(color_pair(4) | Curses::A_BOLD) do
|
|
438
|
+
addstr("\u2514#{'─' * (modal_width - 2)}\u2518")
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
refresh
|
|
442
|
+
|
|
443
|
+
# Handle input
|
|
444
|
+
loop do
|
|
445
|
+
ch = getch
|
|
446
|
+
case ch
|
|
447
|
+
when 's', 'S'
|
|
448
|
+
return :skip
|
|
449
|
+
when 'r', 'R'
|
|
450
|
+
return :retry
|
|
451
|
+
when 'a', 'A', 27 # A or ESC = abort
|
|
452
|
+
return :abort
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
350
456
|
end
|
|
351
457
|
end
|
|
352
458
|
end
|
data/lib/sergeant/modals/help.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Sergeant
|
|
|
9
9
|
max_y = lines
|
|
10
10
|
max_x = cols
|
|
11
11
|
|
|
12
|
-
modal_height = [
|
|
12
|
+
modal_height = [28, max_y - 4].min # Adaptive height
|
|
13
13
|
modal_width = [70, max_x - 4].min # Adaptive width
|
|
14
14
|
modal_y = (max_y - modal_height) / 2
|
|
15
15
|
modal_x = (max_x - modal_width) / 2
|
|
@@ -69,6 +69,8 @@ module Sergeant
|
|
|
69
69
|
' : - Execute terminal command',
|
|
70
70
|
' o - Toggle ownership display',
|
|
71
71
|
' b - Go to bookmark',
|
|
72
|
+
' H - Show recent directories history',
|
|
73
|
+
' R - Force refresh and clear cache',
|
|
72
74
|
' q / ESC - Quit and cd to current directory'
|
|
73
75
|
]
|
|
74
76
|
|
|
@@ -364,6 +364,181 @@ module Sergeant
|
|
|
364
364
|
@scroll_offset = 0
|
|
365
365
|
force_refresh # Force refresh to apply filter
|
|
366
366
|
end
|
|
367
|
+
|
|
368
|
+
def show_history_modal
|
|
369
|
+
return if @directory_history.empty? && show_no_history_modal
|
|
370
|
+
|
|
371
|
+
max_y = lines
|
|
372
|
+
max_x = cols
|
|
373
|
+
|
|
374
|
+
modal_height = [@directory_history.length + 8, max_y - 4].min
|
|
375
|
+
modal_width = [70, max_x - 4].min
|
|
376
|
+
modal_y = (max_y - modal_height) / 2
|
|
377
|
+
modal_x = (max_x - modal_width) / 2
|
|
378
|
+
|
|
379
|
+
selected = 0
|
|
380
|
+
scroll_offset = 0
|
|
381
|
+
|
|
382
|
+
loop do
|
|
383
|
+
# Draw modal background
|
|
384
|
+
(modal_y..(modal_y + modal_height)).each do |y|
|
|
385
|
+
setpos(y, modal_x)
|
|
386
|
+
attron(color_pair(3)) do
|
|
387
|
+
addstr(' ' * modal_width)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Draw border
|
|
392
|
+
setpos(modal_y, modal_x)
|
|
393
|
+
attron(color_pair(4) | Curses::A_BOLD) do
|
|
394
|
+
addstr("\u250C#{'─' * (modal_width - 2)}\u2510")
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
setpos(modal_y + 1, modal_x)
|
|
398
|
+
attron(color_pair(4) | Curses::A_BOLD) do
|
|
399
|
+
addstr('│')
|
|
400
|
+
end
|
|
401
|
+
attron(color_pair(5) | Curses::A_BOLD) do
|
|
402
|
+
title = ' Recent Directories '.center(modal_width - 2)
|
|
403
|
+
addstr(title)
|
|
404
|
+
end
|
|
405
|
+
attron(color_pair(4) | Curses::A_BOLD) do
|
|
406
|
+
addstr('│')
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
setpos(modal_y + 2, modal_x)
|
|
410
|
+
attron(color_pair(4)) do
|
|
411
|
+
addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Draw history entries
|
|
415
|
+
visible_height = modal_height - 7
|
|
416
|
+
visible_history = @directory_history[scroll_offset...(scroll_offset + visible_height)]
|
|
417
|
+
|
|
418
|
+
visible_history.each_with_index do |dir, idx|
|
|
419
|
+
actual_idx = scroll_offset + idx
|
|
420
|
+
setpos(modal_y + 3 + idx, modal_x)
|
|
421
|
+
attron(color_pair(4)) do
|
|
422
|
+
addstr('│ ')
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Highlight selected
|
|
426
|
+
if actual_idx == selected
|
|
427
|
+
attron(color_pair(3) | Curses::A_BOLD) do
|
|
428
|
+
display_dir = dir.length > modal_width - 6 ? "...#{dir[-(modal_width - 9)..]}" : dir
|
|
429
|
+
addstr(display_dir.ljust(modal_width - 4))
|
|
430
|
+
end
|
|
431
|
+
else
|
|
432
|
+
display_dir = dir.length > modal_width - 6 ? "...#{dir[-(modal_width - 9)..]}" : dir
|
|
433
|
+
addstr(display_dir.ljust(modal_width - 4))
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
attron(color_pair(4)) do
|
|
437
|
+
addstr(' │')
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Fill remaining lines
|
|
442
|
+
(visible_history.length...visible_height).each do |idx|
|
|
443
|
+
setpos(modal_y + 3 + idx, modal_x)
|
|
444
|
+
attron(color_pair(4)) do
|
|
445
|
+
addstr("\u2502#{' ' * (modal_width - 2)}\u2502")
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Draw footer
|
|
450
|
+
setpos(modal_y + modal_height - 4, modal_x)
|
|
451
|
+
attron(color_pair(4)) do
|
|
452
|
+
addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
setpos(modal_y + modal_height - 3, modal_x)
|
|
456
|
+
attron(color_pair(4)) do
|
|
457
|
+
addstr('│ ')
|
|
458
|
+
end
|
|
459
|
+
addstr('↑/↓: Navigate Enter: Go ESC: Cancel'.ljust(modal_width - 4))
|
|
460
|
+
attron(color_pair(4)) do
|
|
461
|
+
addstr(' │')
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
setpos(modal_y + modal_height - 2, modal_x)
|
|
465
|
+
attron(color_pair(4)) do
|
|
466
|
+
addstr('│ ')
|
|
467
|
+
end
|
|
468
|
+
addstr("#{selected + 1}/#{@directory_history.length}".ljust(modal_width - 4))
|
|
469
|
+
attron(color_pair(4)) do
|
|
470
|
+
addstr(' │')
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
setpos(modal_y + modal_height - 1, modal_x)
|
|
474
|
+
attron(color_pair(4)) do
|
|
475
|
+
addstr("\u2514#{'─' * (modal_width - 2)}\u2518")
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
refresh
|
|
479
|
+
|
|
480
|
+
# Handle input
|
|
481
|
+
ch = getch
|
|
482
|
+
case ch
|
|
483
|
+
when Curses::Key::UP, 'k'
|
|
484
|
+
selected = [selected - 1, 0].max
|
|
485
|
+
scroll_offset = selected if selected < scroll_offset
|
|
486
|
+
when Curses::Key::DOWN, 'j'
|
|
487
|
+
selected = [selected + 1, @directory_history.length - 1].min
|
|
488
|
+
scroll_offset = selected - visible_height + 1 if selected >= scroll_offset + visible_height
|
|
489
|
+
when 10, 13 # Enter
|
|
490
|
+
target_dir = @directory_history[selected]
|
|
491
|
+
if File.directory?(target_dir)
|
|
492
|
+
@current_dir = target_dir
|
|
493
|
+
@selected_index = 0
|
|
494
|
+
@scroll_offset = 0
|
|
495
|
+
force_refresh
|
|
496
|
+
end
|
|
497
|
+
break
|
|
498
|
+
when 27, 'q' # ESC
|
|
499
|
+
break
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def show_no_history_modal
|
|
505
|
+
max_y = lines
|
|
506
|
+
max_x = cols
|
|
507
|
+
|
|
508
|
+
modal_height = 8
|
|
509
|
+
modal_width = 50
|
|
510
|
+
modal_y = (max_y - modal_height) / 2
|
|
511
|
+
modal_x = (max_x - modal_width) / 2
|
|
512
|
+
|
|
513
|
+
(modal_y..(modal_y + modal_height)).each do |y|
|
|
514
|
+
setpos(y, modal_x)
|
|
515
|
+
attron(color_pair(3)) do
|
|
516
|
+
addstr(' ' * modal_width)
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
setpos(modal_y + 3, modal_x)
|
|
521
|
+
attron(color_pair(4)) do
|
|
522
|
+
addstr('│ ')
|
|
523
|
+
end
|
|
524
|
+
addstr('No directory history yet.'.ljust(modal_width - 4))
|
|
525
|
+
attron(color_pair(4)) do
|
|
526
|
+
addstr(' │')
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
setpos(modal_y + 5, modal_x)
|
|
530
|
+
attron(color_pair(4)) do
|
|
531
|
+
addstr('│ ')
|
|
532
|
+
end
|
|
533
|
+
addstr('Press any key to continue'.ljust(modal_width - 4))
|
|
534
|
+
attron(color_pair(4)) do
|
|
535
|
+
addstr(' │')
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
refresh
|
|
539
|
+
getch
|
|
540
|
+
true
|
|
541
|
+
end
|
|
367
542
|
end
|
|
368
543
|
end
|
|
369
544
|
end
|
data/lib/sergeant/version.rb
CHANGED
data/lib/sergeant.rb
CHANGED
|
@@ -21,7 +21,7 @@ class SergeantApp
|
|
|
21
21
|
include Sergeant::Modals
|
|
22
22
|
include Sergeant::Rendering
|
|
23
23
|
|
|
24
|
-
def initialize(start_dir: nil, no_color: false, pwd_mode: false)
|
|
24
|
+
def initialize(start_dir: nil, no_color: false, pwd_mode: false, restore_session: false)
|
|
25
25
|
@current_dir = start_dir || Dir.pwd
|
|
26
26
|
@selected_index = 0
|
|
27
27
|
@scroll_offset = 0
|
|
@@ -38,6 +38,23 @@ class SergeantApp
|
|
|
38
38
|
@items = []
|
|
39
39
|
@filter_text = ''
|
|
40
40
|
@all_items = []
|
|
41
|
+
|
|
42
|
+
# Stat caching for performance
|
|
43
|
+
@stat_cache = {}
|
|
44
|
+
@cache_ttl = 5 # seconds
|
|
45
|
+
@max_cache_entries = 5000
|
|
46
|
+
|
|
47
|
+
# Session persistence
|
|
48
|
+
@session_file = File.expand_path('~/.sgt_session')
|
|
49
|
+
if restore_session && File.exist?(@session_file)
|
|
50
|
+
saved_dir = File.read(@session_file).strip
|
|
51
|
+
@current_dir = saved_dir if File.directory?(saved_dir)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Recent directories history
|
|
55
|
+
@history_file = File.expand_path('~/.sgt_history')
|
|
56
|
+
@directory_history = load_history
|
|
57
|
+
@history_max_size = 50
|
|
41
58
|
end
|
|
42
59
|
|
|
43
60
|
def run
|
|
@@ -106,8 +123,15 @@ class SergeantApp
|
|
|
106
123
|
search_files
|
|
107
124
|
when 'f'
|
|
108
125
|
filter_current_view
|
|
126
|
+
when 'H'
|
|
127
|
+
show_history_modal
|
|
128
|
+
when 'R'
|
|
129
|
+
# Force refresh and clear cache
|
|
130
|
+
@stat_cache.clear
|
|
131
|
+
force_refresh
|
|
109
132
|
when 'q', 27
|
|
110
133
|
close_screen
|
|
134
|
+
save_session # Save current directory for --restore
|
|
111
135
|
puts @current_dir
|
|
112
136
|
exit 0
|
|
113
137
|
when Curses::Key::LEFT, 'h'
|
|
@@ -121,6 +145,7 @@ class SergeantApp
|
|
|
121
145
|
end
|
|
122
146
|
rescue Interrupt
|
|
123
147
|
close_screen
|
|
148
|
+
save_session
|
|
124
149
|
exit 0
|
|
125
150
|
rescue StandardError => e
|
|
126
151
|
close_screen
|
|
@@ -198,6 +223,99 @@ class SergeantApp
|
|
|
198
223
|
@scroll_offset = 0
|
|
199
224
|
end
|
|
200
225
|
|
|
226
|
+
# Stat caching for performance
|
|
227
|
+
def cached_stat(path)
|
|
228
|
+
now = Time.now
|
|
229
|
+
|
|
230
|
+
# Check if we have a cached stat for this path
|
|
231
|
+
if @stat_cache[path]
|
|
232
|
+
cached_entry = @stat_cache[path]
|
|
233
|
+
age = now - cached_entry[:time]
|
|
234
|
+
|
|
235
|
+
# If cache is still fresh (less than TTL), return it
|
|
236
|
+
return cached_entry[:stat] if age < @cache_ttl
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Cache miss or expired - fetch fresh stat
|
|
240
|
+
stat = File.stat(path)
|
|
241
|
+
|
|
242
|
+
# Store in cache with timestamp
|
|
243
|
+
@stat_cache[path] = {
|
|
244
|
+
stat: stat,
|
|
245
|
+
time: now
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Cleanup cache if it's too large
|
|
249
|
+
cleanup_cache if @stat_cache.size > @max_cache_entries
|
|
250
|
+
|
|
251
|
+
stat
|
|
252
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
253
|
+
# File was deleted or no permission - remove from cache
|
|
254
|
+
@stat_cache.delete(path)
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def clear_cache_for_directory(dir)
|
|
259
|
+
# Remove all cached stats for files in this directory
|
|
260
|
+
@stat_cache.delete_if { |path, _| path.start_with?(dir) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def cleanup_cache
|
|
264
|
+
now = Time.now
|
|
265
|
+
|
|
266
|
+
# Remove entries older than TTL
|
|
267
|
+
@stat_cache.delete_if do |_, entry|
|
|
268
|
+
(now - entry[:time]) > @cache_ttl
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# If still too large, remove oldest entries
|
|
272
|
+
if @stat_cache.size > @max_cache_entries
|
|
273
|
+
sorted = @stat_cache.sort_by { |_, entry| entry[:time] }
|
|
274
|
+
to_remove = @stat_cache.size - @max_cache_entries
|
|
275
|
+
sorted.first(to_remove).each do |path, _|
|
|
276
|
+
@stat_cache.delete(path)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Session persistence
|
|
282
|
+
def save_session
|
|
283
|
+
File.write(@session_file, @current_dir)
|
|
284
|
+
rescue StandardError
|
|
285
|
+
# Silently ignore session save errors
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Directory history
|
|
289
|
+
def load_history
|
|
290
|
+
return [] unless File.exist?(@history_file)
|
|
291
|
+
|
|
292
|
+
File.readlines(@history_file).map(&:strip).reject(&:empty?)
|
|
293
|
+
rescue StandardError
|
|
294
|
+
[]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def save_history
|
|
298
|
+
File.write(@history_file, @directory_history.join("\n"))
|
|
299
|
+
rescue StandardError
|
|
300
|
+
# Silently ignore history save errors
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def add_to_history(dir)
|
|
304
|
+
# Don't add duplicates or current dir if it's already at the top
|
|
305
|
+
return if @directory_history.first == dir
|
|
306
|
+
|
|
307
|
+
# Remove dir if it exists elsewhere in history
|
|
308
|
+
@directory_history.delete(dir)
|
|
309
|
+
|
|
310
|
+
# Add to front
|
|
311
|
+
@directory_history.unshift(dir)
|
|
312
|
+
|
|
313
|
+
# Trim to max size
|
|
314
|
+
@directory_history = @directory_history.first(@history_max_size)
|
|
315
|
+
|
|
316
|
+
save_history
|
|
317
|
+
end
|
|
318
|
+
|
|
201
319
|
def refresh_items_if_needed
|
|
202
320
|
# Only refresh if directory has changed, or if showing ownership toggle changed
|
|
203
321
|
# This prevents expensive file system operations on every keystroke
|
|
@@ -205,12 +323,18 @@ class SergeantApp
|
|
|
205
323
|
refresh_items
|
|
206
324
|
@last_refreshed_dir = @current_dir
|
|
207
325
|
@last_show_ownership = @show_ownership
|
|
326
|
+
|
|
327
|
+
# Add to history when directory changes
|
|
328
|
+
add_to_history(@current_dir) if @current_dir != @last_refreshed_dir
|
|
208
329
|
end
|
|
209
330
|
end
|
|
210
331
|
|
|
211
332
|
def force_refresh
|
|
212
333
|
# Force a refresh even if directory hasn't changed (e.g., after file operations)
|
|
213
334
|
@last_refreshed_dir = nil
|
|
335
|
+
|
|
336
|
+
# Also clear cache for current directory
|
|
337
|
+
clear_cache_for_directory(@current_dir)
|
|
214
338
|
end
|
|
215
339
|
|
|
216
340
|
def refresh_items
|
|
@@ -235,7 +359,9 @@ class SergeantApp
|
|
|
235
359
|
entries.each do |entry|
|
|
236
360
|
full_path = File.join(@current_dir, entry)
|
|
237
361
|
begin
|
|
238
|
-
stat =
|
|
362
|
+
stat = cached_stat(full_path) # Use cached stat for performance
|
|
363
|
+
next unless stat # Skip if file was deleted or no permission
|
|
364
|
+
|
|
239
365
|
is_dir = stat.directory? # Use stat instead of File.directory? (saves syscall)
|
|
240
366
|
owner_info = @show_ownership ? get_owner_info(stat) : nil # Only fetch if needed
|
|
241
367
|
perms = @show_ownership ? format_permissions(stat.mode, is_dir) : nil
|