sergeant 1.0.7 → 1.0.8

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: 2c874d9cc43e11d9c891bf372f1324f188c7a1b5aebe5ee4ddcb71bb5db68c66
4
- data.tar.gz: 2a47b52f0ae9fe27c7e02b13a74338b34c0db9a768bf36216048e838445843e3
3
+ metadata.gz: 36d4f8c54ef4d4f7168cb42c81abe472b8094bf67c648096e9513bdfcca9ad15
4
+ data.tar.gz: 820962c25da75cc167c88dfe3407fb1de3e294444bd5c737ec09779c46414aed
5
5
  SHA512:
6
- metadata.gz: d9b9b02b04e41563f2a623f64228b07f2de2a551480d1547a731e4d09d577033c2bb29ceb1c8c5b0b56d22000d6c8bb51c267fc6e415f07190badcde85b631fc
7
- data.tar.gz: 7a23e6671b4e1c77231060e3dde2cde017d859ce8cfacff23c449166cc7b62b1c3df0bcec53f3243ab5c193d6c82de7b5c73dfe684f671c3db1f3679aeed32b6
6
+ metadata.gz: 1838e264508e70d0c4012560764396c7772e9fb2a50ea1517c52ad584ced2f5d2cecdc7d19efab344ae25d80bb036964f8aa11d39667d5f79afedac05c4c75e4
7
+ data.tar.gz: 8e00de4e37bb7e3d971f39eb1dbce3932131941b80af2549c0e778d23a3c5a6a90b0d57e5aa956582467e112d3e052e0f496c71d602c61ccebbd06a453bff2cd
data/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ 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
8
 
9
+ ## [1.0.8] - 2026-08-19
10
+
11
+ ### Fixed
12
+ - **Screen flicker on every keypress in fast terminals**
13
+ - `draw_screen` was calling curses' `clear`, forcing a full blank-then-redraw on each keystroke
14
+ - Barely visible on slower/buffered terminals but very noticeable on fast, immediate-mode terminals like Alacritty
15
+ - Switched to `erase`, which only rewrites the virtual buffer so `refresh` repaints just the changed cells
16
+
17
+ ### Added
18
+ - **File-type colors** - files are now color-coded by category (archives, media, code, executables) in addition to the existing directory/file distinction, configurable via `~/.sgtrc`
19
+ - **Live side preview panel** (`P` key) - shows the first lines of a text file or a directory's contents in a panel next to the list, without leaving the UI or shelling out to a pager. Appears automatically once the terminal is wide enough
20
+
9
21
  ## [1.0.7] - 2026-03-09
10
22
 
11
23
  ### Added
data/Gemfile CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- ruby '>= 2.7.0'
6
-
7
5
  # Terminal UI library
8
6
  gem 'curses', '~> 1.4'
9
7
 
data/README.md CHANGED
@@ -15,7 +15,8 @@ Simple, fast, and elegant.
15
15
  ### Navigation & Display
16
16
  - 🗂️ **Visual Directory Navigation** - See all directories and files at a glance
17
17
  - ⌨️ **Keyboard Driven** - Arrow keys, vim bindings (hjkl), and shortcuts
18
- - 🎨 **Color-Coded Display** - Directories in cyan, files grayed out
18
+ - 🎨 **Color-Coded Display** - Directories in cyan; files colored by type (code, archives, media, executables)
19
+ - 👀 **Live Preview Pane** - Text files and directory contents preview in a side panel as you move (press 'P' to toggle)
19
20
  - 📊 **Smart Scrolling** - Handles directories with hundreds of items
20
21
  - 🔍 **Git Branch Display** - Shows current git branch in header
21
22
  - 👤 **Ownership Toggle** - View file permissions and ownership (press 'o')
@@ -244,6 +245,7 @@ cd $(sgt --pwd /usr/local)
244
245
  | `/` | Search files (requires fzf) |
245
246
  | `:` | Execute terminal command in current directory |
246
247
  | `o` | Toggle ownership/permissions display |
248
+ | `P` | Toggle side preview panel |
247
249
  | `b` | Go to bookmark |
248
250
  | `H` | Show recent directories history |
249
251
  | `R` | Force refresh and clear cache |
@@ -264,6 +266,10 @@ selected_fg=black
264
266
  header=yellow
265
267
  path=green
266
268
  git_branch=magenta
269
+ archives=red
270
+ media=magenta
271
+ code=blue
272
+ executables=green
267
273
 
268
274
  # Bookmarks
269
275
  [bookmarks]
@@ -22,7 +22,11 @@ module Sergeant
22
22
  'selected_fg' => 'black',
23
23
  'header' => 'yellow',
24
24
  'path' => 'green',
25
- 'git_branch' => 'magenta'
25
+ 'git_branch' => 'magenta',
26
+ 'archives' => 'red',
27
+ 'media' => 'magenta',
28
+ 'code' => 'blue',
29
+ 'executables' => 'green'
26
30
  }.freeze
27
31
 
28
32
  MINIMAL_CONFIG_TEMPLATE = <<~CONFIG.freeze
@@ -35,6 +39,10 @@ module Sergeant
35
39
  header=yellow
36
40
  path=green
37
41
  git_branch=magenta
42
+ archives=red
43
+ media=magenta
44
+ code=blue
45
+ executables=green
38
46
 
39
47
  # Bookmarks
40
48
  [bookmarks]
@@ -68,6 +68,7 @@ module Sergeant
68
68
  'Other:',
69
69
  ' : - Execute terminal command',
70
70
  ' o - Toggle ownership display',
71
+ ' P - Toggle side preview panel',
71
72
  ' b - Go to bookmark',
72
73
  ' H - Show recent directories history',
73
74
  ' R - Force refresh and clear cache',
@@ -11,12 +11,40 @@ module Sergeant
11
11
  ICON_MARK = WINDOWS ? '* ' : '✓ '
12
12
  ICON_SELECT = WINDOWS ? '> ' : '▶ '
13
13
 
14
+ # Side preview panel only kicks in once the terminal is wide enough that
15
+ # both panes stay readable; below that it just doesn't fit.
16
+ MIN_PREVIEW_TOTAL_WIDTH = 100
17
+ MIN_PREVIEW_WIDTH = 24
18
+ PREVIEW_WIDTH_RATIO = 0.35
19
+
20
+ # Maps file_category (Sergeant::Utils) to the color pair set up in
21
+ # Sergeant#apply_color_theme (pairs 7-10).
22
+ FILE_CATEGORY_COLOR_PAIRS = {
23
+ archive: 7,
24
+ media: 8,
25
+ code: 9,
26
+ executable: 10
27
+ }.freeze
28
+
14
29
  def draw_screen
15
- clear
30
+ # `erase` only rewrites the virtual buffer; `refresh` then diffs it
31
+ # against the physical screen and repaints just the changed cells.
32
+ # `clear` forces a full physical blank-then-redraw on every call,
33
+ # which flickers visibly on fast terminals like Alacritty.
34
+ erase
16
35
 
17
36
  max_y = lines - 1
18
37
  max_x = cols
19
38
 
39
+ preview_enabled = @show_preview && max_x >= MIN_PREVIEW_TOTAL_WIDTH
40
+ if preview_enabled
41
+ preview_width = [(max_x * PREVIEW_WIDTH_RATIO).to_i, MIN_PREVIEW_WIDTH].max
42
+ list_width = max_x - preview_width - 1
43
+ else
44
+ preview_width = 0
45
+ list_width = max_x
46
+ end
47
+
20
48
  setpos(0, 0)
21
49
  attron(color_pair(4) | Curses::A_BOLD) do
22
50
  addstr("┌─ Sergeant - 'Leave it to the Sarge!' ".ljust(max_x, '─'))
@@ -42,9 +70,11 @@ module Sergeant
42
70
  end
43
71
  status_text = status_parts.empty? ? '' : " | #{status_parts.join(' | ')}"
44
72
 
73
+ status_width = list_width
74
+
45
75
  if branch
46
76
  branch_text = " [#{branch}]"
47
- path_max_length = max_x - 4 - branch_text.length - status_text.length
77
+ path_max_length = status_width - 4 - branch_text.length - status_text.length
48
78
  path_display = @current_dir.length > path_max_length ? "...#{@current_dir[(-path_max_length + 3)..]}" : @current_dir
49
79
 
50
80
  attron(color_pair(5)) do
@@ -58,9 +88,9 @@ module Sergeant
58
88
  addstr(status_text)
59
89
  end
60
90
  end
61
- remaining = max_x - 2 - path_display.length - branch_text.length - status_text.length
91
+ remaining = status_width - 2 - path_display.length - branch_text.length - status_text.length
62
92
  else
63
- path_max_length = max_x - 4 - status_text.length
93
+ path_max_length = status_width - 4 - status_text.length
64
94
  path_display = @current_dir.length > path_max_length ? "...#{@current_dir[(-path_max_length + 3)..]}" : @current_dir
65
95
 
66
96
  attron(color_pair(5)) do
@@ -71,7 +101,7 @@ module Sergeant
71
101
  addstr(status_text)
72
102
  end
73
103
  end
74
- remaining = max_x - 2 - path_display.length - status_text.length
104
+ remaining = status_width - 2 - path_display.length - status_text.length
75
105
  end
76
106
  addstr(''.ljust(remaining)) if remaining.positive?
77
107
 
@@ -103,18 +133,8 @@ module Sergeant
103
133
 
104
134
  is_selected = actual_index == @selected_index
105
135
 
106
- if is_selected
107
- attron(color_pair(3) | Curses::A_BOLD) do
108
- draw_item(item, max_x, true)
109
- end
110
- elsif item[:type] == :directory
111
- attron(color_pair(1)) do
112
- draw_item(item, max_x, false)
113
- end
114
- else
115
- attron(color_pair(2) | Curses::A_DIM) do
116
- draw_item(item, max_x, false)
117
- end
136
+ attron(item_attributes(item, is_selected)) do
137
+ draw_item(item, list_width, is_selected)
118
138
  end
119
139
  end
120
140
 
@@ -124,15 +144,28 @@ module Sergeant
124
144
  scroll_pos = (@scroll_offset.to_f / (total - visible)) * (visible - 1)
125
145
  scroll_pos = scroll_pos.round.clamp(0, visible - 1)
126
146
 
127
- setpos(3 + scroll_pos, max_x - 1)
147
+ setpos(3 + scroll_pos, list_width - 1)
128
148
  attron(color_pair(4) | Curses::A_BOLD) do
129
149
  addstr('█')
130
150
  end
131
151
  end
132
152
 
153
+ draw_preview_panel(list_width, preview_width, max_y, visible_lines) if preview_enabled
154
+
133
155
  refresh
134
156
  end
135
157
 
158
+ # Which curses attributes to draw a list row with: selection highlight
159
+ # wins, then directories, then a file's type-based color (falling back
160
+ # to the plain dimmed file color when its category has none).
161
+ def item_attributes(item, is_selected)
162
+ return color_pair(3) | Curses::A_BOLD if is_selected
163
+ return color_pair(1) if item[:type] == :directory
164
+
165
+ pair = FILE_CATEGORY_COLOR_PAIRS[file_category(item)]
166
+ pair ? color_pair(pair) : (color_pair(2) | Curses::A_DIM)
167
+ end
168
+
136
169
  def draw_item(item, max_x, is_selected)
137
170
  icon = item[:type] == :directory ? ICON_DIR : ICON_FILE
138
171
 
@@ -171,5 +204,99 @@ module Sergeant
171
204
 
172
205
  addstr(display)
173
206
  end
207
+
208
+ # Draws the divider, title row and content of the side preview panel.
209
+ # Content itself is precomputed by Sergeant#update_preview_if_needed -
210
+ # this method only ever paints what's already in @preview_lines, so it
211
+ # stays cheap even though draw_screen runs on every keystroke.
212
+ def draw_preview_panel(list_width, preview_width, max_y, visible_lines)
213
+ col = list_width + 1
214
+ width = preview_width - 1
215
+ return if width <= 1
216
+
217
+ draw_preview_divider(list_width, max_y)
218
+ draw_preview_title(col, width)
219
+
220
+ case @preview_kind
221
+ when :text
222
+ if @preview_lines.empty?
223
+ draw_preview_message(col, width, '(empty file)')
224
+ else
225
+ draw_preview_lines(col, width, visible_lines, @preview_lines, color_pair(2))
226
+ end
227
+ when :directory
228
+ if @preview_lines.empty?
229
+ draw_preview_message(col, width, '(empty directory)')
230
+ else
231
+ names = @preview_lines.map { |name| " #{name}" }
232
+ draw_preview_lines(col, width, visible_lines, names, color_pair(1))
233
+ end
234
+ else
235
+ draw_preview_message(col, width, preview_placeholder_message)
236
+ end
237
+ end
238
+
239
+ def draw_preview_divider(list_width, max_y)
240
+ setpos(0, list_width)
241
+ attron(color_pair(4) | Curses::A_BOLD) { addstr('┬') }
242
+
243
+ (1...max_y).each do |row|
244
+ next if row == 2
245
+
246
+ setpos(row, list_width)
247
+ attron(color_pair(4)) { addstr('│') }
248
+ end
249
+
250
+ setpos(2, list_width)
251
+ attron(color_pair(4)) { addstr('┼') }
252
+ end
253
+
254
+ def draw_preview_title(col, width)
255
+ label = case @preview_kind
256
+ when :directory then "#{ICON_DIR}#{@preview_item[:name]}"
257
+ when :text, :binary then "#{ICON_FILE}#{@preview_item[:name]}"
258
+ else ''
259
+ end
260
+
261
+ setpos(1, col)
262
+ attron(color_pair(5) | Curses::A_BOLD) do
263
+ addstr(truncate_for_preview(label, width).ljust(width))
264
+ end
265
+ end
266
+
267
+ def draw_preview_lines(col, width, visible_lines, source_lines, pair)
268
+ visible_lines.times do |idx|
269
+ setpos(idx + 3, col)
270
+ line = source_lines[idx]
271
+ attron(pair) do
272
+ addstr((line ? truncate_for_preview(line, width) : '').ljust(width))
273
+ end
274
+ end
275
+ end
276
+
277
+ def draw_preview_message(col, width, message)
278
+ setpos(3, col)
279
+ attron(color_pair(2) | Curses::A_DIM) do
280
+ addstr(truncate_for_preview(message, width).ljust(width))
281
+ end
282
+ end
283
+
284
+ def truncate_for_preview(text, width)
285
+ return text if text.length <= width
286
+ return text[0, width].to_s if width <= 1
287
+
288
+ "#{text[0, width - 1]}…"
289
+ end
290
+
291
+ def preview_placeholder_message
292
+ case @preview_kind
293
+ when :empty then '(empty directory)'
294
+ when :binary
295
+ size = @preview_item && @preview_item[:size]
296
+ size ? "(no preview - #{format_size(size).strip})" : '(no preview available)'
297
+ else
298
+ ''
299
+ end
300
+ end
174
301
  end
175
302
  end
@@ -4,6 +4,28 @@
4
4
 
5
5
  module Sergeant
6
6
  module Utils
7
+ ARCHIVE_EXTENSIONS = %w[.zip .tar .gz .tgz .bz2 .tbz2 .xz .txz .7z .rar .zst .lz .lzma .cab .iso .deb .rpm .apk
8
+ .jar .war].freeze
9
+ MEDIA_EXTENSIONS = %w[.png .jpg .jpeg .gif .bmp .svg .webp .ico .tiff .tif .heic .avif
10
+ .mp4 .mkv .avi .mov .wmv .flv .webm .m4v .mpg .mpeg
11
+ .mp3 .wav .flac .ogg .aac .m4a .wma .opus].freeze
12
+ CODE_EXTENSIONS = %w[.rb .py .js .jsx .mjs .cjs .ts .tsx .go .rs .c .cpp .cc .h .hpp .java .kt .kts .swift .php
13
+ .sh .bash .zsh .fish .lua .pl .pm .r .scala .clj .cljs .ex .exs .erl .hs .ml .mli .sql
14
+ .html .htm .css .scss .sass .less .json .yaml .yml .toml .xml .vue .svelte].freeze
15
+
16
+ # Which color pair (see Sergeant#apply_color_theme) to use for a file,
17
+ # based on its extension, falling back to the executable bit. Directories
18
+ # are colored separately by draw_screen and never reach this method.
19
+ def file_category(item)
20
+ ext = File.extname(item[:name]).downcase
21
+ return :archive if ARCHIVE_EXTENSIONS.include?(ext)
22
+ return :media if MEDIA_EXTENSIONS.include?(ext)
23
+ return :code if CODE_EXTENSIONS.include?(ext)
24
+ return :executable if item[:mode] && (item[:mode] & 0o111).nonzero? && ext.empty?
25
+
26
+ :file
27
+ end
28
+
7
29
  def get_git_branch
8
30
  return nil unless Dir.exist?(File.join(@current_dir, '.git'))
9
31
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sergeant
4
- VERSION = '1.0.7'
4
+ VERSION = '1.0.8'
5
5
  end
data/lib/sergeant.rb CHANGED
@@ -21,6 +21,10 @@ class SergeantApp
21
21
  include Sergeant::Modals
22
22
  include Sergeant::Rendering
23
23
 
24
+ # Side preview panel content limits
25
+ PREVIEW_LINE_LIMIT = 500
26
+ PREVIEW_LINE_MAX_BYTES = 2000
27
+
24
28
  def initialize(start_dir: nil, no_color: false, pwd_mode: false, restore_session: false)
25
29
  @current_dir = start_dir || Dir.pwd
26
30
  @selected_index = 0
@@ -39,6 +43,13 @@ class SergeantApp
39
43
  @filter_text = ''
40
44
  @all_items = []
41
45
 
46
+ # Side preview panel
47
+ @show_preview = true
48
+ @last_preview_path = :unset
49
+ @preview_kind = :empty
50
+ @preview_item = nil
51
+ @preview_lines = []
52
+
42
53
  # Stat caching for performance
43
54
  @stat_cache = {}
44
55
  @cache_ttl = 5 # seconds
@@ -74,6 +85,7 @@ class SergeantApp
74
85
  loop do
75
86
  # Only refresh items when directory changes, not on every keystroke
76
87
  refresh_items_if_needed
88
+ update_preview_if_needed
77
89
  draw_screen
78
90
 
79
91
  key = getch
@@ -95,6 +107,9 @@ class SergeantApp
95
107
  goto_bookmark
96
108
  when 'o'
97
109
  @show_ownership = !@show_ownership
110
+ when 'P'
111
+ @show_preview = !@show_preview
112
+ @last_preview_path = :unset
98
113
  when 'e'
99
114
  edit_file
100
115
  when 'v'
@@ -165,6 +180,10 @@ class SergeantApp
165
180
  init_pair(4, Sergeant::Config.get_color(@config['header']), Curses::COLOR_BLACK)
166
181
  init_pair(5, Sergeant::Config.get_color(@config['path']), Curses::COLOR_BLACK)
167
182
  init_pair(6, Sergeant::Config.get_color(@config['git_branch']), Curses::COLOR_BLACK)
183
+ init_pair(7, Sergeant::Config.get_color(@config['archives']), Curses::COLOR_BLACK)
184
+ init_pair(8, Sergeant::Config.get_color(@config['media']), Curses::COLOR_BLACK)
185
+ init_pair(9, Sergeant::Config.get_color(@config['code']), Curses::COLOR_BLACK)
186
+ init_pair(10, Sergeant::Config.get_color(@config['executables']), Curses::COLOR_BLACK)
168
187
  end
169
188
 
170
189
  def search_files
@@ -335,6 +354,69 @@ class SergeantApp
335
354
 
336
355
  # Also clear cache for current directory
337
356
  clear_cache_for_directory(@current_dir)
357
+
358
+ # The selected item's content may have changed too
359
+ @last_preview_path = :unset
360
+ end
361
+
362
+ # Side preview panel
363
+
364
+ def update_preview_if_needed
365
+ return unless @show_preview
366
+
367
+ item = @items[@selected_index]
368
+ path = item && item[:path]
369
+
370
+ # Only rebuild the preview when the selection actually points at a new
371
+ # path - never on every keystroke/redraw, to keep this cheap even in
372
+ # directories with many files.
373
+ return if path == @last_preview_path
374
+
375
+ @last_preview_path = path
376
+ @preview_item = item
377
+ @preview_kind, @preview_lines = build_preview(item)
378
+ end
379
+
380
+ def build_preview(item)
381
+ return [:empty, []] unless item
382
+ return [:parent, []] if item[:name] == '..'
383
+
384
+ if item[:type] == :directory
385
+ [:directory, build_directory_preview(item[:path])]
386
+ elsif text_file?(item[:path])
387
+ [:text, build_text_preview(item[:path])]
388
+ else
389
+ [:binary, []]
390
+ end
391
+ end
392
+
393
+ def build_directory_preview(path)
394
+ names = []
395
+
396
+ # each_child is lazy, so even a directory with huge numbers of entries
397
+ # only costs us PREVIEW_LINE_LIMIT reads, not a full scan.
398
+ Dir.each_child(path) do |name|
399
+ names << name
400
+ break if names.length >= PREVIEW_LINE_LIMIT
401
+ end
402
+
403
+ names.sort_by(&:downcase)
404
+ rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR
405
+ []
406
+ end
407
+
408
+ def build_text_preview(path)
409
+ lines = []
410
+
411
+ File.foreach(path, mode: 'rb') do |line|
412
+ text = line.byteslice(0, PREVIEW_LINE_MAX_BYTES).to_s.chomp
413
+ lines << text.force_encoding('UTF-8').scrub('?').gsub("\t", ' ')
414
+ break if lines.length >= PREVIEW_LINE_LIMIT
415
+ end
416
+
417
+ lines
418
+ rescue StandardError
419
+ []
338
420
  end
339
421
 
340
422
  def refresh_items
@@ -384,7 +466,8 @@ class SergeantApp
384
466
  size: stat.size,
385
467
  mtime: stat.mtime,
386
468
  owner: owner_info,
387
- perms: perms
469
+ perms: perms,
470
+ mode: stat.mode
388
471
  }
389
472
  end
390
473
  rescue Errno::EACCES, Errno::ENOENT
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.7
4
+ version: 1.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mateusz Grotha