sergeant 1.0.7 → 1.0.9

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: 0ac7149617ce3dd0a2e0c767ff46f6d2627fe917383303835f40a37461097dd8
4
+ data.tar.gz: 3d5621c41d6eec97194f62cab6ed525e1e1dd826fc9ed3fce3f9a08d67fba52a
5
5
  SHA512:
6
- metadata.gz: d9b9b02b04e41563f2a623f64228b07f2de2a551480d1547a731e4d09d577033c2bb29ceb1c8c5b0b56d22000d6c8bb51c267fc6e415f07190badcde85b631fc
7
- data.tar.gz: 7a23e6671b4e1c77231060e3dde2cde017d859ce8cfacff23c449166cc7b62b1c3df0bcec53f3243ab5c193d6c82de7b5c73dfe684f671c3db1f3679aeed32b6
6
+ metadata.gz: 1168b03a1a86233bebd92e7b287d10e33264e4d80b025bb06087b48a2560fa3f8f6c1c35e16634eedf64a7a60e723a01fb0755481ad7f32e9748a871a9be5639
7
+ data.tar.gz: 4111f86ea5fc31114d1e6a691bebf3e3a21beb4b1de6b4e26a1d34d11825c895ff3e3acdf568d5e1c818838402f06709e3bf715d12687725b1d9ea6530d4da9d
data/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ 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.9] - 2026-08-19
10
+
11
+ ### Added
12
+ - **Syntax highlighting in the side preview panel** - text file previews are now colorized (keywords, strings, comments, numbers, names) using [Rouge](https://github.com/rouge-ruby/rouge), which guesses the language from the filename
13
+
14
+ ### Fixed
15
+ - **Starship / Ruby-version-manager compatibility** - `Gemfile` no longer declares `ruby '>= 2.7.0'`, a version-range constraint that tools which auto-detect a project's Ruby version from the Gemfile (e.g. Starship's prompt) can't parse, causing an `Unknown ruby interpreter version` error just from `cd`-ing into the project. The constraint is already enforced by `required_ruby_version` in the gemspec.
16
+
17
+ ## [1.0.8] - 2026-08-19
18
+
19
+ ### Fixed
20
+ - **Screen flicker on every keypress in fast terminals**
21
+ - `draw_screen` was calling curses' `clear`, forcing a full blank-then-redraw on each keystroke
22
+ - Barely visible on slower/buffered terminals but very noticeable on fast, immediate-mode terminals like Alacritty
23
+ - Switched to `erase`, which only rewrites the virtual buffer so `refresh` repaints just the changed cells
24
+
25
+ ### Added
26
+ - **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`
27
+ - **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
28
+
9
29
  ## [1.0.7] - 2026-03-09
10
30
 
11
31
  ### Added
data/Gemfile CHANGED
@@ -2,11 +2,12 @@
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
 
8
+ # Syntax highlighting for the file preview panel
9
+ gem 'rouge', '~> 4.0'
10
+
10
11
  group :development do
11
12
  # Code linting and style checking
12
13
  gem 'rubocop', '~> 1.81', require: false
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), with syntax highlighting powered by [Rouge](https://github.com/rouge-ruby/rouge)
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,52 @@ 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
+
29
+ # Maps token_category (Sergeant::Utils) to the syntax-highlight color
30
+ # pairs set up in Sergeant#apply_color_theme (pairs 11-16).
31
+ PREVIEW_TOKEN_COLOR_PAIRS = {
32
+ keyword: 11,
33
+ string: 12,
34
+ comment: 13,
35
+ number: 14,
36
+ function: 15,
37
+ constant: 15,
38
+ error: 16
39
+ }.freeze
40
+
14
41
  def draw_screen
15
- clear
42
+ # `erase` only rewrites the virtual buffer; `refresh` then diffs it
43
+ # against the physical screen and repaints just the changed cells.
44
+ # `clear` forces a full physical blank-then-redraw on every call,
45
+ # which flickers visibly on fast terminals like Alacritty.
46
+ erase
16
47
 
17
48
  max_y = lines - 1
18
49
  max_x = cols
19
50
 
51
+ preview_enabled = @show_preview && max_x >= MIN_PREVIEW_TOTAL_WIDTH
52
+ if preview_enabled
53
+ preview_width = [(max_x * PREVIEW_WIDTH_RATIO).to_i, MIN_PREVIEW_WIDTH].max
54
+ list_width = max_x - preview_width - 1
55
+ else
56
+ preview_width = 0
57
+ list_width = max_x
58
+ end
59
+
20
60
  setpos(0, 0)
21
61
  attron(color_pair(4) | Curses::A_BOLD) do
22
62
  addstr("┌─ Sergeant - 'Leave it to the Sarge!' ".ljust(max_x, '─'))
@@ -42,9 +82,11 @@ module Sergeant
42
82
  end
43
83
  status_text = status_parts.empty? ? '' : " | #{status_parts.join(' | ')}"
44
84
 
85
+ status_width = list_width
86
+
45
87
  if branch
46
88
  branch_text = " [#{branch}]"
47
- path_max_length = max_x - 4 - branch_text.length - status_text.length
89
+ path_max_length = status_width - 4 - branch_text.length - status_text.length
48
90
  path_display = @current_dir.length > path_max_length ? "...#{@current_dir[(-path_max_length + 3)..]}" : @current_dir
49
91
 
50
92
  attron(color_pair(5)) do
@@ -58,9 +100,9 @@ module Sergeant
58
100
  addstr(status_text)
59
101
  end
60
102
  end
61
- remaining = max_x - 2 - path_display.length - branch_text.length - status_text.length
103
+ remaining = status_width - 2 - path_display.length - branch_text.length - status_text.length
62
104
  else
63
- path_max_length = max_x - 4 - status_text.length
105
+ path_max_length = status_width - 4 - status_text.length
64
106
  path_display = @current_dir.length > path_max_length ? "...#{@current_dir[(-path_max_length + 3)..]}" : @current_dir
65
107
 
66
108
  attron(color_pair(5)) do
@@ -71,7 +113,7 @@ module Sergeant
71
113
  addstr(status_text)
72
114
  end
73
115
  end
74
- remaining = max_x - 2 - path_display.length - status_text.length
116
+ remaining = status_width - 2 - path_display.length - status_text.length
75
117
  end
76
118
  addstr(''.ljust(remaining)) if remaining.positive?
77
119
 
@@ -103,18 +145,8 @@ module Sergeant
103
145
 
104
146
  is_selected = actual_index == @selected_index
105
147
 
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
148
+ attron(item_attributes(item, is_selected)) do
149
+ draw_item(item, list_width, is_selected)
118
150
  end
119
151
  end
120
152
 
@@ -124,15 +156,28 @@ module Sergeant
124
156
  scroll_pos = (@scroll_offset.to_f / (total - visible)) * (visible - 1)
125
157
  scroll_pos = scroll_pos.round.clamp(0, visible - 1)
126
158
 
127
- setpos(3 + scroll_pos, max_x - 1)
159
+ setpos(3 + scroll_pos, list_width - 1)
128
160
  attron(color_pair(4) | Curses::A_BOLD) do
129
161
  addstr('█')
130
162
  end
131
163
  end
132
164
 
165
+ draw_preview_panel(list_width, preview_width, max_y, visible_lines) if preview_enabled
166
+
133
167
  refresh
134
168
  end
135
169
 
170
+ # Which curses attributes to draw a list row with: selection highlight
171
+ # wins, then directories, then a file's type-based color (falling back
172
+ # to the plain dimmed file color when its category has none).
173
+ def item_attributes(item, is_selected)
174
+ return color_pair(3) | Curses::A_BOLD if is_selected
175
+ return color_pair(1) if item[:type] == :directory
176
+
177
+ pair = FILE_CATEGORY_COLOR_PAIRS[file_category(item)]
178
+ pair ? color_pair(pair) : (color_pair(2) | Curses::A_DIM)
179
+ end
180
+
136
181
  def draw_item(item, max_x, is_selected)
137
182
  icon = item[:type] == :directory ? ICON_DIR : ICON_FILE
138
183
 
@@ -171,5 +216,130 @@ module Sergeant
171
216
 
172
217
  addstr(display)
173
218
  end
219
+
220
+ # Draws the divider, title row and content of the side preview panel.
221
+ # Content itself is precomputed by Sergeant#update_preview_if_needed -
222
+ # this method only ever paints what's already in @preview_lines, so it
223
+ # stays cheap even though draw_screen runs on every keystroke.
224
+ def draw_preview_panel(list_width, preview_width, max_y, visible_lines)
225
+ col = list_width + 1
226
+ width = preview_width - 1
227
+ return if width <= 1
228
+
229
+ draw_preview_divider(list_width, max_y)
230
+ draw_preview_title(col, width)
231
+
232
+ case @preview_kind
233
+ when :text
234
+ if @preview_lines.empty?
235
+ draw_preview_message(col, width, '(empty file)')
236
+ else
237
+ draw_preview_highlighted_lines(col, width, visible_lines, @preview_lines)
238
+ end
239
+ when :directory
240
+ if @preview_lines.empty?
241
+ draw_preview_message(col, width, '(empty directory)')
242
+ else
243
+ names = @preview_lines.map { |name| " #{name}" }
244
+ draw_preview_lines(col, width, visible_lines, names, color_pair(1))
245
+ end
246
+ else
247
+ draw_preview_message(col, width, preview_placeholder_message)
248
+ end
249
+ end
250
+
251
+ def draw_preview_divider(list_width, max_y)
252
+ setpos(0, list_width)
253
+ attron(color_pair(4) | Curses::A_BOLD) { addstr('┬') }
254
+
255
+ (1...max_y).each do |row|
256
+ next if row == 2
257
+
258
+ setpos(row, list_width)
259
+ attron(color_pair(4)) { addstr('│') }
260
+ end
261
+
262
+ setpos(2, list_width)
263
+ attron(color_pair(4)) { addstr('┼') }
264
+ end
265
+
266
+ def draw_preview_title(col, width)
267
+ label = case @preview_kind
268
+ when :directory then "#{ICON_DIR}#{@preview_item[:name]}"
269
+ when :text, :binary then "#{ICON_FILE}#{@preview_item[:name]}"
270
+ else ''
271
+ end
272
+
273
+ setpos(1, col)
274
+ attron(color_pair(5) | Curses::A_BOLD) do
275
+ addstr(truncate_for_preview(label, width).ljust(width))
276
+ end
277
+ end
278
+
279
+ def draw_preview_lines(col, width, visible_lines, source_lines, pair)
280
+ visible_lines.times do |idx|
281
+ setpos(idx + 3, col)
282
+ line = source_lines[idx]
283
+ attron(pair) do
284
+ addstr((line ? truncate_for_preview(line, width) : '').ljust(width))
285
+ end
286
+ end
287
+ end
288
+
289
+ # Like draw_preview_lines, but each line is an array of
290
+ # {text:, category:} segments (built by Sergeant#build_text_preview via
291
+ # Rouge) instead of a single string, so every token can carry its own
292
+ # syntax-highlight color.
293
+ def draw_preview_highlighted_lines(col, width, visible_lines, lines)
294
+ visible_lines.times do |idx|
295
+ setpos(idx + 3, col)
296
+ draw_preview_segments(lines[idx] || [], width)
297
+ end
298
+ end
299
+
300
+ def draw_preview_segments(segments, width)
301
+ remaining = width
302
+
303
+ segments.each do |segment|
304
+ break if remaining <= 0
305
+
306
+ text = segment[:text]
307
+ chunk = text.length > remaining ? text[0, remaining] : text
308
+
309
+ attron(preview_token_attr(segment[:category])) { addstr(chunk) }
310
+ remaining -= chunk.length
311
+ end
312
+
313
+ attron(color_pair(2)) { addstr(' ' * remaining) } if remaining.positive?
314
+ end
315
+
316
+ def preview_token_attr(category)
317
+ color_pair(PREVIEW_TOKEN_COLOR_PAIRS[category] || 2)
318
+ end
319
+
320
+ def draw_preview_message(col, width, message)
321
+ setpos(3, col)
322
+ attron(color_pair(2) | Curses::A_DIM) do
323
+ addstr(truncate_for_preview(message, width).ljust(width))
324
+ end
325
+ end
326
+
327
+ def truncate_for_preview(text, width)
328
+ return text if text.length <= width
329
+ return text[0, width].to_s if width <= 1
330
+
331
+ "#{text[0, width - 1]}…"
332
+ end
333
+
334
+ def preview_placeholder_message
335
+ case @preview_kind
336
+ when :empty then '(empty directory)'
337
+ when :binary
338
+ size = @preview_item && @preview_item[:size]
339
+ size ? "(no preview - #{format_size(size).strip})" : '(no preview available)'
340
+ else
341
+ ''
342
+ end
343
+ end
174
344
  end
175
345
  end
@@ -4,6 +4,44 @@
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
+
29
+ # Maps a Rouge token's qualified name (e.g. "Literal.String.Double") to
30
+ # one of the syntax-highlight color pairs set up in
31
+ # Sergeant#apply_color_theme. Unmatched token types (Text, Punctuation,
32
+ # Operator, ...) return nil, meaning "draw in the default text color".
33
+ def token_category(qualname)
34
+ case qualname
35
+ when /\AComment/ then :comment
36
+ when /\AKeyword/ then :keyword
37
+ when /\ALiteral\.String/ then :string
38
+ when /\ALiteral\.Number/ then :number
39
+ when /\AName\.(Function|Class|Namespace|Tag)/ then :function
40
+ when /\AName\.(Constant|Builtin|Variable\.Class)/ then :constant
41
+ when /\A(Error|Generic\.Error|Generic\.Traceback)/ then :error
42
+ end
43
+ end
44
+
7
45
  def get_git_branch
8
46
  return nil unless Dir.exist?(File.join(@current_dir, '.git'))
9
47
 
@@ -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.9'
5
5
  end
data/lib/sergeant.rb CHANGED
@@ -7,6 +7,7 @@ require 'curses'
7
7
  require 'pathname'
8
8
  require 'etc'
9
9
  require 'fileutils'
10
+ require 'rouge'
10
11
 
11
12
  require_relative 'sergeant/version'
12
13
  require_relative 'sergeant/config'
@@ -21,6 +22,10 @@ class SergeantApp
21
22
  include Sergeant::Modals
22
23
  include Sergeant::Rendering
23
24
 
25
+ # Side preview panel content limits
26
+ PREVIEW_LINE_LIMIT = 500
27
+ PREVIEW_LINE_MAX_BYTES = 2000
28
+
24
29
  def initialize(start_dir: nil, no_color: false, pwd_mode: false, restore_session: false)
25
30
  @current_dir = start_dir || Dir.pwd
26
31
  @selected_index = 0
@@ -39,6 +44,13 @@ class SergeantApp
39
44
  @filter_text = ''
40
45
  @all_items = []
41
46
 
47
+ # Side preview panel
48
+ @show_preview = true
49
+ @last_preview_path = :unset
50
+ @preview_kind = :empty
51
+ @preview_item = nil
52
+ @preview_lines = []
53
+
42
54
  # Stat caching for performance
43
55
  @stat_cache = {}
44
56
  @cache_ttl = 5 # seconds
@@ -74,6 +86,7 @@ class SergeantApp
74
86
  loop do
75
87
  # Only refresh items when directory changes, not on every keystroke
76
88
  refresh_items_if_needed
89
+ update_preview_if_needed
77
90
  draw_screen
78
91
 
79
92
  key = getch
@@ -95,6 +108,9 @@ class SergeantApp
95
108
  goto_bookmark
96
109
  when 'o'
97
110
  @show_ownership = !@show_ownership
111
+ when 'P'
112
+ @show_preview = !@show_preview
113
+ @last_preview_path = :unset
98
114
  when 'e'
99
115
  edit_file
100
116
  when 'v'
@@ -165,6 +181,19 @@ class SergeantApp
165
181
  init_pair(4, Sergeant::Config.get_color(@config['header']), Curses::COLOR_BLACK)
166
182
  init_pair(5, Sergeant::Config.get_color(@config['path']), Curses::COLOR_BLACK)
167
183
  init_pair(6, Sergeant::Config.get_color(@config['git_branch']), Curses::COLOR_BLACK)
184
+ init_pair(7, Sergeant::Config.get_color(@config['archives']), Curses::COLOR_BLACK)
185
+ init_pair(8, Sergeant::Config.get_color(@config['media']), Curses::COLOR_BLACK)
186
+ init_pair(9, Sergeant::Config.get_color(@config['code']), Curses::COLOR_BLACK)
187
+ init_pair(10, Sergeant::Config.get_color(@config['executables']), Curses::COLOR_BLACK)
188
+
189
+ # Syntax highlighting for the preview panel - not user-configurable
190
+ # (yet), curses only gives us the 8 base colors to work with anyway.
191
+ init_pair(11, Curses::COLOR_MAGENTA, Curses::COLOR_BLACK) # keyword
192
+ init_pair(12, Curses::COLOR_GREEN, Curses::COLOR_BLACK) # string
193
+ init_pair(13, Curses::COLOR_WHITE, Curses::COLOR_BLACK) # comment (dimmed)
194
+ init_pair(14, Curses::COLOR_CYAN, Curses::COLOR_BLACK) # number
195
+ init_pair(15, Curses::COLOR_YELLOW, Curses::COLOR_BLACK) # function/class name
196
+ init_pair(16, Curses::COLOR_RED, Curses::COLOR_BLACK) # error token
168
197
  end
169
198
 
170
199
  def search_files
@@ -335,6 +364,114 @@ class SergeantApp
335
364
 
336
365
  # Also clear cache for current directory
337
366
  clear_cache_for_directory(@current_dir)
367
+
368
+ # The selected item's content may have changed too
369
+ @last_preview_path = :unset
370
+ end
371
+
372
+ # Side preview panel
373
+
374
+ def update_preview_if_needed
375
+ return unless @show_preview
376
+
377
+ item = @items[@selected_index]
378
+ path = item && item[:path]
379
+
380
+ # Only rebuild the preview when the selection actually points at a new
381
+ # path - never on every keystroke/redraw, to keep this cheap even in
382
+ # directories with many files.
383
+ return if path == @last_preview_path
384
+
385
+ @last_preview_path = path
386
+ @preview_item = item
387
+ @preview_kind, @preview_lines = build_preview(item)
388
+ end
389
+
390
+ def build_preview(item)
391
+ return [:empty, []] unless item
392
+ return [:parent, []] if item[:name] == '..'
393
+
394
+ if item[:type] == :directory
395
+ [:directory, build_directory_preview(item[:path])]
396
+ elsif text_file?(item[:path])
397
+ [:text, build_text_preview(item[:path], item[:name])]
398
+ else
399
+ [:binary, []]
400
+ end
401
+ end
402
+
403
+ def build_directory_preview(path)
404
+ names = []
405
+
406
+ # each_child is lazy, so even a directory with huge numbers of entries
407
+ # only costs us PREVIEW_LINE_LIMIT reads, not a full scan.
408
+ Dir.each_child(path) do |name|
409
+ names << name
410
+ break if names.length >= PREVIEW_LINE_LIMIT
411
+ end
412
+
413
+ names.sort_by(&:downcase)
414
+ rescue Errno::EACCES, Errno::ENOENT, Errno::ENOTDIR
415
+ []
416
+ end
417
+
418
+ def build_text_preview(path, filename)
419
+ raw_lines = []
420
+
421
+ File.foreach(path, mode: 'rb') do |line|
422
+ text = line.byteslice(0, PREVIEW_LINE_MAX_BYTES).to_s.chomp
423
+ raw_lines << text.force_encoding('UTF-8').scrub('?').gsub("\t", ' ')
424
+ break if raw_lines.length >= PREVIEW_LINE_LIMIT
425
+ end
426
+
427
+ highlight_lines(raw_lines, filename)
428
+ rescue StandardError
429
+ []
430
+ end
431
+
432
+ # Runs the preview text through Rouge and reassembles it into one array
433
+ # per line of [{text:, category:}, ...] segments, so the preview panel can
434
+ # paint each token in its own color. Falls back to plain (uncategorized)
435
+ # lines if Rouge can't make sense of the file - a highlighting bug should
436
+ # never be able to break the preview itself.
437
+ def highlight_lines(raw_lines, filename)
438
+ return [] if raw_lines.empty?
439
+
440
+ text = raw_lines.join("\n")
441
+ lexer = guess_lexer(filename, text)
442
+ tokens_to_lines(lexer, text)
443
+ rescue StandardError
444
+ raw_lines.map { |line| [{ text: line, category: nil }] }
445
+ end
446
+
447
+ def guess_lexer(filename, text)
448
+ Rouge::Lexer.guess(filename: filename, source: text)
449
+ rescue Rouge::Guesser::Ambiguous => e
450
+ e.alternatives.first || Rouge::Lexers::PlainText
451
+ rescue StandardError
452
+ Rouge::Lexers::PlainText
453
+ end
454
+
455
+ def tokens_to_lines(lexer, text)
456
+ lines = []
457
+ current = []
458
+
459
+ lexer.lex(text) do |token, value|
460
+ category = token_category(token.qualname)
461
+ pieces = value.split("\n", -1)
462
+
463
+ pieces.each_with_index do |piece, idx|
464
+ current << { text: piece, category: category } unless piece.empty?
465
+
466
+ next if idx == pieces.length - 1
467
+
468
+ lines << current
469
+ current = []
470
+ end
471
+ end
472
+
473
+ lines << current unless current.empty?
474
+ lines
338
475
  end
339
476
 
340
477
  def refresh_items
@@ -384,7 +521,8 @@ class SergeantApp
384
521
  size: stat.size,
385
522
  mtime: stat.mtime,
386
523
  owner: owner_info,
387
- perms: perms
524
+ perms: perms,
525
+ mode: stat.mode
388
526
  }
389
527
  end
390
528
  rescue Errno::EACCES, Errno::ENOENT
data/sergeant.gemspec CHANGED
@@ -57,6 +57,7 @@ Gem::Specification.new do |spec|
57
57
 
58
58
  # Runtime dependencies
59
59
  spec.add_dependency 'curses', '~> 1.4'
60
+ spec.add_dependency 'rouge', '~> 4.0'
60
61
 
61
62
  # Development dependencies
62
63
  spec.add_development_dependency 'rspec', '~> 3.13'
metadata CHANGED
@@ -1,13 +1,14 @@
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.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mateusz Grotha
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-08-24 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: curses
@@ -23,6 +24,20 @@ dependencies:
23
24
  - - "~>"
24
25
  - !ruby/object:Gem::Version
25
26
  version: '1.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rouge
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '4.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '4.0'
26
41
  - !ruby/object:Gem::Dependency
27
42
  name: rspec
28
43
  requirement: !ruby/object:Gem::Requirement
@@ -106,7 +121,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
106
121
  - !ruby/object:Gem::Version
107
122
  version: '0'
108
123
  requirements: []
109
- rubygems_version: 4.0.3
124
+ rubygems_version: 3.5.22
125
+ signing_key:
110
126
  specification_version: 4
111
127
  summary: Interactive TUI Directory Navigator for Terminal
112
128
  test_files: []