beniya 0.6.1 → 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: eabacf1e9c20898106af324c8275d857b209150badec6518d211a1820a78778f
4
- data.tar.gz: 4e43ee255191fdad616db68a320d77f326e7fb925932145cdf6e528a4f6f67ef
3
+ metadata.gz: c82332a61b93f69a73371f2f9d4aa4488d04fc45b12f5db958bd0597476a8550
4
+ data.tar.gz: 5f689d6a5fb75451d3f8b75ff9f09343f342d80e451b19de39e0a024fdf688dc
5
5
  SHA512:
6
- metadata.gz: 19f7fba9a329ca1dae8b3fa1d63898af7e0f16030bbf60cb8e8b10d5af02d4e2bbba783fef30df30e2c212fef06bb07628b9122139d0c50a093752f4a2bdb0a0
7
- data.tar.gz: d3a21a8322852750a2410145ffbd63d4c0ed72c7ae5485b6763f68d192872f05a4b7ddea62900ca910c21e04252347bcbcd679b2c1b5fbbe7721734fea8db2d8
6
+ metadata.gz: f8e95d8c526f42619d798107a447b103a592005699a06392c61bf05919df0fe090c2066dc427aa6e047d35ec8d19285f11cd6696bf9ff4aa96d810b9bb465a82
7
+ data.tar.gz: 6baebd7f5d4c288589d0f1a88b7ed871f0d344529787cc42356ed741c51ca47aa8758c7f25dd4dc9e31ac3804180ea1e23ae14e80642ffb7ee972e7622aed32e
@@ -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
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Beniya
4
+ # Manages filtering of directory entries
5
+ class FilterManager
6
+ attr_reader :filter_query, :filter_mode
7
+
8
+ def initialize
9
+ @filter_mode = false
10
+ @filter_query = ''
11
+ @original_entries = []
12
+ @filtered_entries = []
13
+ end
14
+
15
+ # Start filter mode with the given entries
16
+ # @param entries [Array<Hash>] Directory entries to filter
17
+ def start_filter_mode(entries)
18
+ @filter_mode = true
19
+ @filter_query = ''
20
+ @original_entries = entries.dup
21
+ @filtered_entries = @original_entries.dup
22
+ true
23
+ end
24
+
25
+ # Handle filter input character
26
+ # @param key [String] Input key
27
+ # @return [Symbol] :exit_clear, :exit_keep, :continue, or :backspace_exit
28
+ def handle_filter_input(key)
29
+ case key
30
+ when "\e" # ESC - clear filter and exit
31
+ :exit_clear
32
+ when "\r", "\n" # Enter - keep filter and exit
33
+ :exit_keep
34
+ when "\u007f", "\b" # Backspace
35
+ if @filter_query.length > 0
36
+ @filter_query = @filter_query[0...-1]
37
+ apply_filter
38
+ :continue
39
+ else
40
+ :backspace_exit
41
+ end
42
+ else
43
+ # Printable characters (alphanumeric, symbols, Japanese, etc.)
44
+ if key.length == 1 && key.ord >= 32 && key.ord < 127 # ASCII printable
45
+ @filter_query += key
46
+ apply_filter
47
+ :continue
48
+ elsif key.bytesize > 1 # Multi-byte characters (Japanese, etc.)
49
+ @filter_query += key
50
+ apply_filter
51
+ :continue
52
+ else
53
+ # Ignore other keys (Ctrl+c, etc.)
54
+ :continue
55
+ end
56
+ end
57
+ end
58
+
59
+ # Apply filter to entries
60
+ # @return [Array<Hash>] Filtered entries
61
+ def apply_filter
62
+ if @filter_query.empty?
63
+ @filtered_entries = @original_entries.dup
64
+ else
65
+ query_downcase = @filter_query.downcase
66
+ @filtered_entries = @original_entries.select do |entry|
67
+ entry[:name].downcase.include?(query_downcase)
68
+ end
69
+ end
70
+ @filtered_entries
71
+ end
72
+
73
+ # Clear filter mode
74
+ def clear_filter
75
+ @filter_mode = false
76
+ @filter_query = ''
77
+ @filtered_entries = []
78
+ @original_entries = []
79
+ end
80
+
81
+ # Exit filter mode while keeping the filter
82
+ def exit_filter_mode_keep_filter
83
+ @filter_mode = false
84
+ # Keep @filter_query and @filtered_entries
85
+ end
86
+
87
+ # Check if filter is active
88
+ # @return [Boolean]
89
+ def filter_active?
90
+ @filter_mode || !@filter_query.empty?
91
+ end
92
+
93
+ # Get filtered entries
94
+ # @return [Array<Hash>]
95
+ def filtered_entries
96
+ @filtered_entries
97
+ end
98
+
99
+ # Update original entries (e.g., after directory refresh)
100
+ # @param entries [Array<Hash>] New entries
101
+ def update_entries(entries)
102
+ @original_entries = entries.dup
103
+ apply_filter if filter_active?
104
+ end
105
+
106
+ # Restart filter mode with existing query
107
+ # @param entries [Array<Hash>] Directory entries
108
+ def restart_filter_mode(entries)
109
+ @filter_mode = true
110
+ @original_entries = entries.dup if @original_entries.empty?
111
+ apply_filter
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Beniya
4
+ # Unified logger for debug and error messages
5
+ # Only logs when BENIYA_DEBUG environment variable is set to '1'
6
+ class Logger
7
+ LOG_FILE = File.join(Dir.home, '.beniya_debug.log')
8
+
9
+ # Log levels
10
+ DEBUG = :debug
11
+ INFO = :info
12
+ WARN = :warn
13
+ ERROR = :error
14
+
15
+ class << self
16
+ # Log a debug message with optional context
17
+ # @param message [String] The log message
18
+ # @param context [Hash] Additional context information
19
+ def debug(message, context: {})
20
+ return unless debug_enabled?
21
+
22
+ write_log(DEBUG, message, context)
23
+ end
24
+
25
+ # Log an info message
26
+ # @param message [String] The log message
27
+ # @param context [Hash] Additional context information
28
+ def info(message, context: {})
29
+ return unless debug_enabled?
30
+
31
+ write_log(INFO, message, context)
32
+ end
33
+
34
+ # Log a warning message
35
+ # @param message [String] The log message
36
+ # @param context [Hash] Additional context information
37
+ def warn(message, context: {})
38
+ return unless debug_enabled?
39
+
40
+ write_log(WARN, message, context)
41
+ end
42
+
43
+ # Log an error message with optional exception
44
+ # @param message [String] The error message
45
+ # @param exception [Exception, nil] Optional exception object
46
+ # @param context [Hash] Additional context information
47
+ def error(message, exception: nil, context: {})
48
+ return unless debug_enabled?
49
+
50
+ full_context = context.dup
51
+ if exception
52
+ full_context[:exception] = exception.message
53
+ full_context[:backtrace] = exception.backtrace&.first(5)
54
+ end
55
+
56
+ write_log(ERROR, message, full_context)
57
+ end
58
+
59
+ # Clear the log file
60
+ def clear_log
61
+ return unless debug_enabled?
62
+
63
+ File.open(LOG_FILE, 'w') { |f| f.puts "=== Beniya Debug Log Cleared at #{Time.now} ===" }
64
+ end
65
+
66
+ private
67
+
68
+ # Check if debug logging is enabled
69
+ # @return [Boolean]
70
+ def debug_enabled?
71
+ ENV['BENIYA_DEBUG'] == '1'
72
+ end
73
+
74
+ # Write a log entry to the log file
75
+ # @param level [Symbol] Log level
76
+ # @param message [String] Log message
77
+ # @param context [Hash] Context information
78
+ def write_log(level, message, context)
79
+ File.open(LOG_FILE, 'a') do |f|
80
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
81
+ f.puts "[#{timestamp}] [#{level.to_s.upcase}] #{message}"
82
+
83
+ unless context.empty?
84
+ f.puts ' Context:'
85
+ context.each do |key, value|
86
+ if value.is_a?(Array) && value.length > 10
87
+ f.puts " #{key}: [#{value.length} items]"
88
+ else
89
+ f.puts " #{key}: #{value.inspect}"
90
+ end
91
+ end
92
+ end
93
+
94
+ f.puts ''
95
+ end
96
+ rescue StandardError => e
97
+ # Silently fail if we can't write to log file
98
+ # Don't want logging to break the application
99
+ warn "Failed to write to log file: #{e.message}"
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Beniya
4
+ # Manages selected items (files/directories) for bulk operations
5
+ class SelectionManager
6
+ def initialize
7
+ @selected_items = []
8
+ end
9
+
10
+ # Toggle selection for an entry
11
+ # @param entry [Hash] Entry with :name key
12
+ # @return [Boolean] true if now selected, false if unselected
13
+ def toggle_selection(entry)
14
+ return false unless entry
15
+
16
+ if @selected_items.include?(entry[:name])
17
+ @selected_items.delete(entry[:name])
18
+ false
19
+ else
20
+ @selected_items << entry[:name]
21
+ true
22
+ end
23
+ end
24
+
25
+ # Check if an entry is selected
26
+ # @param entry_name [String] Entry name
27
+ # @return [Boolean]
28
+ def selected?(entry_name)
29
+ @selected_items.include?(entry_name)
30
+ end
31
+
32
+ # Get all selected items
33
+ # @return [Array<String>] Copy of selected items
34
+ def selected_items
35
+ @selected_items.dup
36
+ end
37
+
38
+ # Clear all selections
39
+ def clear
40
+ @selected_items.clear
41
+ end
42
+
43
+ # Check if any items are selected
44
+ # @return [Boolean]
45
+ def any?
46
+ !@selected_items.empty?
47
+ end
48
+
49
+ # Get the count of selected items
50
+ # @return [Integer]
51
+ def count
52
+ @selected_items.length
53
+ end
54
+
55
+ # Add an item to selection
56
+ # @param item_name [String] Item name
57
+ def add(item_name)
58
+ @selected_items << item_name unless @selected_items.include?(item_name)
59
+ end
60
+
61
+ # Remove an item from selection
62
+ # @param item_name [String] Item name
63
+ def remove(item_name)
64
+ @selected_items.delete(item_name)
65
+ end
66
+
67
+ # Select multiple items
68
+ # @param item_names [Array<String>] Item names
69
+ def select_multiple(item_names)
70
+ item_names.each { |name| add(name) }
71
+ end
72
+
73
+ # Check if selection is empty
74
+ # @return [Boolean]
75
+ def empty?
76
+ @selected_items.empty?
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Beniya
4
+ # Text utility methods for display width calculation and string manipulation
5
+ # Handles multi-byte characters (Japanese, etc.) correctly
6
+ module TextUtils
7
+ module_function
8
+
9
+ # Character width constants
10
+ FULLWIDTH_CHAR_WIDTH = 2
11
+ HALFWIDTH_CHAR_WIDTH = 1
12
+ MULTIBYTE_THRESHOLD = 1
13
+
14
+ # Truncation constants
15
+ ELLIPSIS_MIN_WIDTH = 3
16
+ ELLIPSIS = '...'
17
+
18
+ # Line break constants
19
+ BREAK_POINT_THRESHOLD = 0.5 # Break after 50% of max_width
20
+
21
+ # Calculate display width of a string
22
+ # Full-width characters (Japanese, etc.) count as 2, half-width as 1
23
+ def display_width(string)
24
+ string.each_char.map do |char|
25
+ case char
26
+ when /[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF\uFF00-\uFFEF]/
27
+ FULLWIDTH_CHAR_WIDTH # Japanese characters (hiragana, katakana, kanji, full-width symbols)
28
+ when /[\u0020-\u007E]/
29
+ HALFWIDTH_CHAR_WIDTH # ASCII characters
30
+ else
31
+ char.bytesize > MULTIBYTE_THRESHOLD ? FULLWIDTH_CHAR_WIDTH : HALFWIDTH_CHAR_WIDTH
32
+ end
33
+ end.sum
34
+ end
35
+
36
+ # Truncate string to fit within max_width
37
+ def truncate_to_width(string, max_width)
38
+ return string if display_width(string) <= max_width
39
+
40
+ result = ''
41
+ current_width = 0
42
+
43
+ string.each_char do |char|
44
+ char_width = display_width(char)
45
+ break if current_width + char_width > max_width
46
+
47
+ result += char
48
+ current_width += char_width
49
+ end
50
+
51
+ # Add ellipsis if there's room
52
+ result += ELLIPSIS if max_width >= ELLIPSIS_MIN_WIDTH && current_width <= max_width - ELLIPSIS_MIN_WIDTH
53
+ result
54
+ end
55
+
56
+ # Pad string to target_width with spaces
57
+ def pad_string_to_width(string, target_width)
58
+ current_width = display_width(string)
59
+ if current_width >= target_width
60
+ truncate_to_width(string, target_width)
61
+ else
62
+ string + ' ' * (target_width - current_width)
63
+ end
64
+ end
65
+
66
+ # Find the best break point for wrapping text within max_width
67
+ def find_break_point(line, max_width)
68
+ return line.length if display_width(line) <= max_width
69
+
70
+ current_width = 0
71
+ best_break_point = 0
72
+ space_break_point = nil
73
+ punct_break_point = nil
74
+
75
+ line.each_char.with_index do |char, index|
76
+ char_width = display_width(char)
77
+ break if current_width + char_width > max_width
78
+
79
+ current_width += char_width
80
+ best_break_point = index + 1
81
+
82
+ # Record break point at space
83
+ space_break_point = index + 1 if char == ' ' && current_width > max_width * BREAK_POINT_THRESHOLD
84
+
85
+ # Record break point at Japanese punctuation
86
+ punct_break_point = index + 1 if char.match?(/[、。,.!?]/) && current_width > max_width * BREAK_POINT_THRESHOLD
87
+ end
88
+
89
+ space_break_point || punct_break_point || best_break_point
90
+ end
91
+ end
92
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Beniya
4
- VERSION = '0.6.1'
4
+ VERSION = '0.6.2'
5
5
  end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module Beniya
6
+ # Integrates with zoxide for directory history navigation
7
+ class ZoxideIntegration
8
+ # Dialog size constants
9
+ DIALOG_WIDTH = 45
10
+ DIALOG_BORDER_HEIGHT = 4
11
+
12
+ def initialize(dialog_renderer = nil)
13
+ @dialog_renderer = dialog_renderer
14
+ end
15
+
16
+ # Check if zoxide is available
17
+ # @return [Boolean]
18
+ def available?
19
+ system('which zoxide > /dev/null 2>&1')
20
+ end
21
+
22
+ # Get zoxide history
23
+ # @return [Array<Hash>] Array of { path: String, score: Float }
24
+ def get_history
25
+ return [] unless available?
26
+
27
+ begin
28
+ # Get zoxide history with scores
29
+ output = `zoxide query --list --score 2>/dev/null`.strip
30
+ return [] if output.empty?
31
+
32
+ # Parse each line into path and score
33
+ lines = output.split("\n")
34
+ history = lines.map do |line|
35
+ # zoxide output format: "score path"
36
+ if line.match(/^\s*(\d+(?:\.\d+)?)\s+(.+)$/)
37
+ score = ::Regexp.last_match(1).to_f
38
+ path = ::Regexp.last_match(2).strip
39
+ { path: path, score: score }
40
+ else
41
+ # No score (backward compatibility)
42
+ { path: line.strip, score: 0.0 }
43
+ end
44
+ end
45
+
46
+ # Filter to only existing directories
47
+ history.select { |entry| Dir.exist?(entry[:path]) }
48
+ rescue StandardError
49
+ []
50
+ end
51
+ end
52
+
53
+ # Show zoxide history menu and let user select
54
+ # @return [String, nil] Selected path or nil if cancelled
55
+ def show_menu
56
+ return nil unless @dialog_renderer
57
+
58
+ history = get_history
59
+
60
+ if history.empty?
61
+ show_no_history_message
62
+ return nil
63
+ end
64
+
65
+ select_from_history(history)
66
+ end
67
+
68
+ # Add directory to zoxide history
69
+ # @param path [String] Directory path
70
+ # @return [Boolean] Success status
71
+ def add_to_history(path)
72
+ return false unless available?
73
+ return false unless Dir.exist?(path)
74
+
75
+ begin
76
+ system("zoxide add #{Shellwords.escape(path)} > /dev/null 2>&1")
77
+ true
78
+ rescue StandardError
79
+ false
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # Show message when no history is available
86
+ def show_no_history_message
87
+ return unless @dialog_renderer
88
+
89
+ title = 'Zoxide'
90
+ content_lines = [
91
+ '',
92
+ 'No zoxide history found.',
93
+ '',
94
+ 'Zoxide learns from your directory navigation.',
95
+ 'Use zoxide more to build up history.',
96
+ '',
97
+ 'Press any key to continue...'
98
+ ]
99
+
100
+ dialog_width = DIALOG_WIDTH
101
+ dialog_height = DIALOG_BORDER_HEIGHT + content_lines.length
102
+ x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
103
+
104
+ @dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
105
+ border_color: "\e[33m", # Yellow
106
+ title_color: "\e[1;33m", # Bold yellow
107
+ content_color: "\e[37m" # White
108
+ })
109
+
110
+ STDIN.getch
111
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
112
+ end
113
+
114
+ # Select from zoxide history
115
+ # @param history [Array<Hash>] History entries
116
+ # @return [String, nil] Selected path or nil
117
+ def select_from_history(history)
118
+ return nil unless @dialog_renderer
119
+
120
+ title = 'Zoxide History'
121
+
122
+ # Format history for display (max 20 items)
123
+ display_history = history.first(20)
124
+ content_lines = ['']
125
+
126
+ display_history.each_with_index do |entry, index|
127
+ # Shorten path display (replace home directory with ~)
128
+ display_path = entry[:path].gsub(ENV['HOME'], '~')
129
+ line = " #{index + 1}. #{display_path}"
130
+ # Truncate if too long
131
+ line = line[0...60] + '...' if line.length > 63
132
+ content_lines << line
133
+ end
134
+
135
+ content_lines << ''
136
+ content_lines << 'Enter number (1-' + display_history.length.to_s + ') or ESC to cancel'
137
+
138
+ dialog_width = 70
139
+ dialog_height = [4 + content_lines.length, 25].min
140
+ x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
141
+
142
+ @dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
143
+ border_color: "\e[36m", # Cyan
144
+ title_color: "\e[1;36m", # Bold cyan
145
+ content_color: "\e[37m" # White
146
+ })
147
+
148
+ # Number input mode
149
+ input_buffer = ''
150
+
151
+ loop do
152
+ char = STDIN.getch
153
+
154
+ case char
155
+ when "\e", "\x03" # ESC, Ctrl+C
156
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
157
+ return nil
158
+ when "\r", "\n" # Enter
159
+ unless input_buffer.empty?
160
+ number = input_buffer.to_i
161
+ if number > 0 && number <= display_history.length
162
+ selected_entry = display_history[number - 1]
163
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
164
+ return selected_entry[:path]
165
+ end
166
+ end
167
+ # Invalid input, ask again
168
+ input_buffer = ''
169
+ when "\u007f", "\b" # Backspace
170
+ input_buffer = input_buffer[0...-1] unless input_buffer.empty?
171
+ when /[0-9]/
172
+ input_buffer += char
173
+ # Max 2 digits
174
+ input_buffer = input_buffer[-2..-1] if input_buffer.length > 2
175
+
176
+ # If number is within range, select immediately
177
+ number = input_buffer.to_i
178
+ if number > 0 && number <= display_history.length &&
179
+ (number >= 10 || input_buffer.length == 1)
180
+ selected_entry = display_history[number - 1]
181
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
182
+ return selected_entry[:path]
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
data/lib/beniya.rb CHANGED
@@ -5,6 +5,15 @@ require_relative "beniya/config"
5
5
  require_relative "beniya/config_loader"
6
6
  require_relative "beniya/color_helper"
7
7
  require_relative "beniya/directory_listing"
8
+ require_relative "beniya/filter_manager"
9
+ require_relative "beniya/selection_manager"
10
+ require_relative "beniya/file_operations"
11
+ require_relative "beniya/bookmark_manager"
12
+ require_relative "beniya/bookmark"
13
+ require_relative "beniya/zoxide_integration"
14
+ require_relative "beniya/dialog_renderer"
15
+ require_relative "beniya/text_utils"
16
+ require_relative "beniya/logger"
8
17
  require_relative "beniya/keybind_handler"
9
18
  require_relative "beniya/file_preview"
10
19
  require_relative "beniya/terminal_ui"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: beniya
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - masisz
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-10-19 00:00:00.000000000 Z
10
+ date: 2025-10-27 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: io-console
@@ -129,16 +129,24 @@ files:
129
129
  - lib/beniya.rb
130
130
  - lib/beniya/application.rb
131
131
  - lib/beniya/bookmark.rb
132
+ - lib/beniya/bookmark_manager.rb
132
133
  - lib/beniya/color_helper.rb
133
134
  - lib/beniya/config.rb
134
135
  - lib/beniya/config_loader.rb
136
+ - lib/beniya/dialog_renderer.rb
135
137
  - lib/beniya/directory_listing.rb
136
138
  - lib/beniya/file_opener.rb
139
+ - lib/beniya/file_operations.rb
137
140
  - lib/beniya/file_preview.rb
141
+ - lib/beniya/filter_manager.rb
138
142
  - lib/beniya/health_checker.rb
139
143
  - lib/beniya/keybind_handler.rb
144
+ - lib/beniya/logger.rb
145
+ - lib/beniya/selection_manager.rb
140
146
  - lib/beniya/terminal_ui.rb
147
+ - lib/beniya/text_utils.rb
141
148
  - lib/beniya/version.rb
149
+ - lib/beniya/zoxide_integration.rb
142
150
  - publish_gem.zsh
143
151
  - test_delete/test1.txt
144
152
  - test_delete/test2.txt