beniya 0.6.0 → 0.6.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e920583f6645634b10889369089e81e114db44a01be5b0baa177dc8279ca1bc
4
- data.tar.gz: 7c3ec6afa823e45700e26fa02c50076cbdc268d136d11403599a6befc858aebf
3
+ metadata.gz: c82332a61b93f69a73371f2f9d4aa4488d04fc45b12f5db958bd0597476a8550
4
+ data.tar.gz: 5f689d6a5fb75451d3f8b75ff9f09343f342d80e451b19de39e0a024fdf688dc
5
5
  SHA512:
6
- metadata.gz: bd20c3f4392c8d65aec9bc2e3261701d5f091e6baa73012e779f7417e0d2ea1bc8fe10ea4cac99117b70f82a70b68de0638ec05b390e97436c67691ac11bef90
7
- data.tar.gz: b28475770533a0d8989eab47f72bf63a83e15546fa4e11a43027e45885c07b491498c0bd83296fe9e67a4568c3c1774f96b8b6ff9bd98d1ea6f2b9d185d4ea30
6
+ metadata.gz: f8e95d8c526f42619d798107a447b103a592005699a06392c61bf05919df0fe090c2066dc427aa6e047d35ec8d19285f11cd6696bf9ff4aa96d810b9bb465a82
7
+ data.tar.gz: 6baebd7f5d4c288589d0f1a88b7ed871f0d344529787cc42356ed741c51ca47aa8758c7f25dd4dc9e31ac3804180ea1e23ae14e80642ffb7ee972e7622aed32e
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Beniya
4
4
  class Application
5
+ # Error display constants
6
+ BACKTRACE_LINES = 5 # Number of backtrace lines to show
7
+
5
8
  def initialize(start_directory = Dir.pwd)
6
9
  @start_directory = File.expand_path(start_directory)
7
10
  # Load configuration including language settings
@@ -22,7 +25,7 @@ module Beniya
22
25
  puts "\n\n#{ConfigLoader.message('app.interrupted')}"
23
26
  rescue StandardError => e
24
27
  puts "\n#{ConfigLoader.message('app.error_occurred')}: #{e.message}"
25
- puts e.backtrace.first(5).join("\n")
28
+ puts e.backtrace.first(BACKTRACE_LINES).join("\n")
26
29
  end
27
30
  end
28
31
  end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'bookmark'
4
+ require_relative 'config_loader'
5
+
6
+ module Beniya
7
+ # Manages bookmark operations with interactive UI
8
+ class BookmarkManager
9
+ def initialize(bookmark = nil, dialog_renderer = nil)
10
+ @bookmark = bookmark || Bookmark.new
11
+ @dialog_renderer = dialog_renderer
12
+ end
13
+
14
+ # Show bookmark menu and handle user selection
15
+ # @param current_path [String] Current directory path
16
+ # @return [Symbol, nil] Action to perform (:navigate, :add, :list, :remove, :cancel)
17
+ def show_menu(current_path)
18
+ return :cancel unless @dialog_renderer
19
+
20
+ title = 'Bookmark Menu'
21
+ content_lines = [
22
+ '',
23
+ '[A]dd current directory to bookmarks',
24
+ '[L]ist bookmarks',
25
+ '[R]emove bookmark',
26
+ '',
27
+ 'Press 1-9 to go to bookmark directly',
28
+ '',
29
+ 'Press any other key to cancel'
30
+ ]
31
+
32
+ dialog_width = 45
33
+ dialog_height = 4 + content_lines.length
34
+ x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
35
+
36
+ @dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
37
+ border_color: "\e[34m", # Blue
38
+ title_color: "\e[1;34m", # Bold blue
39
+ content_color: "\e[37m" # White
40
+ })
41
+
42
+ # Wait for key input
43
+ loop do
44
+ input = STDIN.getch.downcase
45
+
46
+ case input
47
+ when 'a'
48
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
49
+ return { action: :add, path: current_path }
50
+ when 'l'
51
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
52
+ return { action: :list }
53
+ when 'r'
54
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
55
+ return { action: :remove }
56
+ when '1', '2', '3', '4', '5', '6', '7', '8', '9'
57
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
58
+ return { action: :navigate, number: input.to_i }
59
+ else
60
+ # Cancel
61
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
62
+ return { action: :cancel }
63
+ end
64
+ end
65
+ end
66
+
67
+ # Add a bookmark interactively
68
+ # @param path [String] Path to bookmark
69
+ # @return [Boolean] Success status
70
+ def add_interactive(path)
71
+ print ConfigLoader.message('bookmark.input_name') || 'Enter bookmark name: '
72
+ name = STDIN.gets.chomp
73
+ return false if name.empty?
74
+
75
+ if @bookmark.add(path, name)
76
+ puts "\n#{ConfigLoader.message('bookmark.added') || 'Bookmark added'}: #{name}"
77
+ true
78
+ else
79
+ puts "\n#{ConfigLoader.message('bookmark.add_failed') || 'Failed to add bookmark'}"
80
+ false
81
+ end
82
+ end
83
+
84
+ # Remove a bookmark interactively
85
+ # @return [Boolean] Success status
86
+ def remove_interactive
87
+ bookmarks = @bookmark.list
88
+
89
+ if bookmarks.empty?
90
+ puts "\n#{ConfigLoader.message('bookmark.no_bookmarks') || 'No bookmarks found'}"
91
+ return false
92
+ end
93
+
94
+ puts "\nBookmarks:"
95
+ bookmarks.each_with_index do |bookmark, index|
96
+ puts " #{index + 1}. #{bookmark[:name]} (#{bookmark[:path]})"
97
+ end
98
+
99
+ print ConfigLoader.message('bookmark.input_number') || 'Enter number to remove: '
100
+ input = STDIN.gets.chomp
101
+ number = input.to_i
102
+
103
+ if number > 0 && number <= bookmarks.length
104
+ bookmark_to_remove = bookmarks[number - 1]
105
+ if @bookmark.remove(bookmark_to_remove[:name])
106
+ puts "\n#{ConfigLoader.message('bookmark.removed') || 'Bookmark removed'}: #{bookmark_to_remove[:name]}"
107
+ true
108
+ else
109
+ puts "\n#{ConfigLoader.message('bookmark.remove_failed') || 'Failed to remove bookmark'}"
110
+ false
111
+ end
112
+ else
113
+ puts "\n#{ConfigLoader.message('bookmark.invalid_number') || 'Invalid number'}"
114
+ false
115
+ end
116
+ end
117
+
118
+ # List all bookmarks interactively
119
+ # @return [Boolean] Success status
120
+ def list_interactive
121
+ bookmarks = @bookmark.list
122
+
123
+ if bookmarks.empty?
124
+ puts "\n#{ConfigLoader.message('bookmark.no_bookmarks') || 'No bookmarks found'}"
125
+ return false
126
+ end
127
+
128
+ puts "\nBookmarks:"
129
+ bookmarks.each_with_index do |bookmark, index|
130
+ puts " #{index + 1}. #{bookmark[:name]} (#{bookmark[:path]})"
131
+ end
132
+
133
+ true
134
+ end
135
+
136
+ # Get bookmark by number
137
+ # @param number [Integer] Bookmark number (1-9)
138
+ # @return [Hash, nil] Bookmark hash with :path and :name
139
+ def find_by_number(number)
140
+ @bookmark.find_by_number(number)
141
+ end
142
+
143
+ # Validate bookmark path exists
144
+ # @param bookmark [Hash] Bookmark hash
145
+ # @return [Boolean]
146
+ def path_exists?(bookmark)
147
+ return false unless bookmark
148
+
149
+ Dir.exist?(bookmark[:path])
150
+ end
151
+
152
+ # Get all bookmarks
153
+ # @return [Array<Hash>]
154
+ def list
155
+ @bookmark.list
156
+ end
157
+
158
+ # Add bookmark
159
+ # @param path [String] Path to bookmark
160
+ # @param name [String] Bookmark name
161
+ # @return [Boolean] Success status
162
+ def add(path, name)
163
+ @bookmark.add(path, name)
164
+ end
165
+
166
+ # Remove bookmark
167
+ # @param name [String] Bookmark name
168
+ # @return [Boolean] Success status
169
+ def remove(name)
170
+ @bookmark.remove(name)
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'text_utils'
4
+
5
+ module Beniya
6
+ # Renders floating dialog windows in the terminal
7
+ class DialogRenderer
8
+ include TextUtils
9
+
10
+ # Draw a floating window with title, content, and customizable colors
11
+ # @param x [Integer] X position (column)
12
+ # @param y [Integer] Y position (row)
13
+ # @param width [Integer] Window width
14
+ # @param height [Integer] Window height
15
+ # @param title [String, nil] Window title (optional)
16
+ # @param content_lines [Array<String>] Content lines to display
17
+ # @param options [Hash] Customization options
18
+ # @option options [String] :border_color Border color ANSI code
19
+ # @option options [String] :title_color Title color ANSI code
20
+ # @option options [String] :content_color Content color ANSI code
21
+ def draw_floating_window(x, y, width, height, title, content_lines, options = {})
22
+ # Default options
23
+ border_color = options[:border_color] || "\e[37m" # White
24
+ title_color = options[:title_color] || "\e[1;33m" # Bold yellow
25
+ content_color = options[:content_color] || "\e[37m" # White
26
+ reset_color = "\e[0m"
27
+
28
+ # Draw top border
29
+ print "\e[#{y};#{x}H#{border_color}┌#{'─' * (width - 2)}┐#{reset_color}"
30
+
31
+ # Draw title line if title exists
32
+ if title
33
+ title_width = TextUtils.display_width(title)
34
+ title_padding = (width - 2 - title_width) / 2
35
+ padded_title = ' ' * title_padding + title
36
+ title_line = TextUtils.pad_string_to_width(padded_title, width - 2)
37
+ print "\e[#{y + 1};#{x}H#{border_color}│#{title_color}#{title_line}#{border_color}│#{reset_color}"
38
+
39
+ # Draw title separator
40
+ print "\e[#{y + 2};#{x}H#{border_color}├#{'─' * (width - 2)}┤#{reset_color}"
41
+ content_start_y = y + 3
42
+ else
43
+ content_start_y = y + 1
44
+ end
45
+
46
+ # Draw content lines
47
+ content_height = title ? height - 4 : height - 2
48
+ content_lines.each_with_index do |line, index|
49
+ break if index >= content_height
50
+
51
+ line_y = content_start_y + index
52
+ line_content = TextUtils.pad_string_to_width(line, width - 2)
53
+ print "\e[#{line_y};#{x}H#{border_color}│#{content_color}#{line_content}#{border_color}│#{reset_color}"
54
+ end
55
+
56
+ # Fill remaining lines with empty space
57
+ remaining_lines = content_height - content_lines.length
58
+ remaining_lines.times do |i|
59
+ line_y = content_start_y + content_lines.length + i
60
+ empty_line = ' ' * (width - 2)
61
+ print "\e[#{line_y};#{x}H#{border_color}│#{empty_line}│#{reset_color}"
62
+ end
63
+
64
+ # Draw bottom border
65
+ bottom_y = y + height - 1
66
+ print "\e[#{bottom_y};#{x}H#{border_color}└#{'─' * (width - 2)}┘#{reset_color}"
67
+ end
68
+
69
+ # Calculate center position for a window
70
+ # @param content_width [Integer] Window width
71
+ # @param content_height [Integer] Window height
72
+ # @return [Array<Integer>] [x, y] position
73
+ def calculate_center(content_width, content_height)
74
+ # Get terminal size
75
+ console = IO.console
76
+ if console
77
+ screen_width, screen_height = console.winsize.reverse
78
+ else
79
+ screen_width = 80
80
+ screen_height = 24
81
+ end
82
+
83
+ # Calculate center position
84
+ x = [(screen_width - content_width) / 2, 1].max
85
+ y = [(screen_height - content_height) / 2, 1].max
86
+
87
+ [x, y]
88
+ end
89
+
90
+ # Clear a rectangular area on the screen
91
+ # @param x [Integer] X position
92
+ # @param y [Integer] Y position
93
+ # @param width [Integer] Area width
94
+ # @param height [Integer] Area height
95
+ def clear_area(x, y, width, height)
96
+ height.times do |row|
97
+ print "\e[#{y + row};#{x}H#{' ' * width}"
98
+ end
99
+ end
100
+
101
+ # Calculate appropriate dimensions for content
102
+ # @param content_lines [Array<String>] Content lines
103
+ # @param options [Hash] Options
104
+ # @option options [String, nil] :title Window title
105
+ # @option options [Integer] :min_width Minimum width (default: 30)
106
+ # @option options [Integer] :max_width Maximum width (default: 80)
107
+ # @return [Array<Integer>] [width, height]
108
+ def calculate_dimensions(content_lines, options = {})
109
+ title = options[:title]
110
+ min_width = options[:min_width] || 30
111
+ max_width = options[:max_width] || 80
112
+
113
+ # Calculate required width based on content
114
+ max_content_width = content_lines.map { |line| TextUtils.display_width(line) }.max || 0
115
+ title_width = title ? TextUtils.display_width(title) : 0
116
+ required_width = [max_content_width, title_width].max + 4 # +4 for borders and padding
117
+
118
+ width = [[required_width, min_width].max, max_width].min
119
+
120
+ # Calculate height: borders + title (if exists) + separator + content
121
+ height = content_lines.length + 2 # +2 for top and bottom borders
122
+ height += 2 if title # +2 for title line and separator
123
+
124
+ [width, height]
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Beniya
6
+ # Handles file operations (move, copy, delete)
7
+ class FileOperations
8
+ # Operation result structure
9
+ # @return [Hash] { success: Boolean, message: String, count: Integer }
10
+ OperationResult = Struct.new(:success, :message, :count, :errors, keyword_init: true)
11
+
12
+ # Move files/directories to destination
13
+ # @param items [Array<String>] Item names to move
14
+ # @param source_directory [String] Source directory path
15
+ # @param destination [String] Destination directory path
16
+ # @return [OperationResult]
17
+ def move(items, source_directory, destination)
18
+ perform_operation(:move, items, source_directory, destination)
19
+ end
20
+
21
+ # Copy files/directories to destination
22
+ # @param items [Array<String>] Item names to copy
23
+ # @param source_directory [String] Source directory path
24
+ # @param destination [String] Destination directory path
25
+ # @return [OperationResult]
26
+ def copy(items, source_directory, destination)
27
+ perform_operation(:copy, items, source_directory, destination)
28
+ end
29
+
30
+ # Delete files/directories
31
+ # @param items [Array<String>] Item names to delete
32
+ # @param source_directory [String] Source directory path
33
+ # @return [OperationResult]
34
+ def delete(items, source_directory)
35
+ success_count = 0
36
+ error_messages = []
37
+
38
+ items.each do |item_name|
39
+ item_path = File.join(source_directory, item_name)
40
+
41
+ begin
42
+ # Check if file/directory exists
43
+ unless File.exist?(item_path)
44
+ error_messages << "#{item_name}: File not found"
45
+ next
46
+ end
47
+
48
+ is_directory = File.directory?(item_path)
49
+
50
+ if is_directory
51
+ FileUtils.rm_rf(item_path)
52
+ else
53
+ FileUtils.rm(item_path)
54
+ end
55
+
56
+ # Verify deletion
57
+ sleep(0.01) # Wait for file system sync
58
+ if File.exist?(item_path)
59
+ error_messages << "#{item_name}: Deletion failed"
60
+ else
61
+ success_count += 1
62
+ end
63
+ rescue StandardError => e
64
+ error_messages << "#{item_name}: #{e.message}"
65
+ end
66
+ end
67
+
68
+ OperationResult.new(
69
+ success: error_messages.empty?,
70
+ message: build_delete_message(success_count, items.length),
71
+ count: success_count,
72
+ errors: error_messages
73
+ )
74
+ end
75
+
76
+ # Create a new file
77
+ # @param directory [String] Directory path
78
+ # @param filename [String] File name
79
+ # @return [OperationResult]
80
+ def create_file(directory, filename)
81
+ # Validate filename
82
+ if filename.include?('/') || filename.include?('\\')
83
+ return OperationResult.new(
84
+ success: false,
85
+ message: 'Invalid filename: cannot contain path separators',
86
+ count: 0,
87
+ errors: []
88
+ )
89
+ end
90
+
91
+ file_path = File.join(directory, filename)
92
+
93
+ # Check if file already exists
94
+ if File.exist?(file_path)
95
+ return OperationResult.new(
96
+ success: false,
97
+ message: 'File already exists',
98
+ count: 0,
99
+ errors: []
100
+ )
101
+ end
102
+
103
+ begin
104
+ File.write(file_path, '')
105
+ OperationResult.new(
106
+ success: true,
107
+ message: "File created: #{filename}",
108
+ count: 1,
109
+ errors: []
110
+ )
111
+ rescue StandardError => e
112
+ OperationResult.new(
113
+ success: false,
114
+ message: "Creation error: #{e.message}",
115
+ count: 0,
116
+ errors: [e.message]
117
+ )
118
+ end
119
+ end
120
+
121
+ # Create a new directory
122
+ # @param parent_directory [String] Parent directory path
123
+ # @param dirname [String] Directory name
124
+ # @return [OperationResult]
125
+ def create_directory(parent_directory, dirname)
126
+ # Validate dirname
127
+ if dirname.include?('/') || dirname.include?('\\')
128
+ return OperationResult.new(
129
+ success: false,
130
+ message: 'Invalid directory name: cannot contain path separators',
131
+ count: 0,
132
+ errors: []
133
+ )
134
+ end
135
+
136
+ dir_path = File.join(parent_directory, dirname)
137
+
138
+ # Check if directory already exists
139
+ if File.exist?(dir_path)
140
+ return OperationResult.new(
141
+ success: false,
142
+ message: 'Directory already exists',
143
+ count: 0,
144
+ errors: []
145
+ )
146
+ end
147
+
148
+ begin
149
+ Dir.mkdir(dir_path)
150
+ OperationResult.new(
151
+ success: true,
152
+ message: "Directory created: #{dirname}",
153
+ count: 1,
154
+ errors: []
155
+ )
156
+ rescue StandardError => e
157
+ OperationResult.new(
158
+ success: false,
159
+ message: "Creation error: #{e.message}",
160
+ count: 0,
161
+ errors: [e.message]
162
+ )
163
+ end
164
+ end
165
+
166
+ private
167
+
168
+ # Perform move or copy operation
169
+ # @param operation [Symbol] :move or :copy
170
+ # @param items [Array<String>] Item names
171
+ # @param source_directory [String] Source directory path
172
+ # @param destination [String] Destination directory path
173
+ # @return [OperationResult]
174
+ def perform_operation(operation, items, source_directory, destination)
175
+ success_count = 0
176
+ error_messages = []
177
+
178
+ items.each do |item_name|
179
+ source_path = File.join(source_directory, item_name)
180
+ dest_path = File.join(destination, item_name)
181
+
182
+ begin
183
+ if File.exist?(dest_path)
184
+ error_messages << "#{item_name}: Already exists in destination"
185
+ next
186
+ end
187
+
188
+ case operation
189
+ when :move
190
+ FileUtils.mv(source_path, dest_path)
191
+ when :copy
192
+ if File.directory?(source_path)
193
+ FileUtils.cp_r(source_path, dest_path)
194
+ else
195
+ FileUtils.cp(source_path, dest_path)
196
+ end
197
+ end
198
+
199
+ success_count += 1
200
+ rescue StandardError => e
201
+ operation_name = operation == :move ? 'move' : 'copy'
202
+ error_messages << "#{item_name}: Failed to #{operation_name} (#{e.message})"
203
+ end
204
+ end
205
+
206
+ operation_name = operation == :move ? 'Moved' : 'Copied'
207
+ message = "#{operation_name} #{success_count} item(s)"
208
+ message += " (#{error_messages.length} failed)" unless error_messages.empty?
209
+
210
+ OperationResult.new(
211
+ success: error_messages.empty?,
212
+ message: message,
213
+ count: success_count,
214
+ errors: error_messages
215
+ )
216
+ end
217
+
218
+ # Build delete operation message
219
+ # @param success_count [Integer] Number of successfully deleted items
220
+ # @param total_count [Integer] Total number of items
221
+ # @return [String]
222
+ def build_delete_message(success_count, total_count)
223
+ if success_count == total_count
224
+ "Deleted #{success_count} item(s)"
225
+ else
226
+ failed_count = total_count - success_count
227
+ "#{success_count} deleted, #{failed_count} failed"
228
+ end
229
+ end
230
+ end
231
+ end
@@ -6,6 +6,13 @@ module Beniya
6
6
  DEFAULT_MAX_LINES = 50
7
7
  MAX_LINE_LENGTH = 500
8
8
 
9
+ # Binary detection constants
10
+ BINARY_SAMPLE_SIZE = 512
11
+ PRINTABLE_CHAR_THRESHOLD = 32
12
+ CONTROL_CHAR_TAB = 9
13
+ CONTROL_CHAR_NEWLINE = 10
14
+ CONTROL_CHAR_CARRIAGE_RETURN = 13
15
+
9
16
  def initialize
10
17
  # future: hold syntax highlight settings etc.
11
18
  end
@@ -19,7 +26,7 @@ module Beniya
19
26
 
20
27
  begin
21
28
  # binary file detection
22
- sample = File.binread(file_path, [file_size, 512].min)
29
+ sample = File.binread(file_path, [file_size, BINARY_SAMPLE_SIZE].min)
23
30
  return binary_response(file_path) if binary_file?(sample)
24
31
 
25
32
  # process as text file
@@ -44,8 +51,9 @@ module Beniya
44
51
 
45
52
  def binary_file?(sample)
46
53
  return false if sample.empty?
47
-
48
- binary_chars = sample.bytes.count { |byte| byte < 32 && ![9, 10, 13].include?(byte) }
54
+
55
+ allowed_control_chars = [CONTROL_CHAR_TAB, CONTROL_CHAR_NEWLINE, CONTROL_CHAR_CARRIAGE_RETURN]
56
+ binary_chars = sample.bytes.count { |byte| byte < PRINTABLE_CHAR_THRESHOLD && !allowed_control_chars.include?(byte) }
49
57
  (binary_chars.to_f / sample.bytes.length) > BINARY_THRESHOLD
50
58
  end
51
59