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.
@@ -0,0 +1,635 @@
1
+ # frozen_string_literal: true
2
+
3
+ # File operation modals (preview, edit, paste, delete, rename)
4
+
5
+ module Sergeant
6
+ module Modals
7
+ module FileOperations
8
+ def edit_file
9
+ item = @items[@selected_index]
10
+
11
+ # Only edit files, not directories
12
+ return unless item && item[:type] == :file
13
+
14
+ file_path = item[:path]
15
+
16
+ # Close curses screen temporarily
17
+ close_screen
18
+
19
+ begin
20
+ # Respect user's preferred editor
21
+ editor = ENV['EDITOR'] || ENV['VISUAL']
22
+
23
+ if editor
24
+ # Use user's preferred editor
25
+ system("#{editor} \"#{file_path}\"")
26
+ elsif nvim_available?
27
+ # Second fallback: nvim (modern vim)
28
+ system("nvim \"#{file_path}\"")
29
+ elsif nano_available?
30
+ # First fallback: nano (user-friendly)
31
+ system("nano \"#{file_path}\"")
32
+ elsif vim_available?
33
+ # Third fallback: vim
34
+ system("vim \"#{file_path}\"")
35
+ elsif vi_available?
36
+ # Fourth fallback: vi (always available on POSIX)
37
+ system("vi \"#{file_path}\"")
38
+ else
39
+ # This should never happen on POSIX systems
40
+ puts 'No editor found. Please set $EDITOR environment variable.'
41
+ puts 'Press Enter to continue...'
42
+ gets
43
+ end
44
+ rescue StandardError => e
45
+ puts "Error opening editor: #{e.message}"
46
+ puts 'Press Enter to continue...'
47
+ gets
48
+ end
49
+
50
+ # Restore curses screen
51
+ init_screen
52
+ start_color
53
+ curs_set(0)
54
+ noecho
55
+ stdscr.keypad(true)
56
+ apply_color_theme
57
+ end
58
+
59
+ def preview_file
60
+ item = @items[@selected_index]
61
+
62
+ # Only preview files, not directories
63
+ return unless item && item[:type] == :file
64
+
65
+ file_path = item[:path]
66
+
67
+ # Check if it's a text file
68
+ unless text_file?(file_path)
69
+ show_error_modal('Cannot preview: Not a text file or too large (>50MB)')
70
+ return
71
+ end
72
+
73
+ # Close curses screen temporarily
74
+ close_screen
75
+
76
+ begin
77
+ file_ext = File.extname(file_path).downcase
78
+
79
+ # Use glow for markdown files if available, otherwise fall back to less
80
+ if file_ext == '.md' && glow_available?
81
+ system("glow -p \"#{file_path}\"")
82
+ elsif file_ext == '.md'
83
+ system("less -R -F -X \"#{file_path}\"")
84
+ elsif nvim_available?
85
+ # For all other text files, prefer nvim for read-only
86
+ system("nvim -R \"#{file_path}\"")
87
+ elsif vim_available?
88
+ system("vim -R \"#{file_path}\"")
89
+ elsif vi_available?
90
+ system("vi -R \"#{file_path}\"")
91
+ elsif nano_available?
92
+ system("nano -v \"#{file_path}\"")
93
+ else
94
+ # Ultimate fallback to less
95
+ system("less -R -F -X \"#{file_path}\"")
96
+ end
97
+ rescue StandardError => e
98
+ puts "Error previewing file: #{e.message}"
99
+ puts 'Press Enter to continue...'
100
+ gets
101
+ end
102
+
103
+ # Restore curses screen
104
+ init_screen
105
+ start_color
106
+ curs_set(0)
107
+ noecho
108
+ stdscr.keypad(true)
109
+ apply_color_theme
110
+ end
111
+
112
+ def paste_with_modal
113
+ require 'fileutils'
114
+
115
+ success_count = 0
116
+ error_count = 0
117
+ errors = []
118
+
119
+ @copied_items.each do |source_path|
120
+ next unless File.exist?(source_path)
121
+
122
+ filename = File.basename(source_path)
123
+ dest_path = File.join(@current_dir, filename)
124
+
125
+ begin
126
+ if File.exist?(dest_path)
127
+ # Handle conflict
128
+ action = ask_conflict_resolution(filename)
129
+ case action
130
+ when :skip
131
+ next
132
+ when :overwrite
133
+ FileUtils.rm_rf(dest_path)
134
+ when :rename
135
+ dest_path = get_unique_filename(dest_path)
136
+ end
137
+ end
138
+
139
+ # Perform copy or move
140
+ if @cut_mode
141
+ FileUtils.mv(source_path, dest_path)
142
+ elsif File.directory?(source_path)
143
+ FileUtils.cp_r(source_path, dest_path)
144
+ else
145
+ FileUtils.cp(source_path, dest_path)
146
+ end
147
+
148
+ success_count += 1
149
+ rescue StandardError => e
150
+ error_count += 1
151
+ errors << "#{filename}: #{e.message}"
152
+ end
153
+ end
154
+
155
+ # Clean up after operation
156
+ @marked_items.clear
157
+ @copied_items.clear
158
+ @cut_mode = false if @cut_mode
159
+
160
+ # Show result
161
+ if error_count.positive?
162
+ show_error_modal("Pasted #{success_count}, #{error_count} error(s)")
163
+ else
164
+ show_info_modal("Successfully pasted #{success_count} item(s)")
165
+ end
166
+ end
167
+
168
+ def get_unique_filename(path)
169
+ dir = File.dirname(path)
170
+ basename = File.basename(path, '.*')
171
+ ext = File.extname(path)
172
+ counter = 1
173
+
174
+ loop do
175
+ new_path = File.join(dir, "#{basename}_#{counter}#{ext}")
176
+ return new_path unless File.exist?(new_path)
177
+
178
+ counter += 1
179
+ end
180
+ end
181
+
182
+ def delete_with_modal
183
+ require 'fileutils'
184
+
185
+ success_count = 0
186
+ error_count = 0
187
+ errors = []
188
+
189
+ @marked_items.each do |item_path|
190
+ next unless File.exist?(item_path)
191
+
192
+ begin
193
+ FileUtils.rm_rf(item_path)
194
+ success_count += 1
195
+ rescue StandardError => e
196
+ error_count += 1
197
+ filename = File.basename(item_path)
198
+ errors << "#{filename}: #{e.message}"
199
+ end
200
+ end
201
+
202
+ # Clear marked items after deletion
203
+ @marked_items.clear
204
+
205
+ # Show result
206
+ if error_count.positive?
207
+ show_error_modal("Deleted #{success_count}, #{error_count} error(s)")
208
+ else
209
+ show_info_modal("Successfully deleted #{success_count} item(s)")
210
+ end
211
+ end
212
+
213
+ def rename_with_modal(item)
214
+ require 'fileutils'
215
+
216
+ max_y = lines
217
+ max_x = cols
218
+
219
+ modal_height = 8
220
+ modal_width = 70
221
+ modal_y = (max_y - modal_height) / 2
222
+ modal_x = (max_x - modal_width) / 2
223
+
224
+ (modal_y..(modal_y + modal_height)).each do |y|
225
+ setpos(y, modal_x)
226
+ attron(color_pair(3)) do
227
+ addstr(' ' * modal_width)
228
+ end
229
+ end
230
+
231
+ setpos(modal_y, modal_x)
232
+ attron(color_pair(4) | Curses::A_BOLD) do
233
+ addstr("\u250C#{'─' * (modal_width - 2)}\u2510")
234
+ end
235
+
236
+ setpos(modal_y + 1, modal_x)
237
+ attron(color_pair(4) | Curses::A_BOLD) do
238
+ addstr('│')
239
+ end
240
+ attron(color_pair(5) | Curses::A_BOLD) do
241
+ addstr(' Rename '.center(modal_width - 2))
242
+ end
243
+ attron(color_pair(4) | Curses::A_BOLD) do
244
+ addstr('│')
245
+ end
246
+
247
+ setpos(modal_y + 2, modal_x)
248
+ attron(color_pair(4)) do
249
+ addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
250
+ end
251
+
252
+ msg = "Current: #{item[:name]}"
253
+ setpos(modal_y + 3, modal_x)
254
+ attron(color_pair(4)) do
255
+ addstr('│ ')
256
+ end
257
+ display_msg = msg.length > modal_width - 4 ? "#{msg[0..(modal_width - 8)]}..." : msg
258
+ addstr(display_msg.ljust(modal_width - 4))
259
+ attron(color_pair(4)) do
260
+ addstr(' │')
261
+ end
262
+
263
+ setpos(modal_y + 4, modal_x)
264
+ attron(color_pair(4)) do
265
+ addstr("\u2502#{' ' * (modal_width - 2)}\u2502")
266
+ end
267
+
268
+ setpos(modal_y + 5, modal_x)
269
+ attron(color_pair(4)) do
270
+ addstr('│ ')
271
+ end
272
+ prompt = 'New name: '
273
+ attron(color_pair(5)) do
274
+ addstr(prompt)
275
+ end
276
+ addstr(' ' * (modal_width - 4 - prompt.length))
277
+ attron(color_pair(4)) do
278
+ addstr(' │')
279
+ end
280
+
281
+ setpos(modal_y + 6, modal_x)
282
+ attron(color_pair(4)) do
283
+ addstr('│ ')
284
+ end
285
+
286
+ curs_set(1)
287
+ echo
288
+ setpos(modal_y + 6, modal_x + 2)
289
+
290
+ input_width = modal_width - 5
291
+ new_name = item[:name].dup
292
+
293
+ # Position cursor at end of name
294
+ setpos(modal_y + 6, modal_x + 2)
295
+ addstr(new_name.ljust(input_width))
296
+ setpos(modal_y + 6, modal_x + 2 + new_name.length)
297
+
298
+ loop do
299
+ ch = getch
300
+
301
+ case ch
302
+ when 10, 13
303
+ break
304
+ when 27
305
+ new_name = ''
306
+ break
307
+ when 127, Curses::Key::BACKSPACE
308
+ if new_name.length.positive?
309
+ new_name = new_name[0...-1]
310
+ setpos(modal_y + 6, modal_x + 2)
311
+ addstr(new_name.ljust(input_width))
312
+ setpos(modal_y + 6, modal_x + 2 + new_name.length)
313
+ end
314
+ else
315
+ if ch.is_a?(String) && new_name.length < input_width && ch != '/'
316
+ new_name += ch
317
+ setpos(modal_y + 6, modal_x + 2)
318
+ addstr(new_name.ljust(input_width))
319
+ setpos(modal_y + 6, modal_x + 2 + new_name.length)
320
+ end
321
+ end
322
+
323
+ refresh
324
+ end
325
+
326
+ noecho
327
+ curs_set(0)
328
+
329
+ setpos(modal_y + modal_height - 1, modal_x)
330
+ attron(color_pair(4) | Curses::A_BOLD) do
331
+ addstr("\u2514#{'─' * (modal_width - 2)}\u2518")
332
+ end
333
+
334
+ refresh
335
+
336
+ new_name = new_name.strip
337
+
338
+ return if new_name.empty? || new_name == item[:name]
339
+
340
+ old_path = item[:path]
341
+ new_path = File.join(File.dirname(old_path), new_name)
342
+
343
+ if File.exist?(new_path)
344
+ show_error_modal('File or directory already exists!')
345
+ else
346
+ begin
347
+ FileUtils.mv(old_path, new_path)
348
+ show_info_modal('Renamed successfully!')
349
+
350
+ # Update marked items if this item was marked
351
+ if @marked_items.include?(old_path)
352
+ @marked_items.delete(old_path)
353
+ @marked_items << new_path
354
+ end
355
+
356
+ # Update copied items if this item was copied
357
+ if @copied_items.include?(old_path)
358
+ @copied_items.delete(old_path)
359
+ @copied_items << new_path
360
+ end
361
+ rescue StandardError => e
362
+ show_error_modal("Error: #{e.message}")
363
+ end
364
+ end
365
+ end
366
+
367
+ def create_new_with_modal
368
+ max_y = lines
369
+ max_x = cols
370
+
371
+ modal_width = [60, max_x - 4].min
372
+ modal_height = [10, max_y - 8].min # Adaptive height (more conservative margin)
373
+ modal_x = (max_x - modal_width) / 2
374
+ modal_y = (max_y - modal_height) / 2
375
+
376
+ # Draw modal box
377
+ setpos(modal_y, modal_x)
378
+ attron(color_pair(4) | Curses::A_BOLD) do
379
+ addstr("\u250C#{'─' * (modal_width - 2)}\u2510")
380
+ end
381
+
382
+ (1...modal_height - 1).each do |i|
383
+ setpos(modal_y + i, modal_x)
384
+ attron(color_pair(4) | Curses::A_BOLD) do
385
+ addstr("\u2502#{' ' * (modal_width - 2)}\u2502")
386
+ end
387
+ end
388
+
389
+ # Bottom border
390
+ setpos(modal_y + modal_height - 1, modal_x)
391
+ attron(color_pair(4) | Curses::A_BOLD) do
392
+ addstr("\u2514#{'─' * (modal_width - 2)}\u2518")
393
+ end
394
+
395
+ # Title
396
+ setpos(modal_y + 1, modal_x + 2)
397
+ attron(color_pair(4) | Curses::A_BOLD) do
398
+ addstr('Create New')
399
+ end
400
+
401
+ # Prompt
402
+ setpos(modal_y + 3, modal_x + 2)
403
+ addstr('What do you want to create?')
404
+
405
+ setpos(modal_y + 5, modal_x + 2)
406
+ attron(color_pair(1) | Curses::A_BOLD) do
407
+ addstr('[f] File [d] Directory [ESC] Cancel')
408
+ end
409
+
410
+ refresh
411
+
412
+ # Get choice
413
+ choice = getch
414
+ return if choice == 27 # ESC
415
+
416
+ create_type = case choice
417
+ when 'f', 'F'
418
+ :file
419
+ when 'd', 'D'
420
+ :directory
421
+ else
422
+ return
423
+ end
424
+
425
+ # Clear and ask for name
426
+ setpos(modal_y + 3, modal_x + 2)
427
+ addstr(' ' * (modal_width - 4))
428
+ setpos(modal_y + 5, modal_x + 2)
429
+ addstr(' ' * (modal_width - 4))
430
+
431
+ setpos(modal_y + 3, modal_x + 2)
432
+ type_text = create_type == :file ? 'file' : 'directory'
433
+ addstr("Enter #{type_text} name:")
434
+
435
+ setpos(modal_y + 5, modal_x + 2)
436
+ addstr('(ESC to cancel)')
437
+
438
+ # Input field
439
+ input_width = modal_width - 6
440
+ setpos(modal_y + 6, modal_x + 2)
441
+ attron(color_pair(3)) do
442
+ addstr(' ' * input_width)
443
+ end
444
+
445
+ # Get input
446
+ echo
447
+ curs_set(1)
448
+ new_name = ''
449
+
450
+ loop do
451
+ setpos(modal_y + 6, modal_x + 2 + new_name.length)
452
+ refresh
453
+
454
+ ch = getch
455
+
456
+ case ch
457
+ when 10, 13
458
+ break
459
+ when 27
460
+ new_name = ''
461
+ break
462
+ when 127, Curses::Key::BACKSPACE
463
+ if new_name.length.positive?
464
+ new_name = new_name[0...-1]
465
+ setpos(modal_y + 6, modal_x + 2)
466
+ addstr(new_name.ljust(input_width))
467
+ setpos(modal_y + 6, modal_x + 2 + new_name.length)
468
+ end
469
+ else
470
+ if ch.is_a?(String) && new_name.length < input_width && ch != '/'
471
+ new_name += ch
472
+ setpos(modal_y + 6, modal_x + 2)
473
+ addstr(new_name.ljust(input_width))
474
+ setpos(modal_y + 6, modal_x + 2 + new_name.length)
475
+ end
476
+ end
477
+ end
478
+
479
+ noecho
480
+ curs_set(0)
481
+
482
+ new_name = new_name.strip
483
+
484
+ return if new_name.empty?
485
+
486
+ new_path = File.join(@current_dir, new_name)
487
+
488
+ if File.exist?(new_path)
489
+ show_error_modal('File or directory already exists!')
490
+ else
491
+ begin
492
+ if create_type == :file
493
+ FileUtils.touch(new_path)
494
+ show_info_modal('File created successfully!')
495
+ else
496
+ FileUtils.mkdir_p(new_path)
497
+ show_info_modal('Directory created successfully!')
498
+ end
499
+ rescue StandardError => e
500
+ show_error_modal("Error: #{e.message}")
501
+ end
502
+ end
503
+ end
504
+
505
+ def execute_terminal_command
506
+ max_y = lines
507
+ max_x = cols
508
+
509
+ modal_width = [80, max_x - 4].min
510
+ modal_height = [8, max_y - 8].min # Adaptive height (more conservative margin)
511
+ modal_x = (max_x - modal_width) / 2
512
+ modal_y = (max_y - modal_height) / 2
513
+
514
+ # Draw modal box
515
+ setpos(modal_y, modal_x)
516
+ attron(color_pair(4) | Curses::A_BOLD) do
517
+ addstr("\u250C#{'─' * (modal_width - 2)}\u2510")
518
+ end
519
+
520
+ (1...modal_height - 1).each do |i|
521
+ setpos(modal_y + i, modal_x)
522
+ attron(color_pair(4) | Curses::A_BOLD) do
523
+ addstr("\u2502#{' ' * (modal_width - 2)}\u2502")
524
+ end
525
+ end
526
+
527
+ # Bottom border
528
+ setpos(modal_y + modal_height - 1, modal_x)
529
+ attron(color_pair(4) | Curses::A_BOLD) do
530
+ addstr("\u2514#{'─' * (modal_width - 2)}\u2518")
531
+ end
532
+
533
+ # Title
534
+ setpos(modal_y + 1, modal_x + 2)
535
+ attron(color_pair(4) | Curses::A_BOLD) do
536
+ addstr('Execute Terminal Command')
537
+ end
538
+
539
+ # Show current directory
540
+ setpos(modal_y + 2, modal_x + 2)
541
+ attron(color_pair(5)) do
542
+ dir_display = @current_dir
543
+ max_dir_len = modal_width - 8
544
+ dir_display = "...#{@current_dir[(-max_dir_len + 3)..]}" if @current_dir.length > max_dir_len
545
+ addstr("in: #{dir_display}")
546
+ end
547
+
548
+ # Prompt
549
+ setpos(modal_y + 4, modal_x + 2)
550
+ addstr(':')
551
+
552
+ # Input field
553
+ input_width = modal_width - 6
554
+ setpos(modal_y + 4, modal_x + 4)
555
+ attron(color_pair(3)) do
556
+ addstr(' ' * input_width)
557
+ end
558
+
559
+ setpos(modal_y + 6, modal_x + 2)
560
+ addstr('(ESC to cancel)')
561
+
562
+ # Get input
563
+ echo
564
+ curs_set(1)
565
+ command = ''
566
+
567
+ loop do
568
+ setpos(modal_y + 4, modal_x + 4 + command.length)
569
+ refresh
570
+
571
+ ch = getch
572
+
573
+ case ch
574
+ when 10, 13
575
+ break
576
+ when 27
577
+ command = ''
578
+ break
579
+ when 127, Curses::Key::BACKSPACE
580
+ if command.length.positive?
581
+ command = command[0...-1]
582
+ setpos(modal_y + 4, modal_x + 4)
583
+ addstr(command.ljust(input_width))
584
+ setpos(modal_y + 4, modal_x + 4 + command.length)
585
+ end
586
+ else
587
+ if ch.is_a?(String) && command.length < input_width
588
+ command += ch
589
+ setpos(modal_y + 4, modal_x + 4)
590
+ addstr(command.ljust(input_width))
591
+ setpos(modal_y + 4, modal_x + 4 + command.length)
592
+ end
593
+ end
594
+ end
595
+
596
+ noecho
597
+ curs_set(0)
598
+
599
+ command = command.strip
600
+
601
+ return if command.empty?
602
+
603
+ # Close curses and execute command
604
+ close_screen
605
+
606
+ puts "Executing: #{command}"
607
+ puts '─' * 80
608
+ puts
609
+
610
+ begin
611
+ # Change to current directory and execute
612
+ Dir.chdir(@current_dir) do
613
+ system(command)
614
+ end
615
+ rescue StandardError => e
616
+ puts
617
+ puts "Error: #{e.message}"
618
+ end
619
+
620
+ puts
621
+ puts '─' * 80
622
+ puts 'Press Enter to continue...'
623
+ gets
624
+
625
+ # Restore curses
626
+ init_screen
627
+ start_color
628
+ curs_set(0)
629
+ noecho
630
+ stdscr.keypad(true)
631
+ apply_color_theme
632
+ end
633
+ end
634
+ end
635
+ end