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,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Help modal
4
+
5
+ module Sergeant
6
+ module Modals
7
+ module Help
8
+ def show_help_modal
9
+ max_y = lines
10
+ max_x = cols
11
+
12
+ modal_height = [26, 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
+ help_lines = [
46
+ 'Navigation:',
47
+ ' ↑/k - Move up',
48
+ ' ↓/j - Move down',
49
+ ' Enter / →/l - Open directory or preview file',
50
+ ' ←/h - Go back to parent directory',
51
+ '',
52
+ 'File Operations:',
53
+ ' Space - Mark/unmark item',
54
+ ' c - Copy marked items',
55
+ ' x - Cut marked items',
56
+ ' p - Paste copied/cut items',
57
+ ' d - Delete marked items',
58
+ ' r - Rename current item',
59
+ ' u - Unmark all items',
60
+ ' n - Create new file or directory',
61
+ '',
62
+ 'Other:',
63
+ ' : - Execute terminal command',
64
+ ' e - Edit file ($EDITOR, nano, nvim, vim)',
65
+ ' v - Preview file (read-only)',
66
+ ' o - Toggle ownership display',
67
+ ' b - Go to bookmark',
68
+ ' / - Search files (with fzf if available)',
69
+ ' q / ESC - Quit and cd to current directory'
70
+ ]
71
+
72
+ help_lines.each_with_index do |line, idx|
73
+ break if idx >= modal_height - 4 # Stop if we run out of vertical space
74
+
75
+ setpos(modal_y + 3 + idx, modal_x)
76
+ attron(color_pair(4)) do
77
+ addstr('│ ')
78
+ end
79
+
80
+ # Truncate line if it's too long for the modal width
81
+ display_line = if line.length > modal_width - 4
82
+ "#{line[0...(modal_width - 7)]}..."
83
+ else
84
+ line.ljust(modal_width - 4)
85
+ end
86
+
87
+ if line.start_with?('Navigation:', 'File Operations:', 'Other:')
88
+ attron(color_pair(1) | Curses::A_BOLD) do
89
+ addstr(display_line)
90
+ end
91
+ else
92
+ addstr(display_line)
93
+ end
94
+ attron(color_pair(4)) do
95
+ addstr(' │')
96
+ end
97
+ end
98
+
99
+ setpos(modal_y + modal_height - 1, modal_x)
100
+ attron(color_pair(4) | Curses::A_BOLD) do
101
+ addstr("\u2514#{'─' * (modal_width - 2)}\u2518")
102
+ end
103
+
104
+ refresh
105
+ getch
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Navigation and bookmark modals
4
+
5
+ module Sergeant
6
+ module Modals
7
+ module Navigation
8
+ def goto_bookmark
9
+ return if @bookmarks.empty? && show_no_bookmarks_modal
10
+
11
+ max_y = lines
12
+ max_x = cols
13
+
14
+ modal_height = [@bookmarks.length + 8, max_y - 4].min
15
+ modal_width = [60, max_x - 4].min
16
+ modal_y = (max_y - modal_height) / 2
17
+ modal_x = (max_x - modal_width) / 2
18
+
19
+ (modal_y..(modal_y + modal_height)).each do |y|
20
+ setpos(y, modal_x)
21
+ attron(color_pair(3)) do
22
+ addstr(' ' * modal_width)
23
+ end
24
+ end
25
+
26
+ setpos(modal_y, modal_x)
27
+ attron(color_pair(4) | Curses::A_BOLD) do
28
+ addstr("\u250C#{'─' * (modal_width - 2)}\u2510")
29
+ end
30
+
31
+ setpos(modal_y + 1, modal_x)
32
+ attron(color_pair(4) | Curses::A_BOLD) do
33
+ addstr('│')
34
+ end
35
+ attron(color_pair(5) | Curses::A_BOLD) do
36
+ title = ' Bookmarks '.center(modal_width - 2)
37
+ addstr(title)
38
+ end
39
+ attron(color_pair(4) | Curses::A_BOLD) do
40
+ addstr('│')
41
+ end
42
+
43
+ setpos(modal_y + 2, modal_x)
44
+ attron(color_pair(4)) do
45
+ addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
46
+ end
47
+
48
+ visible_bookmarks = @bookmarks.to_a.first(modal_height - 7)
49
+ visible_bookmarks.each_with_index do |(name, path), idx|
50
+ setpos(modal_y + 3 + idx, modal_x)
51
+ attron(color_pair(4)) do
52
+ addstr('│')
53
+ end
54
+
55
+ attron(color_pair(1) | Curses::A_BOLD) do
56
+ addstr(" #{name}".ljust(20))
57
+ end
58
+
59
+ path_space = modal_width - 23
60
+ display_path = path.length > path_space ? "...#{path[-(path_space - 3)..]}" : path
61
+ addstr(display_path.ljust(path_space - 1))
62
+
63
+ attron(color_pair(4)) do
64
+ addstr('│')
65
+ end
66
+ end
67
+
68
+ (visible_bookmarks.length...(modal_height - 7)).each do |idx|
69
+ setpos(modal_y + 3 + idx, modal_x)
70
+ attron(color_pair(4)) do
71
+ addstr("\u2502#{' ' * (modal_width - 2)}\u2502")
72
+ end
73
+ end
74
+
75
+ input_line = modal_y + modal_height - 4
76
+ setpos(input_line, modal_x)
77
+ attron(color_pair(4)) do
78
+ addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
79
+ end
80
+
81
+ setpos(input_line + 1, modal_x)
82
+ attron(color_pair(4)) do
83
+ addstr('│')
84
+ end
85
+ prompt = ' Enter bookmark name: '
86
+ attron(color_pair(5)) do
87
+ addstr(prompt)
88
+ end
89
+ addstr(' ' * (modal_width - 2 - prompt.length))
90
+ attron(color_pair(4)) do
91
+ addstr('│')
92
+ end
93
+
94
+ setpos(input_line + 2, modal_x)
95
+ attron(color_pair(4)) do
96
+ addstr('│ ')
97
+ end
98
+
99
+ curs_set(1)
100
+ echo
101
+ setpos(input_line + 2, modal_x + 2)
102
+
103
+ input_width = modal_width - 5
104
+ bookmark_name = ''
105
+
106
+ loop do
107
+ ch = getch
108
+
109
+ case ch
110
+ when 10, 13
111
+ break
112
+ when 27
113
+ bookmark_name = ''
114
+ break
115
+ when 127, Curses::Key::BACKSPACE
116
+ if bookmark_name.length.positive?
117
+ bookmark_name = bookmark_name[0...-1]
118
+ setpos(input_line + 2, modal_x + 2)
119
+ addstr(bookmark_name.ljust(input_width))
120
+ setpos(input_line + 2, modal_x + 2 + bookmark_name.length)
121
+ end
122
+ else
123
+ if ch.is_a?(String) && bookmark_name.length < input_width
124
+ bookmark_name += ch
125
+ setpos(input_line + 2, modal_x + 2)
126
+ addstr(bookmark_name.ljust(input_width))
127
+ end
128
+ end
129
+
130
+ refresh
131
+ end
132
+
133
+ noecho
134
+ curs_set(0)
135
+
136
+ setpos(modal_y + modal_height - 1, modal_x)
137
+ attron(color_pair(4) | Curses::A_BOLD) do
138
+ addstr("\u2514#{'─' * (modal_width - 2)}\u2518")
139
+ end
140
+
141
+ refresh
142
+
143
+ bookmark_name = bookmark_name.strip
144
+
145
+ return if bookmark_name.empty?
146
+
147
+ if @bookmarks.key?(bookmark_name)
148
+ target_path = @bookmarks[bookmark_name]
149
+ if Dir.exist?(target_path)
150
+ @current_dir = target_path
151
+ @selected_index = 0
152
+ @scroll_offset = 0
153
+ else
154
+ show_error_modal("Bookmark path doesn't exist")
155
+ end
156
+ else
157
+ show_error_modal("Bookmark '#{bookmark_name}' not found")
158
+ end
159
+ end
160
+
161
+ def show_no_bookmarks_modal
162
+ max_y = lines
163
+ max_x = cols
164
+
165
+ modal_height = 10
166
+ modal_width = 60
167
+ modal_y = (max_y - modal_height) / 2
168
+ modal_x = (max_x - modal_width) / 2
169
+
170
+ (modal_y..(modal_y + modal_height)).each do |y|
171
+ setpos(y, modal_x)
172
+ attron(color_pair(3)) do
173
+ addstr(' ' * modal_width)
174
+ end
175
+ end
176
+
177
+ setpos(modal_y, modal_x)
178
+ attron(color_pair(4) | Curses::A_BOLD) do
179
+ addstr("\u250C#{'─' * (modal_width - 2)}\u2510")
180
+ end
181
+
182
+ setpos(modal_y + 1, modal_x)
183
+ attron(color_pair(4) | Curses::A_BOLD) do
184
+ addstr('│')
185
+ end
186
+ attron(color_pair(5) | Curses::A_BOLD) do
187
+ addstr(' No Bookmarks Defined '.center(modal_width - 2))
188
+ end
189
+ attron(color_pair(4) | Curses::A_BOLD) do
190
+ addstr('│')
191
+ end
192
+
193
+ setpos(modal_y + 2, modal_x)
194
+ attron(color_pair(4)) do
195
+ addstr("\u251C#{'─' * (modal_width - 2)}\u2524")
196
+ end
197
+
198
+ messages = [
199
+ 'Add bookmarks to ~/.sgtrc:',
200
+ '',
201
+ '[bookmarks]',
202
+ 'home=/home/user',
203
+ 'projects=~/projects'
204
+ ]
205
+
206
+ messages.each_with_index do |msg, idx|
207
+ setpos(modal_y + 3 + idx, modal_x)
208
+ attron(color_pair(4)) do
209
+ addstr('│ ')
210
+ end
211
+ if idx > 1
212
+ attron(color_pair(1)) do
213
+ addstr(msg.ljust(modal_width - 4))
214
+ end
215
+ else
216
+ addstr(msg.ljust(modal_width - 4))
217
+ end
218
+ attron(color_pair(4)) do
219
+ addstr(' │')
220
+ end
221
+ end
222
+
223
+ setpos(modal_y + 9, modal_x)
224
+ attron(color_pair(4)) do
225
+ addstr('│')
226
+ end
227
+ attron(color_pair(4) | Curses::A_DIM) do
228
+ addstr(' Press any key to continue '.center(modal_width - 2))
229
+ end
230
+ attron(color_pair(4)) do
231
+ addstr('│')
232
+ end
233
+
234
+ setpos(modal_y + modal_height - 1, modal_x)
235
+ attron(color_pair(4) | Curses::A_BOLD) do
236
+ addstr("\u2514#{'─' * (modal_width - 2)}\u2518")
237
+ end
238
+
239
+ refresh
240
+ getch
241
+ true
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Modal dialog windows - organized by context
4
+
5
+ require_relative 'modals/navigation'
6
+ require_relative 'modals/dialogs'
7
+ require_relative 'modals/file_operations'
8
+ require_relative 'modals/help'
9
+
10
+ module Sergeant
11
+ module Modals
12
+ include Navigation
13
+ include Dialogs
14
+ include FileOperations
15
+ include Help
16
+ end
17
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ # UI rendering methods
4
+
5
+ module Sergeant
6
+ module Rendering
7
+ def draw_screen
8
+ clear
9
+
10
+ max_y = lines - 1
11
+ max_x = cols
12
+
13
+ setpos(0, 0)
14
+ attron(color_pair(4) | Curses::A_BOLD) do
15
+ addstr("┌─ Sergeant - 'Leave it to the Sarge!' ".ljust(max_x, '─'))
16
+ end
17
+
18
+ setpos(1, 0)
19
+ branch = get_git_branch
20
+
21
+ # Build status info
22
+ status_parts = []
23
+ status_parts << "Marked: #{@marked_items.length}" unless @marked_items.empty?
24
+ unless @copied_items.empty?
25
+ mode_text = @cut_mode ? 'Cut' : 'Copied'
26
+ status_parts << "#{mode_text}: #{@copied_items.length}"
27
+ end
28
+ status_text = status_parts.empty? ? '' : " | #{status_parts.join(' | ')}"
29
+
30
+ if branch
31
+ branch_text = " [#{branch}]"
32
+ path_max_length = max_x - 4 - branch_text.length - status_text.length
33
+ path_display = @current_dir.length > path_max_length ? "...#{@current_dir[(-path_max_length + 3)..]}" : @current_dir
34
+
35
+ attron(color_pair(5)) do
36
+ addstr("│ #{path_display}")
37
+ end
38
+ attron(color_pair(6) | Curses::A_BOLD) do
39
+ addstr(branch_text)
40
+ end
41
+ unless status_text.empty?
42
+ attron(color_pair(1) | Curses::A_BOLD) do
43
+ addstr(status_text)
44
+ end
45
+ end
46
+ remaining = max_x - 2 - path_display.length - branch_text.length - status_text.length
47
+ else
48
+ path_max_length = max_x - 4 - status_text.length
49
+ path_display = @current_dir.length > path_max_length ? "...#{@current_dir[(-path_max_length + 3)..]}" : @current_dir
50
+
51
+ attron(color_pair(5)) do
52
+ addstr("│ #{path_display}")
53
+ end
54
+ unless status_text.empty?
55
+ attron(color_pair(1) | Curses::A_BOLD) do
56
+ addstr(status_text)
57
+ end
58
+ end
59
+ remaining = max_x - 2 - path_display.length - status_text.length
60
+ end
61
+ addstr(''.ljust(remaining)) if remaining.positive?
62
+
63
+ setpos(2, 0)
64
+ attron(color_pair(4)) do
65
+ addstr('├'.ljust(max_x, '─'))
66
+ end
67
+
68
+ setpos(max_y, 0)
69
+ attron(color_pair(4)) do
70
+ help = '↑↓/jk:Move Enter:Open ←h:Back Space:Mark c:Copy x:Cut p:Paste d:Del e:Edit m:Help q:Quit'
71
+ addstr("└─ #{help}".ljust(max_x, ' '))
72
+ end
73
+
74
+ visible_lines = max_y - 4
75
+
76
+ if @selected_index < @scroll_offset
77
+ @scroll_offset = @selected_index
78
+ elsif @selected_index >= @scroll_offset + visible_lines
79
+ @scroll_offset = @selected_index - visible_lines + 1
80
+ end
81
+
82
+ visible_items = @items[@scroll_offset, visible_lines] || []
83
+ visible_items.each_with_index do |item, idx|
84
+ line_num = idx + 3
85
+ actual_index = @scroll_offset + idx
86
+
87
+ setpos(line_num, 0)
88
+
89
+ is_selected = actual_index == @selected_index
90
+
91
+ if is_selected
92
+ attron(color_pair(3) | Curses::A_BOLD) do
93
+ draw_item(item, max_x, true)
94
+ end
95
+ elsif item[:type] == :directory
96
+ attron(color_pair(1)) do
97
+ draw_item(item, max_x, false)
98
+ end
99
+ else
100
+ attron(color_pair(2) | Curses::A_DIM) do
101
+ draw_item(item, max_x, false)
102
+ end
103
+ end
104
+ end
105
+
106
+ if @items.length > visible_lines
107
+ total = @items.length
108
+ visible = visible_lines
109
+ scroll_pos = (@scroll_offset.to_f / (total - visible)) * (visible - 1)
110
+ scroll_pos = scroll_pos.round.clamp(0, visible - 1)
111
+
112
+ setpos(3 + scroll_pos, max_x - 1)
113
+ attron(color_pair(4) | Curses::A_BOLD) do
114
+ addstr('█')
115
+ end
116
+ end
117
+
118
+ refresh
119
+ end
120
+
121
+ def draw_item(item, max_x, is_selected)
122
+ icon = item[:type] == :directory ? '📁 ' : '📄 '
123
+
124
+ # Check if item is marked
125
+ is_marked = @marked_items.include?(item[:path])
126
+ mark_indicator = is_marked ? '✓ ' : ' '
127
+
128
+ prefix = is_selected ? '▶ ' : ' '
129
+
130
+ size_str = format_size(item[:size])
131
+ date_str = format_date(item[:mtime])
132
+
133
+ if @show_ownership && item[:owner] && item[:perms]
134
+ perms_str = item[:perms]
135
+ owner_str = item[:owner].ljust(16)
136
+ metadata_space = perms_str.length + owner_str.length + size_str.length + date_str.length + 8
137
+ else
138
+ perms_str = ''
139
+ owner_str = ''
140
+ metadata_space = size_str.length + date_str.length + 4
141
+ end
142
+
143
+ available = max_x - prefix.length - mark_indicator.length - icon.length - metadata_space - 1
144
+
145
+ name = if item[:name].length > available
146
+ "#{item[:name][0...(available - 3)]}..."
147
+ else
148
+ item[:name].ljust(available)
149
+ end
150
+
151
+ display = if @show_ownership && item[:owner] && item[:perms]
152
+ "#{prefix}#{mark_indicator}#{icon}#{name} #{perms_str} #{owner_str} #{size_str} #{date_str}".ljust(max_x)
153
+ else
154
+ "#{prefix}#{mark_indicator}#{icon}#{name} #{size_str} #{date_str}".ljust(max_x)
155
+ end
156
+
157
+ addstr(display)
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Utility functions for formatting and file operations
4
+
5
+ module Sergeant
6
+ module Utils
7
+ def get_git_branch
8
+ return nil unless Dir.exist?(File.join(@current_dir, '.git'))
9
+
10
+ head_file = File.join(@current_dir, '.git', 'HEAD')
11
+ return nil unless File.exist?(head_file)
12
+
13
+ head_content = File.read(head_file).strip
14
+ if head_content.start_with?('ref: refs/heads/')
15
+ head_content.sub('ref: refs/heads/', '')
16
+ else
17
+ head_content[0..7]
18
+ end
19
+ rescue StandardError
20
+ nil
21
+ end
22
+
23
+ def get_owner_info(stat)
24
+ begin
25
+ user = Etc.getpwuid(stat.uid).name
26
+ rescue StandardError
27
+ user = stat.uid.to_s
28
+ end
29
+
30
+ begin
31
+ group = Etc.getgrgid(stat.gid).name
32
+ rescue StandardError
33
+ group = stat.gid.to_s
34
+ end
35
+
36
+ "#{user}:#{group}"
37
+ end
38
+
39
+ def format_permissions(mode, is_directory)
40
+ type = is_directory ? 'd' : '-'
41
+
42
+ owner = ''
43
+ owner += mode.anybits?(0o400) ? 'r' : '-'
44
+ owner += mode.anybits?(0o200) ? 'w' : '-'
45
+ owner += mode.anybits?(0o100) ? 'x' : '-'
46
+
47
+ group = ''
48
+ group += mode.anybits?(0o040) ? 'r' : '-'
49
+ group += mode.anybits?(0o020) ? 'w' : '-'
50
+ group += mode.anybits?(0o010) ? 'x' : '-'
51
+
52
+ other = ''
53
+ other += mode.anybits?(0o004) ? 'r' : '-'
54
+ other += mode.anybits?(0o002) ? 'w' : '-'
55
+ other += mode.anybits?(0o001) ? 'x' : '-'
56
+
57
+ "#{type}#{owner}#{group}#{other}"
58
+ end
59
+
60
+ def format_size(bytes)
61
+ return ' ' if bytes.nil?
62
+
63
+ if bytes < 1024
64
+ "#{bytes}B".rjust(7)
65
+ elsif bytes < 1024 * 1024
66
+ "#{(bytes / 1024.0).round(1)}K".rjust(7)
67
+ elsif bytes < 1024 * 1024 * 1024
68
+ "#{(bytes / (1024.0 * 1024)).round(1)}M".rjust(7)
69
+ else
70
+ "#{(bytes / (1024.0 * 1024 * 1024)).round(1)}G".rjust(7)
71
+ end
72
+ end
73
+
74
+ def format_date(time)
75
+ return '' if time.nil?
76
+
77
+ time.strftime('%b %d %H:%M')
78
+ end
79
+
80
+ def fzf_available?
81
+ system('command -v fzf > /dev/null 2>&1')
82
+ end
83
+
84
+ def glow_available?
85
+ system('command -v glow > /dev/null 2>&1')
86
+ end
87
+
88
+ def nvim_available?
89
+ system('command -v nvim > /dev/null 2>&1')
90
+ end
91
+
92
+ def vim_available?
93
+ system('command -v vim > /dev/null 2>&1')
94
+ end
95
+
96
+ def vi_available?
97
+ system('command -v vi > /dev/null 2>&1')
98
+ end
99
+
100
+ def nano_available?
101
+ system('command -v nano > /dev/null 2>&1')
102
+ end
103
+
104
+ def text_file?(file_path)
105
+ return false unless File.file?(file_path)
106
+ return false unless File.readable?(file_path)
107
+
108
+ # Check file size - limit to 50MB for safety
109
+ return false if File.size(file_path) > 50 * 1024 * 1024
110
+
111
+ # Check if it's a binary file by reading first 8KB
112
+ File.open(file_path, 'rb') do |f|
113
+ sample = f.read(8192) || ''
114
+ # Check for null bytes (common in binary files)
115
+ !sample.bytes.include?(0)
116
+ end
117
+ rescue StandardError
118
+ false
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sergeant
4
+ VERSION = '1.0.0'
5
+ end