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.
@@ -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