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.
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