rufio 0.9.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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +188 -0
  3. data/CHANGELOG_v0.4.0.md +146 -0
  4. data/CHANGELOG_v0.5.0.md +26 -0
  5. data/CHANGELOG_v0.6.0.md +182 -0
  6. data/CHANGELOG_v0.7.0.md +280 -0
  7. data/CHANGELOG_v0.8.0.md +267 -0
  8. data/CHANGELOG_v0.9.0.md +279 -0
  9. data/README.md +631 -0
  10. data/README_EN.md +561 -0
  11. data/Rakefile +156 -0
  12. data/bin/rufio +34 -0
  13. data/config_example.rb +88 -0
  14. data/docs/PLUGIN_GUIDE.md +431 -0
  15. data/docs/plugin_example.rb +119 -0
  16. data/lib/rufio/application.rb +32 -0
  17. data/lib/rufio/bookmark.rb +115 -0
  18. data/lib/rufio/bookmark_manager.rb +173 -0
  19. data/lib/rufio/color_helper.rb +150 -0
  20. data/lib/rufio/command_mode.rb +72 -0
  21. data/lib/rufio/command_mode_ui.rb +168 -0
  22. data/lib/rufio/config.rb +199 -0
  23. data/lib/rufio/config_loader.rb +110 -0
  24. data/lib/rufio/dialog_renderer.rb +127 -0
  25. data/lib/rufio/directory_listing.rb +113 -0
  26. data/lib/rufio/file_opener.rb +140 -0
  27. data/lib/rufio/file_operations.rb +231 -0
  28. data/lib/rufio/file_preview.rb +200 -0
  29. data/lib/rufio/filter_manager.rb +114 -0
  30. data/lib/rufio/health_checker.rb +246 -0
  31. data/lib/rufio/keybind_handler.rb +828 -0
  32. data/lib/rufio/logger.rb +103 -0
  33. data/lib/rufio/plugin.rb +89 -0
  34. data/lib/rufio/plugin_config.rb +59 -0
  35. data/lib/rufio/plugin_manager.rb +84 -0
  36. data/lib/rufio/plugins/file_operations.rb +44 -0
  37. data/lib/rufio/selection_manager.rb +79 -0
  38. data/lib/rufio/terminal_ui.rb +630 -0
  39. data/lib/rufio/text_utils.rb +108 -0
  40. data/lib/rufio/version.rb +5 -0
  41. data/lib/rufio/zoxide_integration.rb +188 -0
  42. data/lib/rufio.rb +33 -0
  43. data/publish_gem.zsh +131 -0
  44. data/rufio.gemspec +40 -0
  45. data/test_delete/test1.txt +1 -0
  46. data/test_delete/test2.txt +1 -0
  47. metadata +189 -0
@@ -0,0 +1,828 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require_relative 'file_opener'
5
+ require_relative 'filter_manager'
6
+ require_relative 'selection_manager'
7
+ require_relative 'file_operations'
8
+ require_relative 'bookmark_manager'
9
+ require_relative 'zoxide_integration'
10
+ require_relative 'dialog_renderer'
11
+ require_relative 'logger'
12
+
13
+ module Rufio
14
+ class KeybindHandler
15
+ attr_reader :current_index
16
+
17
+ def filter_query
18
+ @filter_manager.filter_query
19
+ end
20
+
21
+ # ASCII character range constants
22
+ ASCII_PRINTABLE_START = 32
23
+ ASCII_PRINTABLE_END = 127
24
+ MULTIBYTE_THRESHOLD = 1
25
+
26
+ # Dialog size constants
27
+ CONFIRMATION_DIALOG_WIDTH = 45
28
+ DIALOG_BORDER_HEIGHT = 4
29
+
30
+ # File system operation constants
31
+ FILESYSTEM_SYNC_DELAY = 0.01 # 10ms wait for filesystem sync
32
+
33
+ def initialize
34
+ @current_index = 0
35
+ @directory_listing = nil
36
+ @terminal_ui = nil
37
+ @file_opener = FileOpener.new
38
+
39
+ # New manager classes
40
+ @filter_manager = FilterManager.new
41
+ @selection_manager = SelectionManager.new
42
+ @file_operations = FileOperations.new
43
+ @dialog_renderer = DialogRenderer.new
44
+ @bookmark_manager = BookmarkManager.new(Bookmark.new, @dialog_renderer)
45
+ @zoxide_integration = ZoxideIntegration.new(@dialog_renderer)
46
+
47
+ # Legacy fields for backward compatibility
48
+ @base_directory = nil
49
+ end
50
+
51
+ def set_directory_listing(directory_listing)
52
+ @directory_listing = directory_listing
53
+ @current_index = 0
54
+ end
55
+
56
+ def set_terminal_ui(terminal_ui)
57
+ @terminal_ui = terminal_ui
58
+ end
59
+
60
+ def set_base_directory(base_dir)
61
+ @base_directory = File.expand_path(base_dir)
62
+ end
63
+
64
+ def selected_items
65
+ @selection_manager.selected_items
66
+ end
67
+
68
+ def is_selected?(entry_name)
69
+ @selection_manager.selected?(entry_name)
70
+ end
71
+
72
+ def handle_key(key)
73
+ return false unless @directory_listing
74
+
75
+ # フィルターモード中は他のキーバインドを無効化
76
+ return handle_filter_input(key) if @filter_manager.filter_mode
77
+
78
+ case key
79
+ when 'j'
80
+ move_down
81
+ when 'k'
82
+ move_up
83
+ when 'h'
84
+ navigate_parent
85
+ when 'l', "\r", "\n" # l, Enter
86
+ navigate_enter
87
+ when 'g'
88
+ move_to_top
89
+ when 'G'
90
+ move_to_bottom
91
+ when 'r'
92
+ refresh
93
+ when 'o' # o
94
+ open_current_file
95
+ when 'e' # e - open directory in file explorer
96
+ open_directory_in_explorer
97
+ when 'f' # f - filter files
98
+ if @filter_manager.filter_active?
99
+ # フィルタが設定されている場合は再編集モードに入る
100
+ @filter_manager.restart_filter_mode(@directory_listing.list_entries)
101
+ else
102
+ # 新規フィルターモード開始
103
+ start_filter_mode
104
+ end
105
+ when ' ' # Space - toggle selection
106
+ toggle_selection
107
+ when "\e" # ESC
108
+ if @filter_manager.filter_active?
109
+ # フィルタが設定されている場合はクリア
110
+ clear_filter_mode
111
+ true
112
+ else
113
+ false
114
+ end
115
+ when 'q' # q
116
+ exit_request
117
+ when '/' # /
118
+ fzf_search
119
+ when 's' # s - file name search with fzf
120
+ fzf_search
121
+ when 'F' # F - file content search with rga
122
+ rga_search
123
+ when 'a' # a
124
+ create_file
125
+ when 'A' # A
126
+ create_directory
127
+ when 'm' # m - move selected files to base directory
128
+ move_selected_to_base
129
+ when 'p' # p - copy selected files to base directory
130
+ copy_selected_to_base
131
+ when 'x' # x - delete selected files
132
+ delete_selected_files
133
+ when 'b' # b - bookmark operations
134
+ show_bookmark_menu
135
+ when 'z' # z - zoxide history navigation
136
+ show_zoxide_menu
137
+ when '1', '2', '3', '4', '5', '6', '7', '8', '9' # number keys - go to bookmark
138
+ goto_bookmark(key.to_i)
139
+ when ':' # : - command mode
140
+ activate_command_mode
141
+ else
142
+ false # #{ConfigLoader.message('keybind.invalid_key')}
143
+ end
144
+ end
145
+
146
+ def select_index(index)
147
+ entries = get_active_entries
148
+ @current_index = [[index, 0].max, entries.length - 1].min
149
+ end
150
+
151
+ def current_entry
152
+ entries = get_active_entries
153
+ entries[@current_index]
154
+ end
155
+
156
+ def filter_active?
157
+ @filter_manager.filter_active?
158
+ end
159
+
160
+ def get_active_entries
161
+ if @filter_manager.filter_active?
162
+ @filter_manager.filtered_entries
163
+ else
164
+ @directory_listing&.list_entries || []
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ def move_down
171
+ entries = get_active_entries
172
+ @current_index = [@current_index + 1, entries.length - 1].min
173
+ true
174
+ end
175
+
176
+ def move_up
177
+ @current_index = [@current_index - 1, 0].max
178
+ true
179
+ end
180
+
181
+ def move_to_top
182
+ @current_index = 0
183
+ true
184
+ end
185
+
186
+ def move_to_bottom
187
+ entries = get_active_entries
188
+ @current_index = entries.length - 1
189
+ true
190
+ end
191
+
192
+ def navigate_enter
193
+ entry = current_entry
194
+ return false unless entry
195
+
196
+ if entry[:type] == 'directory'
197
+ result = @directory_listing.navigate_to(entry[:name])
198
+ if result
199
+ @current_index = 0 # select first entry in new directory
200
+ clear_filter_mode # ディレクトリ移動時にフィルタをリセット
201
+ end
202
+ result
203
+ else
204
+ # do nothing for files (file opening feature may be added in the future)
205
+ false
206
+ end
207
+ end
208
+
209
+ def navigate_parent
210
+ result = @directory_listing.navigate_to_parent
211
+ if result
212
+ @current_index = 0 # select first entry in parent directory
213
+ clear_filter_mode # ディレクトリ移動時にフィルタをリセット
214
+ end
215
+ result
216
+ end
217
+
218
+ def refresh
219
+ # ウィンドウサイズを更新して画面を再描画
220
+ @terminal_ui&.refresh_display
221
+
222
+ @directory_listing.refresh
223
+ if @filter_manager.filter_active?
224
+ # Re-apply filter with new directory contents
225
+ @filter_manager.update_entries(@directory_listing.list_entries)
226
+ else
227
+ # adjust index to stay within bounds after refresh
228
+ entries = @directory_listing.list_entries
229
+ @current_index = [@current_index, entries.length - 1].min if entries.any?
230
+ end
231
+ true
232
+ end
233
+
234
+ def open_current_file
235
+ entry = current_entry
236
+ return false unless entry
237
+
238
+ if entry[:type] == 'file'
239
+ @file_opener.open_file(entry[:path])
240
+ true
241
+ else
242
+ false
243
+ end
244
+ end
245
+
246
+ def open_directory_in_explorer
247
+ current_path = @directory_listing&.current_path || Dir.pwd
248
+ @file_opener.open_directory_in_explorer(current_path)
249
+ true
250
+ end
251
+
252
+ def exit_request
253
+ true # request exit
254
+ end
255
+
256
+ def fzf_search
257
+ return false unless fzf_available?
258
+
259
+ current_path = @directory_listing&.current_path || Dir.pwd
260
+
261
+ # fzfでファイル検索を実行
262
+ # Dir.chdirを使用してディレクトリ移動を安全に行う
263
+ selected_file = nil
264
+ Dir.chdir(current_path) do
265
+ selected_file = `find . -type f | fzf --preview 'cat {}'`.strip
266
+ end
267
+
268
+ # ファイルが選択された場合、そのファイルを開く
269
+ if !selected_file.empty?
270
+ full_path = File.expand_path(selected_file, current_path)
271
+ @file_opener.open_file(full_path) if File.exist?(full_path)
272
+ end
273
+
274
+ true
275
+ end
276
+
277
+ def fzf_available?
278
+ system('which fzf > /dev/null 2>&1')
279
+ end
280
+
281
+ def rga_search
282
+ return false unless rga_available?
283
+
284
+ current_path = @directory_listing&.current_path || Dir.pwd
285
+
286
+ # input search keyword
287
+ print ConfigLoader.message('keybind.search_text')
288
+ search_query = STDIN.gets.chomp
289
+ return false if search_query.empty?
290
+
291
+ # execute rga file content search
292
+ # Dir.chdirを使用してディレクトリ移動を安全に行う
293
+ search_results = nil
294
+ Dir.chdir(current_path) do
295
+ # Shellwords.escapeで検索クエリをエスケープ
296
+ escaped_query = Shellwords.escape(search_query)
297
+ search_results = `rga --line-number --with-filename #{escaped_query} . 2>/dev/null`
298
+ end
299
+
300
+ if search_results.empty?
301
+ puts "\n#{ConfigLoader.message('keybind.no_matches')}"
302
+ print ConfigLoader.message('keybind.press_any_key')
303
+ STDIN.getch
304
+ return true
305
+ end
306
+
307
+ # pass results to fzf for selection
308
+ selected_result = IO.popen('fzf', 'r+') do |fzf|
309
+ fzf.write(search_results)
310
+ fzf.close_write
311
+ fzf.read.strip
312
+ end
313
+
314
+ # extract file path and line number from selected result
315
+ if !selected_result.empty? && selected_result.match(/^(.+?):(\d+):/)
316
+ file_path = ::Regexp.last_match(1)
317
+ line_number = ::Regexp.last_match(2).to_i
318
+ full_path = File.expand_path(file_path, current_path)
319
+
320
+ @file_opener.open_file_with_line(full_path, line_number) if File.exist?(full_path)
321
+ end
322
+
323
+ true
324
+ end
325
+
326
+ def rga_available?
327
+ system('which rga > /dev/null 2>&1')
328
+ end
329
+
330
+ def start_filter_mode
331
+ @filter_manager.start_filter_mode(@directory_listing.list_entries)
332
+ @current_index = 0
333
+ true
334
+ end
335
+
336
+ def handle_filter_input(key)
337
+ result = @filter_manager.handle_filter_input(key)
338
+
339
+ case result
340
+ when :exit_clear
341
+ clear_filter_mode
342
+ when :exit_keep
343
+ exit_filter_mode_keep_filter
344
+ when :backspace_exit
345
+ clear_filter_mode
346
+ when :continue
347
+ @current_index = [@current_index, [@filter_manager.filtered_entries.length - 1, 0].max].min
348
+ end
349
+
350
+ true
351
+ end
352
+
353
+ def exit_filter_mode_keep_filter
354
+ # フィルタを維持したまま通常モードに戻る
355
+ @filter_manager.exit_filter_mode_keep_filter
356
+ end
357
+
358
+ def clear_filter_mode
359
+ # フィルタをクリアして通常モードに戻る
360
+ @filter_manager.clear_filter
361
+ @current_index = 0
362
+ end
363
+
364
+ def exit_filter_mode
365
+ # 既存メソッド(後方互換用)
366
+ clear_filter_mode
367
+ end
368
+
369
+ def create_file
370
+ current_path = @directory_listing&.current_path || Dir.pwd
371
+
372
+ # カーソルを画面下部に移動して入力プロンプトを表示
373
+ move_to_input_line
374
+ print ConfigLoader.message('keybind.input_filename')
375
+ STDOUT.flush
376
+
377
+ filename = read_line_with_escape
378
+ return false if filename.nil? || filename.empty?
379
+
380
+ # FileOperationsを使用してファイルを作成
381
+ result = @file_operations.create_file(current_path, filename)
382
+
383
+ # ディレクトリ表示を更新
384
+ if result.success
385
+ @directory_listing.refresh
386
+
387
+ # 作成したファイルを選択状態にする
388
+ entries = @directory_listing.list_entries
389
+ new_file_index = entries.find_index { |entry| entry[:name] == filename }
390
+ @current_index = new_file_index if new_file_index
391
+ end
392
+
393
+ # 結果を表示
394
+ puts "\n#{result.message}"
395
+ print ConfigLoader.message('keybind.press_any_key')
396
+ STDIN.getch
397
+ result.success
398
+ end
399
+
400
+ def create_directory
401
+ current_path = @directory_listing&.current_path || Dir.pwd
402
+
403
+ # カーソルを画面下部に移動して入力プロンプトを表示
404
+ move_to_input_line
405
+ print ConfigLoader.message('keybind.input_dirname')
406
+ STDOUT.flush
407
+
408
+ dirname = read_line_with_escape
409
+ return false if dirname.nil? || dirname.empty?
410
+
411
+ # FileOperationsを使用してディレクトリを作成
412
+ result = @file_operations.create_directory(current_path, dirname)
413
+
414
+ # ディレクトリ表示を更新
415
+ if result.success
416
+ @directory_listing.refresh
417
+
418
+ # 作成したディレクトリを選択状態にする
419
+ entries = @directory_listing.list_entries
420
+ new_dir_index = entries.find_index { |entry| entry[:name] == dirname }
421
+ @current_index = new_dir_index if new_dir_index
422
+ end
423
+
424
+ # 結果を表示
425
+ puts "\n#{result.message}"
426
+ print ConfigLoader.message('keybind.press_any_key')
427
+ STDIN.getch
428
+ result.success
429
+ end
430
+
431
+ def toggle_selection
432
+ entry = current_entry
433
+ return false unless entry
434
+
435
+ @selection_manager.toggle_selection(entry)
436
+ true
437
+ end
438
+
439
+ def move_selected_to_base
440
+ return false if @selection_manager.empty? || @base_directory.nil?
441
+
442
+ if show_confirmation_dialog('Move', @selection_manager.count)
443
+ current_path = @directory_listing&.current_path || Dir.pwd
444
+ result = @file_operations.move(@selection_manager.selected_items, current_path, @base_directory)
445
+
446
+ # Show result and refresh
447
+ show_operation_result(result)
448
+ @selection_manager.clear
449
+ @directory_listing.refresh if @directory_listing
450
+ true
451
+ else
452
+ false
453
+ end
454
+ end
455
+
456
+ def copy_selected_to_base
457
+ return false if @selection_manager.empty? || @base_directory.nil?
458
+
459
+ if show_confirmation_dialog('Copy', @selection_manager.count)
460
+ current_path = @directory_listing&.current_path || Dir.pwd
461
+ result = @file_operations.copy(@selection_manager.selected_items, current_path, @base_directory)
462
+
463
+ # Show result and refresh
464
+ show_operation_result(result)
465
+ @selection_manager.clear
466
+ @directory_listing.refresh if @directory_listing
467
+ true
468
+ else
469
+ false
470
+ end
471
+ end
472
+
473
+ def show_confirmation_dialog(operation, count)
474
+ print "\n#{operation} #{count} item(s)? (y/n): "
475
+ response = STDIN.gets.chomp.downcase
476
+ %w[y yes].include?(response)
477
+ end
478
+
479
+ # Helper method to show operation result
480
+ def show_operation_result(result)
481
+ if result.errors.any?
482
+ puts "\n#{result.message}"
483
+ result.errors.each { |error| puts " - #{error}" }
484
+ else
485
+ puts "\n#{result.message}"
486
+ end
487
+ print 'Press any key to continue...'
488
+ STDIN.getch
489
+ end
490
+
491
+ def delete_selected_files
492
+ return false if @selection_manager.empty?
493
+
494
+ if show_delete_confirmation(@selection_manager.count)
495
+ current_path = @directory_listing&.current_path || Dir.pwd
496
+ result = @file_operations.delete(@selection_manager.selected_items, current_path)
497
+
498
+ # Show detailed delete result
499
+ show_deletion_result(result.count, @selection_manager.count, result.errors)
500
+ @selection_manager.clear
501
+ @directory_listing.refresh if @directory_listing
502
+ true
503
+ else
504
+ false
505
+ end
506
+ end
507
+
508
+ def show_delete_confirmation(count)
509
+ show_floating_delete_confirmation(count)
510
+ end
511
+
512
+ def show_floating_delete_confirmation(count)
513
+ # コンテンツの準備
514
+ title = 'Delete Confirmation'
515
+ content_lines = [
516
+ '',
517
+ "Delete #{count} item(s)?",
518
+ '',
519
+ ' [Y]es - Delete',
520
+ ' [N]o - Cancel',
521
+ ''
522
+ ]
523
+
524
+ # ダイアログのサイズ設定(コンテンツに合わせて調整)
525
+ dialog_width = CONFIRMATION_DIALOG_WIDTH
526
+ # タイトルあり: 上枠1 + タイトル1 + 区切り1 + コンテンツ + 下枠1
527
+ dialog_height = DIALOG_BORDER_HEIGHT + content_lines.length
528
+
529
+ # ダイアログの位置を中央に設定
530
+ x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
531
+
532
+ # ダイアログの描画
533
+ @dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
534
+ border_color: "\e[31m", # 赤色(警告)
535
+ title_color: "\e[1;31m", # 太字赤色
536
+ content_color: "\e[37m" # 白色
537
+ })
538
+
539
+ # フラッシュしてユーザーの注意を引く
540
+ print "\a" # ベル音
541
+
542
+ # キー入力待機
543
+ loop do
544
+ input = STDIN.getch.downcase
545
+
546
+ case input
547
+ when 'y'
548
+ # ダイアログをクリア
549
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
550
+ @terminal_ui&.refresh_display # 画面を再描画
551
+ return true
552
+ when 'n', "\e", "\x03" # n, ESC, Ctrl+C
553
+ # ダイアログをクリア
554
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
555
+ @terminal_ui&.refresh_display # 画面を再描画
556
+ return false
557
+ when 'q' # qキーでもキャンセル
558
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
559
+ @terminal_ui&.refresh_display
560
+ return false
561
+ end
562
+ # 無効なキー入力の場合は再度ループ
563
+ end
564
+ end
565
+
566
+ def perform_delete_operation(items)
567
+ Logger.debug('Starting delete operation', context: { items: items, count: items.length })
568
+
569
+ success_count = 0
570
+ error_messages = []
571
+ current_path = @directory_listing&.current_path || Dir.pwd
572
+
573
+ items.each do |item_name|
574
+ item_path = File.join(current_path, item_name)
575
+ Logger.debug("Processing deletion", context: { item: item_name, path: item_path })
576
+
577
+ begin
578
+ # ファイル/ディレクトリの存在確認
579
+ unless File.exist?(item_path)
580
+ error_messages << "#{item_name}: File not found"
581
+ Logger.warn("File not found for deletion", context: { item: item_name })
582
+ next
583
+ end
584
+
585
+ is_directory = File.directory?(item_path)
586
+ Logger.debug("Item type determined", context: { item: item_name, type: is_directory ? 'Directory' : 'File' })
587
+
588
+ if is_directory
589
+ FileUtils.rm_rf(item_path)
590
+ else
591
+ FileUtils.rm(item_path)
592
+ end
593
+
594
+ # 削除が実際に成功したかを確認
595
+ sleep(FILESYSTEM_SYNC_DELAY) # wait for filesystem sync
596
+ still_exists = File.exist?(item_path)
597
+
598
+ if still_exists
599
+ error_messages << "#{item_name}: Deletion failed"
600
+ Logger.error("Deletion failed", context: { item: item_name, still_exists: true })
601
+ else
602
+ success_count += 1
603
+ Logger.debug("Deletion successful", context: { item: item_name })
604
+ end
605
+ rescue StandardError => e
606
+ error_messages << "#{item_name}: #{e.message}"
607
+ Logger.error("Exception during deletion", exception: e, context: { item: item_name })
608
+ end
609
+ end
610
+
611
+ Logger.debug('Delete operation completed', context: {
612
+ success_count: success_count,
613
+ total_count: items.length,
614
+ error_count: error_messages.length,
615
+ has_errors: !error_messages.empty?
616
+ })
617
+
618
+ # 削除結果をフローティングウィンドウで表示
619
+ show_deletion_result(success_count, items.length, error_messages)
620
+
621
+ # 削除完了後の処理
622
+ @selection_manager.clear
623
+ @directory_listing.refresh if @directory_listing
624
+
625
+ true
626
+ end
627
+
628
+ def show_deletion_result(success_count, total_count, error_messages = [])
629
+ Logger.debug('Showing deletion result dialog', context: {
630
+ success_count: success_count,
631
+ total_count: total_count,
632
+ error_messages: error_messages
633
+ })
634
+
635
+ # エラーメッセージがある場合はダイアログサイズを拡大
636
+ has_errors = !error_messages.empty?
637
+ dialog_width = has_errors ? 50 : 35
638
+ dialog_height = has_errors ? [8 + error_messages.length, 15].min : 6
639
+
640
+ # ダイアログの位置を中央に設定
641
+ x, y = @dialog_renderer.calculate_center(dialog_width, dialog_height)
642
+
643
+ # 成功・失敗に応じた色設定
644
+ if success_count == total_count && !has_errors
645
+ border_color = "\e[32m" # 緑色(成功)
646
+ title_color = "\e[1;32m" # 太字緑色
647
+ title = 'Delete Complete'
648
+ message = "Deleted #{success_count} item(s)"
649
+ else
650
+ border_color = "\e[33m" # 黄色(警告)
651
+ title_color = "\e[1;33m" # 太字黄色
652
+ title = 'Delete Result'
653
+ if success_count == total_count && has_errors
654
+ # 全て削除成功したがエラーメッセージがある場合(本来ここに入らないはず)
655
+ message = "#{success_count} deleted (with error info)"
656
+ else
657
+ failed_count = total_count - success_count
658
+ message = "#{success_count} deleted, #{failed_count} failed"
659
+ end
660
+ end
661
+
662
+ # コンテンツの準備
663
+ content_lines = ['', message]
664
+
665
+ # エラーメッセージがある場合は追加
666
+ if has_errors
667
+ content_lines << ''
668
+ content_lines << 'Error details:'
669
+ error_messages.each { |error| content_lines << " #{error}" }
670
+ end
671
+
672
+ content_lines << ''
673
+ content_lines << 'Press any key to continue...'
674
+
675
+ # ダイアログの描画
676
+ @dialog_renderer.draw_floating_window(x, y, dialog_width, dialog_height, title, content_lines, {
677
+ border_color: border_color,
678
+ title_color: title_color,
679
+ content_color: "\e[37m"
680
+ })
681
+
682
+ # キー入力待機
683
+ STDIN.getch
684
+
685
+ # ダイアログをクリア
686
+ @dialog_renderer.clear_area(x, y, dialog_width, dialog_height)
687
+ @terminal_ui&.refresh_display
688
+ end
689
+
690
+
691
+ # ブックマーク機能
692
+ def show_bookmark_menu
693
+ current_path = @directory_listing&.current_path || Dir.pwd
694
+ result = @bookmark_manager.show_menu(current_path)
695
+
696
+ @terminal_ui&.refresh_display
697
+
698
+ case result[:action]
699
+ when :add
700
+ success = @bookmark_manager.add_interactive(result[:path])
701
+ wait_for_keypress
702
+ success
703
+ when :list
704
+ @bookmark_manager.list_interactive
705
+ wait_for_keypress
706
+ true
707
+ when :remove
708
+ @bookmark_manager.remove_interactive
709
+ wait_for_keypress
710
+ true
711
+ when :navigate
712
+ goto_bookmark(result[:number])
713
+ else
714
+ false
715
+ end
716
+ end
717
+
718
+ def goto_bookmark(number)
719
+ bookmark = @bookmark_manager.find_by_number(number)
720
+
721
+ return show_error_and_wait('bookmark.not_found', number) unless bookmark
722
+ return show_error_and_wait('bookmark.path_not_exist', bookmark[:path]) unless @bookmark_manager.path_exists?(bookmark)
723
+
724
+ # ディレクトリに移動
725
+ if navigate_to_directory(bookmark[:path])
726
+ puts "\n#{ConfigLoader.message('bookmark.navigated') || 'Navigated to bookmark'}: #{bookmark[:name]}"
727
+ sleep(0.5) # 短時間表示
728
+ true
729
+ else
730
+ show_error_and_wait('bookmark.navigate_failed', bookmark[:name])
731
+ end
732
+ end
733
+
734
+ # ヘルパーメソッド
735
+ def wait_for_keypress
736
+ print ConfigLoader.message('keybind.press_any_key') || 'Press any key to continue...'
737
+ STDIN.getch
738
+ end
739
+
740
+ def show_error_and_wait(message_key, value)
741
+ puts "\n#{ConfigLoader.message(message_key) || message_key}: #{value}"
742
+ wait_for_keypress
743
+ false
744
+ end
745
+
746
+ def navigate_to_directory(path)
747
+ result = @directory_listing.navigate_to_path(path)
748
+ if result
749
+ @current_index = 0
750
+ clear_filter_mode
751
+ true
752
+ else
753
+ false
754
+ end
755
+ end
756
+
757
+ # zoxide 機能
758
+ def show_zoxide_menu
759
+ selected_path = @zoxide_integration.show_menu
760
+
761
+ if selected_path && Dir.exist?(selected_path)
762
+ if navigate_to_directory(selected_path)
763
+ @zoxide_integration.add_to_history(selected_path)
764
+ true
765
+ else
766
+ false
767
+ end
768
+ else
769
+ @terminal_ui&.refresh_display
770
+ false
771
+ end
772
+ end
773
+
774
+ # コマンドモードを起動
775
+ def activate_command_mode
776
+ @terminal_ui&.activate_command_mode
777
+ true
778
+ end
779
+
780
+ private
781
+
782
+ # カーソルを画面下部の入力行に移動
783
+ def move_to_input_line
784
+ # 画面の最終行にカーソルを移動
785
+ # terminal_uiから画面の高さを取得できない場合は、24行目(デフォルト)を使用
786
+ screen_height = @terminal_ui&.instance_variable_get(:@screen_height) || 24
787
+ print "\e[#{screen_height};1H" # 最終行の先頭にカーソル移動
788
+ print "\e[2K" # 行全体をクリア
789
+ end
790
+
791
+ # Escキーでキャンセル可能な入力処理
792
+ # 戻り値: 入力された文字列 (Escでキャンセルした場合はnil)
793
+ def read_line_with_escape
794
+ require 'io/console'
795
+ input = []
796
+
797
+ loop do
798
+ char = STDIN.getch
799
+
800
+ case char
801
+ when "\e" # Escape
802
+ # 入力をクリア
803
+ print "\r" + ' ' * (input.length + 50) + "\r"
804
+ return nil
805
+ when "\r", "\n" # Enter
806
+ puts
807
+ return input.join
808
+ when "\u007F", "\b" # Backspace/Delete
809
+ unless input.empty?
810
+ input.pop
811
+ # カーソルを1つ戻して文字を消去
812
+ print "\b \b"
813
+ end
814
+ when "\u0003" # Ctrl+C
815
+ puts
816
+ raise Interrupt
817
+ else
818
+ # 印字可能文字のみ受け付ける
819
+ if char.ord >= ASCII_PRINTABLE_START && char.ord < ASCII_PRINTABLE_END ||
820
+ char.bytesize > MULTIBYTE_THRESHOLD # マルチバイト文字(日本語など)
821
+ input << char
822
+ print char
823
+ end
824
+ end
825
+ end
826
+ end
827
+ end
828
+ end