sergeant 1.0.5 → 1.0.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac9c7c93fc3b942684b0f038cf0fa8658cc17c2a1fdc732a9bfef538bc4afa78
4
- data.tar.gz: f4c839577ff9a2c23560e22332adf2641c2614118cc017b5e83cb7a0c3219878
3
+ metadata.gz: 2c874d9cc43e11d9c891bf372f1324f188c7a1b5aebe5ee4ddcb71bb5db68c66
4
+ data.tar.gz: 2a47b52f0ae9fe27c7e02b13a74338b34c0db9a768bf36216048e838445843e3
5
5
  SHA512:
6
- metadata.gz: 24182721fbd0c5edb7900440b452a29ad2f7d98092e57c9701bad1bd19872faa38fa9d60985985785abb26734e5772604e4a90fa9862dab85d1646b880b98a45
7
- data.tar.gz: 0e7d245c24cca22da30aba6c5fffd988f66ec82b93e34a83e0e543bf48657c1f1ada2985003e9157afc418b758e974ed86e400faf948570c4d5570a29aae65f4
6
+ metadata.gz: d9b9b02b04e41563f2a623f64228b07f2de2a551480d1547a731e4d09d577033c2bb29ceb1c8c5b0b56d22000d6c8bb51c267fc6e415f07190badcde85b631fc
7
+ data.tar.gz: 7a23e6671b4e1c77231060e3dde2cde017d859ce8cfacff23c449166cc7b62b1c3df0bcec53f3243ab5c193d6c82de7b5c73dfe684f671c3db1f3679aeed32b6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ 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
+
9
+ ## [1.0.7] - 2026-03-09
10
+
11
+ ### Added
12
+ - **Progress bar for file operations**
13
+ - Copy, move, and delete now show a live progress modal
14
+ - Displays current filename, filled bar (█░), and X/N (%) counter
15
+ - Prevents UI from appearing frozen during large or multi-file operations
16
+
17
+ ## [1.0.6] - 2026-01-16
18
+
19
+ ### Added
20
+ - **Session persistence** (`--restore` flag)
21
+ - Automatically saves current directory on exit to `~/.sgt_session`
22
+ - Use `sgt --restore` to continue from where you left off
23
+ - Perfect for resuming work after restarting terminal
24
+ - **Recent directories history** (H key)
25
+ - Tracks last 50 visited directories in `~/.sgt_history`
26
+ - Press 'H' to show history modal with quick navigation
27
+ - Navigate with ↑/↓, press Enter to jump to directory
28
+ - **Enhanced error handling**
29
+ - New error dialog with file path and detailed error message
30
+ - Options: [S]kip, [R]etry, [A]bort for better error recovery
31
+ - Shows specific file/path that caused the error
32
+
33
+ ### Performance
34
+ - **Stat caching with 5-second TTL**
35
+ - Cache file stat results to avoid redundant system calls
36
+ - 90%+ faster navigation when browsing back/forth between directories
37
+ - Automatic cache cleanup and memory management (max 5000 entries)
38
+ - Press 'R' to force refresh and clear cache manually
39
+ - Especially beneficial on network filesystems (NFS, SMB)
40
+
8
41
  ## [1.0.5] - 2025-01-15
9
42
 
10
43
  ### 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,199 @@ module Sergeant
347
347
  end
348
348
  end
349
349
  end
350
+
351
+ def draw_progress_modal(title, total)
352
+ max_y = lines
353
+ max_x = cols
354
+
355
+ modal_width = [60, max_x - 4].min
356
+ modal_height = 7
357
+ modal_y = (max_y - modal_height) / 2
358
+ modal_x = (max_x - modal_width) / 2
359
+
360
+ @progress_modal = { y: modal_y, x: modal_x, width: modal_width, total: total }
361
+
362
+ # Draw background
363
+ (modal_y..(modal_y + modal_height)).each do |y|
364
+ setpos(y, modal_x)
365
+ attron(color_pair(3)) { addstr(' ' * modal_width) }
366
+ end
367
+
368
+ # Top border
369
+ setpos(modal_y, modal_x)
370
+ attron(color_pair(4) | Curses::A_BOLD) do
371
+ addstr("\u250C#{'─' * (modal_width - 2)}\u2510")
372
+ end
373
+
374
+ # Title row
375
+ setpos(modal_y + 1, modal_x)
376
+ attron(color_pair(4) | Curses::A_BOLD) { addstr('│') }
377
+ attron(color_pair(5) | Curses::A_BOLD) { addstr(" #{title} ".center(modal_width - 2)) }
378
+ attron(color_pair(4) | Curses::A_BOLD) { addstr('│') }
379
+
380
+ # Separator
381
+ setpos(modal_y + 2, modal_x)
382
+ attron(color_pair(4)) { addstr("\u251C#{'─' * (modal_width - 2)}\u2524") }
383
+
384
+ # Filename row (blank initially)
385
+ setpos(modal_y + 3, modal_x)
386
+ attron(color_pair(4)) { addstr('│ ') }
387
+ addstr(' ' * (modal_width - 4))
388
+ attron(color_pair(4)) { addstr(' │') }
389
+
390
+ # Spacer row
391
+ setpos(modal_y + 4, modal_x)
392
+ attron(color_pair(4)) { addstr("\u2502#{' ' * (modal_width - 2)}\u2502") }
393
+
394
+ # Progress bar row (blank initially)
395
+ setpos(modal_y + 5, modal_x)
396
+ attron(color_pair(4)) { addstr('│ ') }
397
+ addstr(' ' * (modal_width - 4))
398
+ attron(color_pair(4)) { addstr(' │') }
399
+
400
+ # Bottom border
401
+ setpos(modal_y + 6, modal_x)
402
+ attron(color_pair(4) | Curses::A_BOLD) do
403
+ addstr("\u2514#{'─' * (modal_width - 2)}\u2518")
404
+ end
405
+
406
+ refresh
407
+ end
408
+
409
+ def update_progress_modal(current, total, filename)
410
+ return unless @progress_modal
411
+
412
+ modal_y = @progress_modal[:y]
413
+ modal_x = @progress_modal[:x]
414
+ modal_width = @progress_modal[:width]
415
+ inner_width = modal_width - 4 # between '│ ' and ' │'
416
+
417
+ # Update filename line
418
+ setpos(modal_y + 3, modal_x + 2)
419
+ display_name = filename.to_s
420
+ display_name = "...#{display_name[-(inner_width - 3)..]}" if display_name.length > inner_width
421
+ addstr(display_name.ljust(inner_width))
422
+
423
+ # Build progress bar
424
+ percent = total > 0 ? (current.to_f / total * 100).round : 0
425
+ count_str = "#{current}/#{total} (#{percent}%)"
426
+ bar_outer = [inner_width - count_str.length - 1, 3].max # 1 space between bar and count
427
+ bar_inner = bar_outer - 2 # subtract [ and ]
428
+ filled = total > 0 ? (current.to_f / total * bar_inner).round : 0
429
+ empty = bar_inner - filled
430
+
431
+ bar = "[#{'█' * filled}#{'░' * empty}] #{count_str}"
432
+ setpos(modal_y + 5, modal_x + 2)
433
+ addstr(bar.ljust(inner_width))
434
+
435
+ refresh
436
+ end
437
+
438
+ def show_error_with_retry(filepath, error_message)
439
+ max_y = lines
440
+ max_x = cols
441
+
442
+ # Calculate modal dimensions based on message length
443
+ message_lines = [
444
+ "Error with:",
445
+ filepath.length > 60 ? "...#{filepath[-60..]}" : filepath,
446
+ "",
447
+ error_message.length > 60 ? error_message[0...60] : error_message
448
+ ]
449
+
450
+ modal_height = 11
451
+ modal_width = [70, max_x - 4].min
452
+ modal_y = (max_y - modal_height) / 2
453
+ modal_x = (max_x - modal_width) / 2
454
+
455
+ # Draw modal background
456
+ (modal_y..(modal_y + modal_height)).each do |y|
457
+ setpos(y, modal_x)
458
+ attron(color_pair(3)) do
459
+ addstr(' ' * modal_width)
460
+ end
461
+ end
462
+
463
+ # Draw border and title
464
+ setpos(modal_y, modal_x)
465
+ attron(color_pair(4) | Curses::A_BOLD) do
466
+ addstr("\u250C#{'─' * (modal_width - 2)}\u2510")
467
+ end
468
+
469
+ setpos(modal_y + 1, modal_x)
470
+ attron(color_pair(4) | Curses::A_BOLD) do
471
+ addstr('│')
472
+ end
473
+ attron(color_pair(4) | Curses::A_BOLD) do
474
+ addstr(' Error '.center(modal_width - 2))
475
+ end
476
+ attron(color_pair(4) | Curses::A_BOLD) do
477
+ addstr('│')
478
+ end
479
+
480
+ setpos(modal_y + 2, modal_x)
481
+ attron(color_pair(4)) do
482
+ addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
483
+ end
484
+
485
+ # Draw message lines
486
+ message_lines.each_with_index do |line, idx|
487
+ setpos(modal_y + 3 + idx, modal_x)
488
+ attron(color_pair(4)) do
489
+ addstr('│ ')
490
+ end
491
+ addstr(line.ljust(modal_width - 4))
492
+ attron(color_pair(4)) do
493
+ addstr(' │')
494
+ end
495
+ end
496
+
497
+ # Draw separator
498
+ setpos(modal_y + 7, modal_x)
499
+ attron(color_pair(4)) do
500
+ addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
501
+ end
502
+
503
+ # Draw options
504
+ setpos(modal_y + 8, modal_x)
505
+ attron(color_pair(4)) do
506
+ addstr('│ ')
507
+ end
508
+ addstr('[S]kip [R]etry [A]bort'.ljust(modal_width - 4))
509
+ attron(color_pair(4)) do
510
+ addstr(' │')
511
+ end
512
+
513
+ setpos(modal_y + 9, modal_x)
514
+ attron(color_pair(4)) do
515
+ addstr('│ ')
516
+ end
517
+ addstr('Choose action:'.ljust(modal_width - 4))
518
+ attron(color_pair(4)) do
519
+ addstr(' │')
520
+ end
521
+
522
+ # Draw bottom border
523
+ setpos(modal_y + modal_height - 1, modal_x)
524
+ attron(color_pair(4) | Curses::A_BOLD) do
525
+ addstr("\u2514#{'─' * (modal_width - 2)}\u2518")
526
+ end
527
+
528
+ refresh
529
+
530
+ # Handle input
531
+ loop do
532
+ ch = getch
533
+ case ch
534
+ when 's', 'S'
535
+ return :skip
536
+ when 'r', 'R'
537
+ return :retry
538
+ when 'a', 'A', 27 # A or ESC = abort
539
+ return :abort
540
+ end
541
+ end
542
+ end
350
543
  end
351
544
  end
352
545
  end
@@ -131,11 +131,18 @@ module Sergeant
131
131
  error_count = 0
132
132
  errors = []
133
133
 
134
+ total = @copied_items.count { |p| File.exist?(p) }
135
+ operation = @cut_mode ? 'Moving' : 'Copying'
136
+ draw_progress_modal("#{operation} Files", total)
137
+ current = 0
138
+
134
139
  @copied_items.each do |source_path|
135
140
  next unless File.exist?(source_path)
136
141
 
137
142
  filename = File.basename(source_path)
138
143
  dest_path = File.join(@current_dir, filename)
144
+ current += 1
145
+ update_progress_modal(current, total, filename)
139
146
 
140
147
  begin
141
148
  if File.exist?(dest_path)
@@ -167,6 +174,8 @@ module Sergeant
167
174
  end
168
175
  end
169
176
 
177
+ @progress_modal = nil
178
+
170
179
  # Clean up after operation
171
180
  @marked_items.clear
172
181
  @copied_items.clear
@@ -204,19 +213,28 @@ module Sergeant
204
213
  error_count = 0
205
214
  errors = []
206
215
 
216
+ total = @marked_items.count { |p| File.exist?(p) }
217
+ draw_progress_modal('Deleting Files', total)
218
+ current = 0
219
+
207
220
  @marked_items.each do |item_path|
208
221
  next unless File.exist?(item_path)
209
222
 
223
+ filename = File.basename(item_path)
224
+ current += 1
225
+ update_progress_modal(current, total, filename)
226
+
210
227
  begin
211
228
  FileUtils.rm_rf(item_path)
212
229
  success_count += 1
213
230
  rescue StandardError => e
214
231
  error_count += 1
215
- filename = File.basename(item_path)
216
232
  errors << "#{filename}: #{e.message}"
217
233
  end
218
234
  end
219
235
 
236
+ @progress_modal = nil
237
+
220
238
  # Clear marked items after deletion
221
239
  @marked_items.clear
222
240
 
@@ -9,7 +9,7 @@ module Sergeant
9
9
  max_y = lines
10
10
  max_x = cols
11
11
 
12
- modal_height = [26, max_y - 4].min # Adaptive 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sergeant
4
- VERSION = '1.0.5'
4
+ VERSION = '1.0.7'
5
5
  end
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 = File.stat(full_path)
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sergeant
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.5
4
+ version: 1.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mateusz Grotha