beniya 0.1.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.
- checksums.yaml +7 -0
- data/README.md +288 -0
- data/README_EN.md +289 -0
- data/beniya.gemspec +40 -0
- data/bin/beniya +31 -0
- data/config_example.rb +88 -0
- data/lib/beniya/application.rb +28 -0
- data/lib/beniya/color_helper.rb +150 -0
- data/lib/beniya/config.rb +179 -0
- data/lib/beniya/config_loader.rb +110 -0
- data/lib/beniya/directory_listing.rb +99 -0
- data/lib/beniya/file_opener.rb +114 -0
- data/lib/beniya/file_preview.rb +172 -0
- data/lib/beniya/health_checker.rb +224 -0
- data/lib/beniya/keybind_handler.rb +332 -0
- data/lib/beniya/terminal_ui.rb +469 -0
- data/lib/beniya/version.rb +5 -0
- data/lib/beniya.rb +17 -0
- metadata +161 -0
@@ -0,0 +1,332 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Beniya
|
4
|
+
class KeybindHandler
|
5
|
+
attr_reader :current_index
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@current_index = 0
|
9
|
+
@directory_listing = nil
|
10
|
+
@file_opener = FileOpener.new
|
11
|
+
@filter_mode = false
|
12
|
+
@filter_query = ""
|
13
|
+
@filtered_entries = []
|
14
|
+
@original_entries = []
|
15
|
+
end
|
16
|
+
|
17
|
+
def set_directory_listing(directory_listing)
|
18
|
+
@directory_listing = directory_listing
|
19
|
+
@current_index = 0
|
20
|
+
end
|
21
|
+
|
22
|
+
def handle_key(key)
|
23
|
+
return false unless @directory_listing
|
24
|
+
|
25
|
+
# フィルターモード中は他のキーバインドを無効化
|
26
|
+
if @filter_mode
|
27
|
+
return handle_filter_input(key)
|
28
|
+
end
|
29
|
+
|
30
|
+
case key
|
31
|
+
when 'j'
|
32
|
+
move_down
|
33
|
+
when 'k'
|
34
|
+
move_up
|
35
|
+
when 'h'
|
36
|
+
navigate_parent
|
37
|
+
when 'l', "\r", "\n" # l, Enter
|
38
|
+
navigate_enter
|
39
|
+
when 'g'
|
40
|
+
move_to_top
|
41
|
+
when 'G'
|
42
|
+
move_to_bottom
|
43
|
+
when 'r'
|
44
|
+
refresh
|
45
|
+
when 'o' # o
|
46
|
+
open_current_file
|
47
|
+
when ' ' # Space
|
48
|
+
if !@filter_query.empty?
|
49
|
+
# フィルタが設定されている場合は再編集モードに入る
|
50
|
+
@filter_mode = true
|
51
|
+
@original_entries = @directory_listing.list_entries.dup if @original_entries.empty?
|
52
|
+
else
|
53
|
+
# 新規フィルターモード開始
|
54
|
+
start_filter_mode
|
55
|
+
end
|
56
|
+
when "\e" # ESC
|
57
|
+
if !@filter_query.empty?
|
58
|
+
# フィルタが設定されている場合はクリア
|
59
|
+
clear_filter_mode
|
60
|
+
true
|
61
|
+
else
|
62
|
+
false
|
63
|
+
end
|
64
|
+
when 'q' # q
|
65
|
+
exit_request
|
66
|
+
when 'e' # e
|
67
|
+
open_explorer
|
68
|
+
when '/' # /
|
69
|
+
fzf_search
|
70
|
+
when 'f' # f
|
71
|
+
rga_search
|
72
|
+
else
|
73
|
+
false # #{ConfigLoader.message('keybind.invalid_key')}
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def select_index(index)
|
78
|
+
entries = get_active_entries
|
79
|
+
@current_index = [[index, 0].max, entries.length - 1].min
|
80
|
+
end
|
81
|
+
|
82
|
+
def current_entry
|
83
|
+
entries = get_active_entries
|
84
|
+
entries[@current_index]
|
85
|
+
end
|
86
|
+
|
87
|
+
def filter_active?
|
88
|
+
@filter_mode || !@filter_query.empty?
|
89
|
+
end
|
90
|
+
|
91
|
+
def filter_query
|
92
|
+
@filter_query
|
93
|
+
end
|
94
|
+
|
95
|
+
def get_active_entries
|
96
|
+
if @filter_mode || !@filter_query.empty?
|
97
|
+
@filtered_entries.empty? ? [] : @filtered_entries
|
98
|
+
else
|
99
|
+
@directory_listing&.list_entries || []
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def move_down
|
106
|
+
entries = get_active_entries
|
107
|
+
@current_index = [@current_index + 1, entries.length - 1].min
|
108
|
+
true
|
109
|
+
end
|
110
|
+
|
111
|
+
def move_up
|
112
|
+
@current_index = [@current_index - 1, 0].max
|
113
|
+
true
|
114
|
+
end
|
115
|
+
|
116
|
+
def move_to_top
|
117
|
+
@current_index = 0
|
118
|
+
true
|
119
|
+
end
|
120
|
+
|
121
|
+
def move_to_bottom
|
122
|
+
entries = get_active_entries
|
123
|
+
@current_index = entries.length - 1
|
124
|
+
true
|
125
|
+
end
|
126
|
+
|
127
|
+
def navigate_enter
|
128
|
+
entry = current_entry
|
129
|
+
return false unless entry
|
130
|
+
|
131
|
+
if entry[:type] == "directory"
|
132
|
+
result = @directory_listing.navigate_to(entry[:name])
|
133
|
+
if result
|
134
|
+
@current_index = 0 # select first entry in new directory
|
135
|
+
clear_filter_mode # ディレクトリ移動時にフィルタをリセット
|
136
|
+
end
|
137
|
+
result
|
138
|
+
else
|
139
|
+
# do nothing for files (file opening feature may be added in the future)
|
140
|
+
false
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def navigate_parent
|
145
|
+
result = @directory_listing.navigate_to_parent
|
146
|
+
if result
|
147
|
+
@current_index = 0 # select first entry in parent directory
|
148
|
+
clear_filter_mode # ディレクトリ移動時にフィルタをリセット
|
149
|
+
end
|
150
|
+
result
|
151
|
+
end
|
152
|
+
|
153
|
+
def refresh
|
154
|
+
@directory_listing.refresh
|
155
|
+
if @filter_mode || !@filter_query.empty?
|
156
|
+
# Re-apply filter with new directory contents
|
157
|
+
@original_entries = @directory_listing.list_entries.dup
|
158
|
+
apply_filter
|
159
|
+
else
|
160
|
+
# adjust index to stay within bounds after refresh
|
161
|
+
entries = @directory_listing.list_entries
|
162
|
+
@current_index = [@current_index, entries.length - 1].min if entries.any?
|
163
|
+
end
|
164
|
+
true
|
165
|
+
end
|
166
|
+
|
167
|
+
def open_current_file
|
168
|
+
entry = current_entry
|
169
|
+
return false unless entry
|
170
|
+
|
171
|
+
if entry[:type] == "file"
|
172
|
+
@file_opener.open_file(entry[:path])
|
173
|
+
true
|
174
|
+
else
|
175
|
+
false
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
def exit_request
|
180
|
+
true # request exit
|
181
|
+
end
|
182
|
+
|
183
|
+
def open_explorer
|
184
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
185
|
+
|
186
|
+
case RUBY_PLATFORM
|
187
|
+
when /darwin/ # macOS
|
188
|
+
system("open", current_path)
|
189
|
+
when /linux/ # Linux
|
190
|
+
system("xdg-open", current_path)
|
191
|
+
when /mswin|mingw|cygwin/ # Windows
|
192
|
+
system("explorer", current_path)
|
193
|
+
end
|
194
|
+
|
195
|
+
true
|
196
|
+
end
|
197
|
+
|
198
|
+
def fzf_search
|
199
|
+
return false unless fzf_available?
|
200
|
+
|
201
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
202
|
+
|
203
|
+
# fzfでファイル検索を実行
|
204
|
+
selected_file = `cd "#{current_path}" && find . -type f | fzf --preview 'cat {}'`.strip
|
205
|
+
|
206
|
+
# ファイルが選択された場合、そのファイルを開く
|
207
|
+
if !selected_file.empty? && File.exist?(File.join(current_path, selected_file))
|
208
|
+
full_path = File.expand_path(selected_file, current_path)
|
209
|
+
@file_opener.open_file(full_path)
|
210
|
+
end
|
211
|
+
|
212
|
+
true
|
213
|
+
end
|
214
|
+
|
215
|
+
def fzf_available?
|
216
|
+
system("which fzf > /dev/null 2>&1")
|
217
|
+
end
|
218
|
+
|
219
|
+
def rga_search
|
220
|
+
return false unless rga_available?
|
221
|
+
|
222
|
+
current_path = @directory_listing&.current_path || Dir.pwd
|
223
|
+
|
224
|
+
# input search keyword
|
225
|
+
print ConfigLoader.message('keybind.search_text')
|
226
|
+
search_query = STDIN.gets.chomp
|
227
|
+
return false if search_query.empty?
|
228
|
+
|
229
|
+
# execute rga file content search
|
230
|
+
search_results = `cd "#{current_path}" && rga --line-number --with-filename "#{search_query}" . 2>/dev/null`
|
231
|
+
|
232
|
+
if search_results.empty?
|
233
|
+
puts "\n#{ConfigLoader.message('keybind.no_matches')}"
|
234
|
+
print ConfigLoader.message('keybind.press_any_key')
|
235
|
+
STDIN.getch
|
236
|
+
return true
|
237
|
+
end
|
238
|
+
|
239
|
+
# pass results to fzf for selection
|
240
|
+
selected_result = IO.popen("fzf", "r+") do |fzf|
|
241
|
+
fzf.write(search_results)
|
242
|
+
fzf.close_write
|
243
|
+
fzf.read.strip
|
244
|
+
end
|
245
|
+
|
246
|
+
# extract file path and line number from selected result
|
247
|
+
if !selected_result.empty? && selected_result.match(/^(.+?):(\d+):/)
|
248
|
+
file_path = $1
|
249
|
+
line_number = $2.to_i
|
250
|
+
full_path = File.expand_path(file_path, current_path)
|
251
|
+
|
252
|
+
if File.exist?(full_path)
|
253
|
+
@file_opener.open_file_with_line(full_path, line_number)
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
true
|
258
|
+
end
|
259
|
+
|
260
|
+
def rga_available?
|
261
|
+
system("which rga > /dev/null 2>&1")
|
262
|
+
end
|
263
|
+
|
264
|
+
def start_filter_mode
|
265
|
+
@filter_mode = true
|
266
|
+
@filter_query = ""
|
267
|
+
@original_entries = @directory_listing.list_entries.dup
|
268
|
+
@filtered_entries = @original_entries.dup
|
269
|
+
@current_index = 0
|
270
|
+
true
|
271
|
+
end
|
272
|
+
|
273
|
+
def handle_filter_input(key)
|
274
|
+
case key
|
275
|
+
when "\e" # ESC - フィルタをクリアして通常モードに戻る
|
276
|
+
clear_filter_mode
|
277
|
+
when "\r", "\n" # Enter - フィルタを維持して通常モードに戻る
|
278
|
+
exit_filter_mode_keep_filter
|
279
|
+
when "\u007f", "\b" # Backspace
|
280
|
+
if @filter_query.length > 0
|
281
|
+
@filter_query = @filter_query[0...-1]
|
282
|
+
apply_filter
|
283
|
+
else
|
284
|
+
clear_filter_mode
|
285
|
+
end
|
286
|
+
else
|
287
|
+
# printable characters (英数字、記号、日本語文字など)
|
288
|
+
if key.length == 1 && key.ord >= 32 && key.ord < 127 # ASCII printable
|
289
|
+
@filter_query += key
|
290
|
+
apply_filter
|
291
|
+
elsif key.bytesize > 1 # Multi-byte characters (Japanese, etc.)
|
292
|
+
@filter_query += key
|
293
|
+
apply_filter
|
294
|
+
end
|
295
|
+
# その他のキー(Ctrl+c等)は無視
|
296
|
+
end
|
297
|
+
true
|
298
|
+
end
|
299
|
+
|
300
|
+
def apply_filter
|
301
|
+
if @filter_query.empty?
|
302
|
+
@filtered_entries = @original_entries.dup
|
303
|
+
else
|
304
|
+
query_downcase = @filter_query.downcase
|
305
|
+
@filtered_entries = @original_entries.select do |entry|
|
306
|
+
entry[:name].downcase.include?(query_downcase)
|
307
|
+
end
|
308
|
+
end
|
309
|
+
@current_index = [@current_index, [@filtered_entries.length - 1, 0].max].min
|
310
|
+
end
|
311
|
+
|
312
|
+
def exit_filter_mode_keep_filter
|
313
|
+
# フィルタを維持したまま通常モードに戻る
|
314
|
+
@filter_mode = false
|
315
|
+
# @filter_query, @filtered_entries は維持
|
316
|
+
end
|
317
|
+
|
318
|
+
def clear_filter_mode
|
319
|
+
# フィルタをクリアして通常モードに戻る
|
320
|
+
@filter_mode = false
|
321
|
+
@filter_query = ""
|
322
|
+
@filtered_entries = []
|
323
|
+
@original_entries = []
|
324
|
+
@current_index = 0
|
325
|
+
end
|
326
|
+
|
327
|
+
def exit_filter_mode
|
328
|
+
# 既存メソッド(後方互換用)
|
329
|
+
clear_filter_mode
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|