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 +4 -4
- data/CHANGELOG.md +20 -0
- data/Gemfile +3 -2
- data/README.md +7 -1
- data/lib/sergeant/config.rb +9 -1
- data/lib/sergeant/modals/help.rb +1 -0
- data/lib/sergeant/rendering.rb +188 -18
- data/lib/sergeant/utils.rb +38 -0
- data/lib/sergeant/version.rb +1 -1
- data/lib/sergeant.rb +139 -1
- data/sergeant.gemspec +1 -0
- metadata +19 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ac7149617ce3dd0a2e0c767ff46f6d2627fe917383303835f40a37461097dd8
|
|
4
|
+
data.tar.gz: 3d5621c41d6eec97194f62cab6ed525e1e1dd826fc9ed3fce3f9a08d67fba52a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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]
|
data/lib/sergeant/config.rb
CHANGED
|
@@ -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]
|
data/lib/sergeant/modals/help.rb
CHANGED
data/lib/sergeant/rendering.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
103
|
+
remaining = status_width - 2 - path_display.length - branch_text.length - status_text.length
|
|
62
104
|
else
|
|
63
|
-
path_max_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 =
|
|
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
|
-
|
|
107
|
-
|
|
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,
|
|
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
|
data/lib/sergeant/utils.rb
CHANGED
|
@@ -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
|
|
data/lib/sergeant/version.rb
CHANGED
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
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.
|
|
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:
|
|
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:
|
|
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: []
|