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 +4 -4
- data/lib/beniya/application.rb +4 -1
- data/lib/beniya/bookmark_manager.rb +173 -0
- data/lib/beniya/dialog_renderer.rb +127 -0
- data/lib/beniya/file_operations.rb +231 -0
- data/lib/beniya/file_preview.rb +11 -3
- data/lib/beniya/filter_manager.rb +114 -0
- data/lib/beniya/keybind_handler.rb +211 -671
- data/lib/beniya/logger.rb +103 -0
- data/lib/beniya/selection_manager.rb +79 -0
- data/lib/beniya/terminal_ui.rb +54 -25
- data/lib/beniya/text_utils.rb +92 -0
- data/lib/beniya/version.rb +1 -1
- data/lib/beniya/zoxide_integration.rb +188 -0
- data/lib/beniya.rb +9 -0
- metadata +10 -2
|
@@ -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
|
data/lib/beniya/terminal_ui.rb
CHANGED
|
@@ -4,14 +4,43 @@ require 'io/console'
|
|
|
4
4
|
|
|
5
5
|
module Beniya
|
|
6
6
|
class TerminalUI
|
|
7
|
+
# Layout constants
|
|
8
|
+
HEADER_HEIGHT = 2 # Header占有行数
|
|
9
|
+
FOOTER_HEIGHT = 1 # Footer占有行数
|
|
10
|
+
HEADER_FOOTER_MARGIN = 4 # Header + Footer分のマージン
|
|
11
|
+
|
|
12
|
+
# Panel layout ratios
|
|
13
|
+
LEFT_PANEL_RATIO = 0.5 # 左パネルの幅比率
|
|
14
|
+
RIGHT_PANEL_RATIO = 1.0 - LEFT_PANEL_RATIO
|
|
15
|
+
|
|
16
|
+
# Display constants
|
|
17
|
+
DEFAULT_SCREEN_WIDTH = 80 # デフォルト画面幅
|
|
18
|
+
DEFAULT_SCREEN_HEIGHT = 24 # デフォルト画面高さ
|
|
19
|
+
HEADER_PADDING = 2 # ヘッダーのパディング
|
|
20
|
+
BASE_INFO_RESERVED_WIDTH = 20 # ベースディレクトリ表示の予約幅
|
|
21
|
+
BASE_INFO_MIN_WIDTH = 10 # ベースディレクトリ表示の最小幅
|
|
22
|
+
FILTER_TEXT_RESERVED = 15 # フィルタテキスト表示の予約幅
|
|
23
|
+
|
|
24
|
+
# File display constants
|
|
25
|
+
ICON_SIZE_PADDING = 12 # アイコン、選択マーク、サイズ情報分
|
|
26
|
+
CURSOR_OFFSET = 1 # カーソル位置のオフセット
|
|
27
|
+
|
|
28
|
+
# Size display constants (bytes)
|
|
29
|
+
KILOBYTE = 1024
|
|
30
|
+
MEGABYTE = KILOBYTE * 1024
|
|
31
|
+
GIGABYTE = MEGABYTE * 1024
|
|
32
|
+
|
|
33
|
+
# Line offsets
|
|
34
|
+
CONTENT_START_LINE = 3 # コンテンツ開始行(ヘッダー2行スキップ)
|
|
35
|
+
|
|
7
36
|
def initialize
|
|
8
37
|
console = IO.console
|
|
9
38
|
if console
|
|
10
39
|
@screen_width, @screen_height = console.winsize.reverse
|
|
11
40
|
else
|
|
12
41
|
# fallback values (for test environments etc.)
|
|
13
|
-
@screen_width =
|
|
14
|
-
@screen_height =
|
|
42
|
+
@screen_width = DEFAULT_SCREEN_WIDTH
|
|
43
|
+
@screen_height = DEFAULT_SCREEN_HEIGHT
|
|
15
44
|
end
|
|
16
45
|
@running = false
|
|
17
46
|
end
|
|
@@ -83,9 +112,9 @@ module Beniya
|
|
|
83
112
|
entries = get_display_entries
|
|
84
113
|
selected_entry = entries[@keybind_handler.current_index]
|
|
85
114
|
|
|
86
|
-
# calculate height with header
|
|
87
|
-
content_height = @screen_height -
|
|
88
|
-
left_width = @screen_width
|
|
115
|
+
# calculate height with header and footer margin
|
|
116
|
+
content_height = @screen_height - HEADER_FOOTER_MARGIN
|
|
117
|
+
left_width = (@screen_width * LEFT_PANEL_RATIO).to_i
|
|
89
118
|
right_width = @screen_width - left_width
|
|
90
119
|
|
|
91
120
|
# adjust so right panel doesn't overflow into left panel
|
|
@@ -112,14 +141,14 @@ module Beniya
|
|
|
112
141
|
end
|
|
113
142
|
|
|
114
143
|
# abbreviate if path is too long
|
|
115
|
-
if header.length > @screen_width -
|
|
144
|
+
if header.length > @screen_width - HEADER_PADDING
|
|
116
145
|
if @keybind_handler.filter_active?
|
|
117
146
|
# prioritize showing filter when active
|
|
118
147
|
filter_text = " [Filter: #{@keybind_handler.filter_query}]"
|
|
119
|
-
base_length = @screen_width - filter_text.length -
|
|
148
|
+
base_length = @screen_width - filter_text.length - FILTER_TEXT_RESERVED
|
|
120
149
|
header = "📁 beniya - ...#{current_path[-base_length..-1]}#{filter_text}"
|
|
121
150
|
else
|
|
122
|
-
header = "📁 beniya - ...#{current_path[-(@screen_width -
|
|
151
|
+
header = "📁 beniya - ...#{current_path[-(@screen_width - FILTER_TEXT_RESERVED)..-1]}"
|
|
123
152
|
end
|
|
124
153
|
end
|
|
125
154
|
|
|
@@ -143,15 +172,15 @@ module Beniya
|
|
|
143
172
|
end
|
|
144
173
|
|
|
145
174
|
# 長すぎる場合は省略
|
|
146
|
-
if base_info.length > @screen_width -
|
|
175
|
+
if base_info.length > @screen_width - HEADER_PADDING
|
|
147
176
|
if base_info.include?(" | Selected:")
|
|
148
177
|
selected_part = base_info.split(" | Selected:").last
|
|
149
|
-
available_length = @screen_width -
|
|
178
|
+
available_length = @screen_width - BASE_INFO_RESERVED_WIDTH - " | Selected:#{selected_part}".length
|
|
150
179
|
else
|
|
151
|
-
available_length = @screen_width -
|
|
180
|
+
available_length = @screen_width - BASE_INFO_RESERVED_WIDTH
|
|
152
181
|
end
|
|
153
182
|
|
|
154
|
-
if available_length >
|
|
183
|
+
if available_length > BASE_INFO_MIN_WIDTH
|
|
155
184
|
# パスの最後の部分を表示
|
|
156
185
|
dir_part = base_info.split(": ").last.split(" | ").first
|
|
157
186
|
short_base_dir = "...#{dir_part[-available_length..-1]}"
|
|
@@ -170,7 +199,7 @@ module Beniya
|
|
|
170
199
|
|
|
171
200
|
(0...height).each do |i|
|
|
172
201
|
entry_index = start_index + i
|
|
173
|
-
line_num = i +
|
|
202
|
+
line_num = i + CONTENT_START_LINE
|
|
174
203
|
|
|
175
204
|
print "\e[#{line_num};1H" # set cursor position
|
|
176
205
|
|
|
@@ -181,7 +210,7 @@ module Beniya
|
|
|
181
210
|
draw_entry_line(entry, width, is_selected)
|
|
182
211
|
else
|
|
183
212
|
# 左ペイン専用の安全な幅で空行を出力
|
|
184
|
-
safe_width = [width -
|
|
213
|
+
safe_width = [width - CURSOR_OFFSET, (@screen_width * LEFT_PANEL_RATIO).to_i - CURSOR_OFFSET].min
|
|
185
214
|
print ' ' * safe_width
|
|
186
215
|
end
|
|
187
216
|
end
|
|
@@ -192,14 +221,14 @@ module Beniya
|
|
|
192
221
|
icon, color = get_entry_display_info(entry)
|
|
193
222
|
|
|
194
223
|
# 左ペイン専用の安全な幅を計算(右ペインにはみ出さないよう)
|
|
195
|
-
safe_width = [width -
|
|
224
|
+
safe_width = [width - CURSOR_OFFSET, (@screen_width * LEFT_PANEL_RATIO).to_i - CURSOR_OFFSET].min
|
|
196
225
|
|
|
197
226
|
# 選択マークの追加
|
|
198
227
|
selection_mark = @keybind_handler.is_selected?(entry[:name]) ? "✓ " : " "
|
|
199
228
|
|
|
200
229
|
# ファイル名(必要に応じて切り詰め)
|
|
201
230
|
name = entry[:name]
|
|
202
|
-
max_name_length = safe_width -
|
|
231
|
+
max_name_length = safe_width - ICON_SIZE_PADDING
|
|
203
232
|
name = name[0...max_name_length - 3] + '...' if max_name_length > 0 && name.length > max_name_length
|
|
204
233
|
|
|
205
234
|
# サイズ情報
|
|
@@ -260,22 +289,22 @@ module Beniya
|
|
|
260
289
|
def format_size(size)
|
|
261
290
|
return ' ' if size == 0
|
|
262
291
|
|
|
263
|
-
if size <
|
|
292
|
+
if size < KILOBYTE
|
|
264
293
|
"#{size}B".rjust(6)
|
|
265
|
-
elsif size <
|
|
266
|
-
"#{(size /
|
|
267
|
-
elsif size <
|
|
268
|
-
"#{(size /
|
|
294
|
+
elsif size < MEGABYTE
|
|
295
|
+
"#{(size / KILOBYTE.to_f).round(1)}K".rjust(6)
|
|
296
|
+
elsif size < GIGABYTE
|
|
297
|
+
"#{(size / MEGABYTE.to_f).round(1)}M".rjust(6)
|
|
269
298
|
else
|
|
270
|
-
"#{(size /
|
|
299
|
+
"#{(size / GIGABYTE.to_f).round(1)}G".rjust(6)
|
|
271
300
|
end
|
|
272
301
|
end
|
|
273
302
|
|
|
274
303
|
def draw_file_preview(selected_entry, width, height, left_offset)
|
|
275
304
|
(0...height).each do |i|
|
|
276
|
-
line_num = i +
|
|
305
|
+
line_num = i + CONTENT_START_LINE
|
|
277
306
|
# カーソル位置を左パネルの右端に設定
|
|
278
|
-
cursor_position = left_offset +
|
|
307
|
+
cursor_position = left_offset + CURSOR_OFFSET
|
|
279
308
|
|
|
280
309
|
# 画面の境界を厳密に計算
|
|
281
310
|
max_chars_from_cursor = @screen_width - cursor_position
|
|
@@ -450,7 +479,7 @@ module Beniya
|
|
|
450
479
|
|
|
451
480
|
def draw_footer
|
|
452
481
|
# 最下行から1行上に表示してスクロールを避ける
|
|
453
|
-
footer_line = @screen_height -
|
|
482
|
+
footer_line = @screen_height - FOOTER_HEIGHT
|
|
454
483
|
print "\e[#{footer_line};1H"
|
|
455
484
|
|
|
456
485
|
if @keybind_handler.filter_active?
|
|
@@ -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
|
data/lib/beniya/version.rb
CHANGED
|
@@ -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.
|
|
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
|
+
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
|