sergeant 1.0.0
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 +7 -0
- data/CHANGELOG.md +49 -0
- data/Gemfile +18 -0
- data/LICENSE +21 -0
- data/README.md +245 -0
- data/bin/sgt +13 -0
- data/lib/.DS_Store +0 -0
- data/lib/sergeant/.DS_Store +0 -0
- data/lib/sergeant/config.rb +120 -0
- data/lib/sergeant/modals/.DS_Store +0 -0
- data/lib/sergeant/modals/dialogs.rb +352 -0
- data/lib/sergeant/modals/file_operations.rb +635 -0
- data/lib/sergeant/modals/help.rb +109 -0
- data/lib/sergeant/modals/navigation.rb +245 -0
- data/lib/sergeant/modals.rb +17 -0
- data/lib/sergeant/rendering.rb +160 -0
- data/lib/sergeant/utils.rb +121 -0
- data/lib/sergeant/version.rb +5 -0
- data/lib/sergeant.rb +305 -0
- data/logo.svg +7831 -0
- data/sergeant.gemspec +43 -0
- metadata +108 -0
data/lib/sergeant.rb
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Sergeant (sgt) - Interactive TUI directory navigator
|
|
4
|
+
# Navigate directories with arrow keys, press Enter to cd
|
|
5
|
+
|
|
6
|
+
require 'curses'
|
|
7
|
+
require 'pathname'
|
|
8
|
+
require 'etc'
|
|
9
|
+
require 'fileutils'
|
|
10
|
+
|
|
11
|
+
require_relative 'sergeant/version'
|
|
12
|
+
require_relative 'sergeant/config'
|
|
13
|
+
require_relative 'sergeant/utils'
|
|
14
|
+
require_relative 'sergeant/modals'
|
|
15
|
+
require_relative 'sergeant/rendering'
|
|
16
|
+
|
|
17
|
+
# Main application class for Sergeant
|
|
18
|
+
class SergeantApp
|
|
19
|
+
include Curses
|
|
20
|
+
include Sergeant::Utils
|
|
21
|
+
include Sergeant::Modals
|
|
22
|
+
include Sergeant::Rendering
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@current_dir = Dir.pwd
|
|
26
|
+
@selected_index = 0
|
|
27
|
+
@scroll_offset = 0
|
|
28
|
+
@show_ownership = false
|
|
29
|
+
@config = Sergeant::Config.load_config
|
|
30
|
+
@bookmarks = Sergeant::Config.load_bookmarks
|
|
31
|
+
@marked_items = []
|
|
32
|
+
@copied_items = []
|
|
33
|
+
@cut_mode = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run
|
|
37
|
+
init_screen
|
|
38
|
+
start_color
|
|
39
|
+
curs_set(0)
|
|
40
|
+
noecho
|
|
41
|
+
stdscr.keypad(true)
|
|
42
|
+
|
|
43
|
+
apply_color_theme
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
loop do
|
|
47
|
+
refresh_items
|
|
48
|
+
draw_screen
|
|
49
|
+
|
|
50
|
+
key = getch
|
|
51
|
+
case key
|
|
52
|
+
when Curses::Key::UP, 'k'
|
|
53
|
+
move_selection(-1)
|
|
54
|
+
when Curses::Key::DOWN, 'j'
|
|
55
|
+
move_selection(1)
|
|
56
|
+
when 10, 13, Curses::Key::RIGHT, 'l'
|
|
57
|
+
item = @items[@selected_index]
|
|
58
|
+
if item && item[:type] == :directory
|
|
59
|
+
@current_dir = item[:path]
|
|
60
|
+
@selected_index = 0
|
|
61
|
+
@scroll_offset = 0
|
|
62
|
+
elsif item && item[:type] == :file
|
|
63
|
+
preview_file
|
|
64
|
+
end
|
|
65
|
+
when 'b'
|
|
66
|
+
goto_bookmark
|
|
67
|
+
when 'o'
|
|
68
|
+
@show_ownership = !@show_ownership
|
|
69
|
+
when 'e'
|
|
70
|
+
edit_file
|
|
71
|
+
when 'v'
|
|
72
|
+
preview_file
|
|
73
|
+
when 32, ' '
|
|
74
|
+
toggle_mark
|
|
75
|
+
when 'c'
|
|
76
|
+
copy_marked_items
|
|
77
|
+
when 'x'
|
|
78
|
+
cut_marked_items
|
|
79
|
+
when 'd'
|
|
80
|
+
delete_marked_items
|
|
81
|
+
when 'r'
|
|
82
|
+
rename_item
|
|
83
|
+
when 'p'
|
|
84
|
+
paste_items
|
|
85
|
+
when 'u'
|
|
86
|
+
unmark_all
|
|
87
|
+
when 'm'
|
|
88
|
+
show_help_modal
|
|
89
|
+
when 'n'
|
|
90
|
+
create_new_with_modal
|
|
91
|
+
when ':'
|
|
92
|
+
execute_terminal_command
|
|
93
|
+
when '/'
|
|
94
|
+
search_files
|
|
95
|
+
when 'q', 27
|
|
96
|
+
close_screen
|
|
97
|
+
puts @current_dir
|
|
98
|
+
exit 0
|
|
99
|
+
when Curses::Key::LEFT, 'h'
|
|
100
|
+
parent = File.dirname(@current_dir)
|
|
101
|
+
if parent != @current_dir
|
|
102
|
+
@current_dir = parent
|
|
103
|
+
@selected_index = 0
|
|
104
|
+
@scroll_offset = 0
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
rescue Interrupt
|
|
109
|
+
close_screen
|
|
110
|
+
exit 0
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
close_screen
|
|
113
|
+
puts "Error: #{e.message}"
|
|
114
|
+
puts e.backtrace
|
|
115
|
+
exit 1
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def apply_color_theme
|
|
122
|
+
init_pair(1, Sergeant::Config.get_color(@config['directories']), Curses::COLOR_BLACK)
|
|
123
|
+
init_pair(2, Sergeant::Config.get_color(@config['files']), Curses::COLOR_BLACK)
|
|
124
|
+
init_pair(3, Sergeant::Config.get_color(@config['selected_fg']),
|
|
125
|
+
Sergeant::Config.get_color(@config['selected_bg']))
|
|
126
|
+
init_pair(4, Sergeant::Config.get_color(@config['header']), Curses::COLOR_BLACK)
|
|
127
|
+
init_pair(5, Sergeant::Config.get_color(@config['path']), Curses::COLOR_BLACK)
|
|
128
|
+
init_pair(6, Sergeant::Config.get_color(@config['git_branch']), Curses::COLOR_BLACK)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def search_files
|
|
132
|
+
close_screen
|
|
133
|
+
|
|
134
|
+
if fzf_available?
|
|
135
|
+
fzf_cmd = 'fzf --height=40% --reverse --prompt="Search: " ' \
|
|
136
|
+
'--preview="ls -lah {}" --preview-window=right:50%'
|
|
137
|
+
selected = `find "#{@current_dir}" -type f -o -type d 2>/dev/null | #{fzf_cmd}`.strip
|
|
138
|
+
else
|
|
139
|
+
puts 'fzf not found - using fallback search'
|
|
140
|
+
print 'Search (regex): '
|
|
141
|
+
query = gets.chomp
|
|
142
|
+
|
|
143
|
+
if query.empty?
|
|
144
|
+
selected = nil
|
|
145
|
+
else
|
|
146
|
+
results = `find "#{@current_dir}" 2>/dev/null | grep -i "#{query}"`.split("\n")
|
|
147
|
+
|
|
148
|
+
if results.empty?
|
|
149
|
+
puts 'No results found. Press Enter to continue...'
|
|
150
|
+
gets
|
|
151
|
+
selected = nil
|
|
152
|
+
elsif results.length == 1
|
|
153
|
+
selected = results.first
|
|
154
|
+
else
|
|
155
|
+
puts "\nResults:"
|
|
156
|
+
results.first(20).each_with_index do |result, idx|
|
|
157
|
+
puts "#{idx + 1}. #{result}"
|
|
158
|
+
end
|
|
159
|
+
puts '...' if results.length > 20
|
|
160
|
+
print "\nSelect number (or Enter to cancel): "
|
|
161
|
+
choice = gets.chomp
|
|
162
|
+
selected = choice.empty? ? nil : results[choice.to_i - 1]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
init_screen
|
|
168
|
+
start_color
|
|
169
|
+
curs_set(0)
|
|
170
|
+
noecho
|
|
171
|
+
stdscr.keypad(true)
|
|
172
|
+
apply_color_theme
|
|
173
|
+
|
|
174
|
+
return unless selected && !selected.empty?
|
|
175
|
+
|
|
176
|
+
@current_dir = if File.directory?(selected)
|
|
177
|
+
selected
|
|
178
|
+
else
|
|
179
|
+
File.dirname(selected)
|
|
180
|
+
end
|
|
181
|
+
@selected_index = 0
|
|
182
|
+
@scroll_offset = 0
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def refresh_items
|
|
186
|
+
entries = Dir.entries(@current_dir).reject { |e| e == '.' }
|
|
187
|
+
|
|
188
|
+
@items = []
|
|
189
|
+
|
|
190
|
+
unless @current_dir == '/'
|
|
191
|
+
@items << {
|
|
192
|
+
name: '..',
|
|
193
|
+
type: :directory,
|
|
194
|
+
path: File.dirname(@current_dir),
|
|
195
|
+
size: nil,
|
|
196
|
+
mtime: nil,
|
|
197
|
+
owner: nil,
|
|
198
|
+
perms: nil
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
directories = []
|
|
203
|
+
files = []
|
|
204
|
+
|
|
205
|
+
entries.each do |entry|
|
|
206
|
+
full_path = File.join(@current_dir, entry)
|
|
207
|
+
begin
|
|
208
|
+
stat = File.stat(full_path)
|
|
209
|
+
owner_info = get_owner_info(stat)
|
|
210
|
+
is_dir = File.directory?(full_path)
|
|
211
|
+
perms = format_permissions(stat.mode, is_dir)
|
|
212
|
+
|
|
213
|
+
if is_dir
|
|
214
|
+
directories << {
|
|
215
|
+
name: entry,
|
|
216
|
+
type: :directory,
|
|
217
|
+
path: File.absolute_path(full_path),
|
|
218
|
+
size: stat.size,
|
|
219
|
+
mtime: stat.mtime,
|
|
220
|
+
owner: owner_info,
|
|
221
|
+
perms: perms
|
|
222
|
+
}
|
|
223
|
+
else
|
|
224
|
+
files << {
|
|
225
|
+
name: entry,
|
|
226
|
+
type: :file,
|
|
227
|
+
path: File.absolute_path(full_path),
|
|
228
|
+
size: stat.size,
|
|
229
|
+
mtime: stat.mtime,
|
|
230
|
+
owner: owner_info,
|
|
231
|
+
perms: perms
|
|
232
|
+
}
|
|
233
|
+
end
|
|
234
|
+
rescue Errno::EACCES, Errno::ENOENT
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
directories.sort_by! { |d| d[:name].downcase }
|
|
239
|
+
files.sort_by! { |f| f[:name].downcase }
|
|
240
|
+
|
|
241
|
+
@items += directories + files
|
|
242
|
+
|
|
243
|
+
@selected_index = [@selected_index, @items.length - 1].min
|
|
244
|
+
@selected_index = 0 if @selected_index.negative?
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def move_selection(delta)
|
|
248
|
+
return if @items.empty?
|
|
249
|
+
|
|
250
|
+
@selected_index = (@selected_index + delta).clamp(0, @items.length - 1)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def toggle_mark
|
|
254
|
+
item = @items[@selected_index]
|
|
255
|
+
return unless item && item[:name] != '..'
|
|
256
|
+
|
|
257
|
+
path = item[:path]
|
|
258
|
+
if @marked_items.include?(path)
|
|
259
|
+
@marked_items.delete(path)
|
|
260
|
+
else
|
|
261
|
+
@marked_items << path
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def copy_marked_items
|
|
266
|
+
return if @marked_items.empty?
|
|
267
|
+
|
|
268
|
+
@copied_items = @marked_items.dup
|
|
269
|
+
@cut_mode = false
|
|
270
|
+
show_info_modal("#{@copied_items.length} item(s) copied")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def cut_marked_items
|
|
274
|
+
return if @marked_items.empty?
|
|
275
|
+
|
|
276
|
+
@copied_items = @marked_items.dup
|
|
277
|
+
@cut_mode = true
|
|
278
|
+
show_info_modal("#{@copied_items.length} item(s) cut")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def unmark_all
|
|
282
|
+
@marked_items.clear
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def delete_marked_items
|
|
286
|
+
return if @marked_items.empty?
|
|
287
|
+
|
|
288
|
+
return unless confirm_delete_modal(@marked_items.length)
|
|
289
|
+
|
|
290
|
+
delete_with_modal
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def rename_item
|
|
294
|
+
item = @items[@selected_index]
|
|
295
|
+
return unless item && item[:name] != '..'
|
|
296
|
+
|
|
297
|
+
rename_with_modal(item)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def paste_items
|
|
301
|
+
return if @copied_items.empty?
|
|
302
|
+
|
|
303
|
+
paste_with_modal
|
|
304
|
+
end
|
|
305
|
+
end
|