sergeant 1.0.8 → 1.0.10

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: 36d4f8c54ef4d4f7168cb42c81abe472b8094bf67c648096e9513bdfcca9ad15
4
- data.tar.gz: 820962c25da75cc167c88dfe3407fb1de3e294444bd5c737ec09779c46414aed
3
+ metadata.gz: 41cc1fef750cecd3e0a9a530976e8818955bc0db86a434319420dbe250496405
4
+ data.tar.gz: 60db0f330203e559828d1cc87f701af503d174a6577bc351bfcc2fce2dc88752
5
5
  SHA512:
6
- metadata.gz: 1838e264508e70d0c4012560764396c7772e9fb2a50ea1517c52ad584ced2f5d2cecdc7d19efab344ae25d80bb036964f8aa11d39667d5f79afedac05c4c75e4
7
- data.tar.gz: 8e00de4e37bb7e3d971f39eb1dbce3932131941b80af2549c0e778d23a3c5a6a90b0d57e5aa956582467e112d3e052e0f496c71d602c61ccebbd06a453bff2cd
6
+ metadata.gz: 1b14250ce188e25a4c709b94ffcf16a88d57f4ceb715c737feaab66d1628fcbc041993ffc9e71c2e6496670e1f78846464932a804f392619608896f059aaf62f
7
+ data.tar.gz: 696600fa8dbf40b884949ca2cd1bb4ab16c21b5f1ec947fa77acb7593274638cbb54fb41be80a40ec820981e164ac9eeddf0643c6e9f52e0335b91ccd1c84b60
data/CHANGELOG.md CHANGED
@@ -6,6 +6,19 @@ 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.10] - 2026-08-24
10
+
11
+ ### Fixed
12
+ - **Help modal silently truncating its own key list** - `show_help_modal` capped its height at a hardcoded value that no longer fit all the key mappings, cutting off everything from the ownership toggle (`o`) onward - including `b`, `H`, `R`, and even `q`/`ESC` to quit - on any normal-sized terminal. The modal now sizes itself from the actual number of key mappings, so it never happens again.
13
+
14
+ ## [1.0.9] - 2026-08-19
15
+
16
+ ### Added
17
+ - **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
18
+
19
+ ### Fixed
20
+ - **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.
21
+
9
22
  ## [1.0.8] - 2026-08-19
10
23
 
11
24
  ### Fixed
data/Gemfile CHANGED
@@ -5,6 +5,9 @@ source 'https://rubygems.org'
5
5
  # Terminal UI library
6
6
  gem 'curses', '~> 1.4'
7
7
 
8
+ # Syntax highlighting for the file preview panel
9
+ gem 'rouge', '~> 4.0'
10
+
8
11
  group :development do
9
12
  # Code linting and style checking
10
13
  gem 'rubocop', '~> 1.81', require: false
data/README.md CHANGED
@@ -16,7 +16,7 @@ Simple, fast, and elegant.
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
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
+ - 👀 **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)
20
20
  - 📊 **Smart Scrolling** - Handles directories with hundreds of items
21
21
  - 🔍 **Git Branch Display** - Shows current git branch in header
22
22
  - 👤 **Ownership Toggle** - View file permissions and ownership (press 'o')
@@ -9,39 +9,6 @@ module Sergeant
9
9
  max_y = lines
10
10
  max_x = cols
11
11
 
12
- modal_height = [28, max_y - 4].min # Adaptive height
13
- modal_width = [70, max_x - 4].min # Adaptive width
14
- modal_y = (max_y - modal_height) / 2
15
- modal_x = (max_x - modal_width) / 2
16
-
17
- (modal_y..(modal_y + modal_height)).each do |y|
18
- setpos(y, modal_x)
19
- attron(color_pair(3)) do
20
- addstr(' ' * modal_width)
21
- end
22
- end
23
-
24
- setpos(modal_y, modal_x)
25
- attron(color_pair(4) | Curses::A_BOLD) do
26
- addstr("\u250C#{'─' * (modal_width - 2)}\u2510")
27
- end
28
-
29
- setpos(modal_y + 1, modal_x)
30
- attron(color_pair(4) | Curses::A_BOLD) do
31
- addstr('│')
32
- end
33
- attron(color_pair(5) | Curses::A_BOLD) do
34
- addstr(' Key Mappings '.center(modal_width - 2))
35
- end
36
- attron(color_pair(4) | Curses::A_BOLD) do
37
- addstr('│')
38
- end
39
-
40
- setpos(modal_y + 2, modal_x)
41
- attron(color_pair(4)) do
42
- addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
43
- end
44
-
45
12
  help_lines = [
46
13
  'Navigation:',
47
14
  ' ↑/k - Move up',
@@ -75,6 +42,42 @@ module Sergeant
75
42
  ' q / ESC - Quit and cd to current directory'
76
43
  ]
77
44
 
45
+ # Height must track the number of help lines - a fixed cap silently
46
+ # truncates the bottom of the list (including "quit"!) every time a
47
+ # new key mapping is added without also bumping the cap by hand.
48
+ modal_height = [help_lines.length + 4, max_y - 4].min
49
+ modal_width = [70, max_x - 4].min # Adaptive width
50
+ modal_y = (max_y - modal_height) / 2
51
+ modal_x = (max_x - modal_width) / 2
52
+
53
+ (modal_y..(modal_y + modal_height)).each do |y|
54
+ setpos(y, modal_x)
55
+ attron(color_pair(3)) do
56
+ addstr(' ' * modal_width)
57
+ end
58
+ end
59
+
60
+ setpos(modal_y, modal_x)
61
+ attron(color_pair(4) | Curses::A_BOLD) do
62
+ addstr("\u250C#{'─' * (modal_width - 2)}\u2510")
63
+ end
64
+
65
+ setpos(modal_y + 1, modal_x)
66
+ attron(color_pair(4) | Curses::A_BOLD) do
67
+ addstr('│')
68
+ end
69
+ attron(color_pair(5) | Curses::A_BOLD) do
70
+ addstr(' Key Mappings '.center(modal_width - 2))
71
+ end
72
+ attron(color_pair(4) | Curses::A_BOLD) do
73
+ addstr('│')
74
+ end
75
+
76
+ setpos(modal_y + 2, modal_x)
77
+ attron(color_pair(4)) do
78
+ addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
79
+ end
80
+
78
81
  help_lines.each_with_index do |line, idx|
79
82
  break if idx >= modal_height - 4 # Stop if we run out of vertical space
80
83
 
@@ -26,6 +26,18 @@ module Sergeant
26
26
  executable: 10
27
27
  }.freeze
28
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
+
29
41
  def draw_screen
30
42
  # `erase` only rewrites the virtual buffer; `refresh` then diffs it
31
43
  # against the physical screen and repaints just the changed cells.
@@ -222,7 +234,7 @@ module Sergeant
222
234
  if @preview_lines.empty?
223
235
  draw_preview_message(col, width, '(empty file)')
224
236
  else
225
- draw_preview_lines(col, width, visible_lines, @preview_lines, color_pair(2))
237
+ draw_preview_highlighted_lines(col, width, visible_lines, @preview_lines)
226
238
  end
227
239
  when :directory
228
240
  if @preview_lines.empty?
@@ -274,6 +286,37 @@ module Sergeant
274
286
  end
275
287
  end
276
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
+
277
320
  def draw_preview_message(col, width, message)
278
321
  setpos(3, col)
279
322
  attron(color_pair(2) | Curses::A_DIM) do
@@ -26,6 +26,22 @@ module Sergeant
26
26
  :file
27
27
  end
28
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
+
29
45
  def get_git_branch
30
46
  return nil unless Dir.exist?(File.join(@current_dir, '.git'))
31
47
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sergeant
4
- VERSION = '1.0.8'
4
+ VERSION = '1.0.10'
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'
@@ -184,6 +185,15 @@ class SergeantApp
184
185
  init_pair(8, Sergeant::Config.get_color(@config['media']), Curses::COLOR_BLACK)
185
186
  init_pair(9, Sergeant::Config.get_color(@config['code']), Curses::COLOR_BLACK)
186
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
187
197
  end
188
198
 
189
199
  def search_files
@@ -384,7 +394,7 @@ class SergeantApp
384
394
  if item[:type] == :directory
385
395
  [:directory, build_directory_preview(item[:path])]
386
396
  elsif text_file?(item[:path])
387
- [:text, build_text_preview(item[:path])]
397
+ [:text, build_text_preview(item[:path], item[:name])]
388
398
  else
389
399
  [:binary, []]
390
400
  end
@@ -405,20 +415,65 @@ class SergeantApp
405
415
  []
406
416
  end
407
417
 
408
- def build_text_preview(path)
409
- lines = []
418
+ def build_text_preview(path, filename)
419
+ raw_lines = []
410
420
 
411
421
  File.foreach(path, mode: 'rb') do |line|
412
422
  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
423
+ raw_lines << text.force_encoding('UTF-8').scrub('?').gsub("\t", ' ')
424
+ break if raw_lines.length >= PREVIEW_LINE_LIMIT
415
425
  end
416
426
 
417
- lines
427
+ highlight_lines(raw_lines, filename)
418
428
  rescue StandardError
419
429
  []
420
430
  end
421
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
475
+ end
476
+
422
477
  def refresh_items
423
478
  entries = Dir.entries(@current_dir).reject { |e| e == '.' || e == '..' }
424
479
  @items = []
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.8
4
+ version: 1.0.10
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: []